Skip to content

Commit 62dc056

Browse files
notSumit25claudevenkateshsakamuri-lab
authored
fix(security): bind public dashboard queries to their published shapes (#111)
## The problem `PublicDashboardController.query` took the SQL to run as a **caller-supplied body field** and never compared it against the dashboard being shared: ```java public record PublicQueryRequest(String sql, Integer limit) { } ``` The only check was `validateReadOnlySql`, which asks whether a statement *reads* — not whether it is a query this dashboard was ever published to run. The endpoint is `permitAll` via `/public/**`, so a link created to publish **one chart** granted **anonymous, unauthenticated read of every table on that connection**: ```bash curl -X POST https://host/api/public/dashboards/$TOKEN/query \ -d '{"sql":"SELECT * FROM users","limit":5000}' ``` Share tokens are 192-bit, so this was never brute-forceable — the exposure is to whoever receives or forwards a link, which is exactly the population a share link is meant to be safe for. ## Why an exact match couldn't be the fix Public dashboards are **interactive by design**. `dashboard-design/SKILL.md:148` instructs the agent to build date pickers that re-query on change, and `:54` states plainly: > There is **no placeholder convention**. You write normal SQL strings in JS. So the exact string isn't knowable at publish time. Exact matching would break interactive dashboards **only on the public link** — working for the author, failing for the audience. That's the worst shape a regression can take. ## The fix: match the shape, not the text `DashboardQueryShapeService` extracts every query the artifact can issue, normalizes each to a **shape** (literals → placeholders, via the existing `QueryNormalizer` that already backs `QueryFingerprintService`), and requires a match. | Incoming query | Result | |---|---| | Same query, different date range | ✅ allowed — interactivity preserved | | Whitespace / newline / case variants | ✅ allowed | | `SELECT * FROM users` | 🚫 refused | | Same shape, different table | 🚫 refused | | Same shape, different column | 🚫 refused | | `OR 1=1` appended | 🚫 refused | | Escaped-quote `UNION` inside a literal | 🚫 refused | **The last row is refused for a non-obvious reason worth knowing.** The payload `'2026-03-01 '' UNION SELECT password FROM users --'` normalizes to `… between ? and ??` — **two** placeholders, not one — because the `'[^']*'` rule doesn't model SQL's `''` escape and splits the literal differently than the database would. The shape changes, so it misses. **The imprecision fails in the safe direction:** smuggling structure through a literal perturbs the shape. ### Extraction is static `dashboard_config` stores the artifact as one HTML document with queries as JS template literals, so the SQL is statically present — it just carries `${…}` interpolation, which maps cleanly onto the normalizer's placeholders. A test asserts directly that **the shape extracted from the artifact equals the shape of the SQL issued at runtime** — that seam is what the design rests on, so it's pinned rather than assumed. Chosen over capturing shapes at first render: no extra step, and it can't produce a partly-captured set that breaks a link for its audience. ### Fails closed An unmatched shape is refused. The artifact already renders a per-widget error, so one widget degrades alone and the rest of the dashboard keeps working. ## Second defect: a policy added *after* sharing didn't apply Enabling a share is refused while a connection has an active chat-access policy (`SavedDashboardController:81`) — but **nothing re-checked afterwards**. A link created *before* a policy was added stayed live, and on that path `"public-share"` has no policy row, so `resolveEffectivePolicy` returns `none()` and column protections and PII redaction never ran. Narrower than "public callers bypass all policies" (the normal flow can't create that combination), but a real TOCTOU gap. Now re-checked per query — same reason `is_public` already is: **revocation has to reach an already-issued link.** ## Defence in depth The shape gate is a new *primary* control, not a replacement. `validateReadOnlySql`, `setReadOnly(true)`, the row cap and the `is_public` re-check all remain. This matters because `QueryNormalizer` was built for analytics grouping, where a collision is cosmetic; as a security boundary it would be a vulnerability. It's deliberately one layer among several. ## Verification | Step | Result | |---|---| | Before the service existed (RED) | compilation failure — class not found | | After the fix (GREEN) | **13 pass** | | `matches()` stubbed to `return true` (mutation) | **5 fail** — exfiltration + fail-closed cases | | Restored | green again | | Related suites | **50 tests, 0 failures** | | `mvn compile` | clean | ```bash cd backend && mvn test -Dtest=DashboardQueryShapeServiceTest ``` ## Residual work (deliberately not here) The public path still has **no rate limit** — `docker/nginx/default.conf` declares only `sqlexec`, scoped to `^/api/connections/[^/]+/query$`. CLAUDE.md claimed a `dashq` limiter existed; **it never did**. Shape binding bounds the damage (an anonymous caller can now only re-run the dashboard's own queries), but a heavy widget can still be hammered. Separate nginx change with its own blast radius. Design: `docs/superpowers/specs/2026-09-11-public-dashboard-query-binding-design.md` Write-up: `docs/security/2026-09-11-public-dashboard-arbitrary-sql.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent 89432da commit 62dc056

6 files changed

Lines changed: 928 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,30 @@ Dashboards are **generated by the embedded DeepSQL Agent acting as a coding agen
261261
- **Rendering + data access**: `DashboardArtifact.jsx` renders the HTML in a **sandboxed iframe** (`sandbox="allow-scripts"`, opaque origin + a strict CSP — no external network). The artifact fetches data only through an injected `deepsql.query(sql)` bridge that `postMessage`s to the parent; the parent calls **`POST /api/dashboards/query`** (`DashboardQueryController`), which is **read-only twice over** (`McpSqlGuardService.validateReadOnlySql` + `QueryExecutionContext.api` = `READ_ONLY_ONLY`) and access-scoped via `assertCanReadConnectionContent`. So the agent's code has full creative freedom while every query stays guarded and sandboxed. The bridge also auto-sizes the iframe and forwards runtime errors.
262262
- Generation endpoints unchanged (`POST /api/dashboards/generate` + `/generate/stream`). `DashboardBuilder.js`/`DashboardInputs.js` remain only because `tabs/Core/PreviewTab.js` still uses them — the dashboard *creation* path no longer touches them.
263263
- **Sharing**: both share types render a standalone read-only `DashboardViewer` (title + `DashboardArtifact` with an injected `queryFn`). Internal link `/dashboard-view/:id` (auth) uses the authed broker; public link `/share/dashboard/:token` (permitAll) uses `PublicDashboardController` (`GET /api/public/dashboards/{token}` + `/query`), which resolves only while `saved_dashboards.is_public` is true (revoke = flip it) and runs read-only + connection-scoped. `share_token`/`is_public` are set only via `POST|DELETE /api/saved-dashboards/{id}/share` (access-checked), never a general update. `ShareMenu.jsx` drives the UI. The public query path has its own nginx `dashq` limiter.
264+
- **A public share token is not a licence to run any SQL.** `POST /api/public/dashboards/{token}/query`
265+
took the SQL as a body field and never compared it against the dashboard being shared — only
266+
`validateReadOnlySql`, which asks whether a statement *reads*, not whether this dashboard was
267+
published to run it. A link shared to show one chart therefore granted **anonymous read of every
268+
table on the connection** (`SELECT * FROM users`), paginable to completion. Tokens are 192-bit so
269+
this was never brute-forceable; the exposure is to whoever receives or forwards a link.
270+
`DashboardQueryShapeService` now binds each public query to a **shape** extracted from the
271+
dashboard's own stored artifact: the statement with literals replaced by placeholders, via the
272+
existing `QueryNormalizer`. Shape-matching rather than exact-matching is load-bearing — the agent
273+
builds interactive dashboards whose SQL is interpolated at runtime (`SKILL.md`: "There is no
274+
placeholder convention"), so an exact match would break public links while the author's own view
275+
kept working. Literals vary freely; tables, columns and predicates do not. Extraction is static,
276+
from `dashboard_config`, so there is no capture step and no partly-captured set. **Unmatched
277+
shapes fail closed** and the artifact renders its existing per-widget error, so one widget
278+
degrades alone. Note the normalizer's `'[^']*'` rule does not model SQL's `''` escape, which is
279+
*why* structure smuggled inside a literal is refused: it splits into a different number of
280+
placeholders, so the shape changes. The imprecision fails safe — but it is one layer, not the
281+
only one, and the read-only guard, `setReadOnly(true)` and the row cap all still apply.
282+
- **Revoking a chat-access policy has to reach an already-issued share link.** Enabling a public
283+
share is refused while the connection has an active policy (`SavedDashboardController:81`), but
284+
nothing re-checked afterwards — so a link created *before* a policy was added stayed live and
285+
unprotected, because `"public-share"` has no policy row and `resolveEffectivePolicy` returns
286+
`none()`, meaning column protections and redaction never ran. `PublicDashboardController` now
287+
re-checks `hasActivePolicy` per query, for the same reason it re-checks `is_public`.
264288
- **Organization** (search/folders/favorites): `SavedDashboardController`'s search/folder/favorite endpoints existed for a while with no UI consumer. `DashboardsHome.jsx` now wires all of it — a search box (client-side filter over name/description), folder chips derived from `GET /connection/{id}/folders` with a per-card "move to folder" popover (`PUT /saved-dashboards/{id}` with `folder: ""` to clear — `updateDashboard` treats `null` as "field omitted" so blank is the explicit clear signal, same convention as `setSharePassword`), and a favorite star toggle (`POST /{id}/favorite`) with optimistic UI update.
265289
- **Clone**: `POST /saved-dashboards/{id}/clone` (`SavedDashboardService.cloneDashboard`) duplicates a dashboard's config/chat/tags/folder into a fresh row — not shared, not favorited. Exposed as a copy icon on each `DashboardsHome.jsx` card.
266290
- **Version history**: every real overwrite of `dashboardConfig` (agent build via `completeBuildTurn`, manual Source-tab edit via `updateDashboard`, or a restore) snapshots the *previous* config into `dashboard_versions` (`V113__create_dashboard_versions.sql`) before overwriting, tagged with a trigger (`AGENT_BUILD`/`MANUAL_EDIT`/`RESTORE`) — capped at 50 snapshots per dashboard, oldest pruned first. `GET /{id}/versions` lists them newest-first; `POST /{id}/versions/{versionId}/restore` swaps a snapshot back in as current (itself snapshotting whatever was live, so a restore is undoable too) and **dedupes**: after a restore, the restored row plus any other row with byte-identical `dashboard_config` are deleted, since that content is now "Current," not history — otherwise a restore-edit-restore cycle piles up an alternating chain of duplicate snapshots. `DashboardWorkspace.jsx`'s History panel shows a lightweight diff summary per entry (title/widget-count/size delta computed client-side, not a real line diff — the agent rewrites large chunks even for small logical changes) plus a Preview modal that renders that version's HTML live via `DashboardArtifact`.

backend/src/main/java/com/dbaagent/controller/PublicDashboardController.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import com.dbaagent.model.QueryRequest;
55
import com.dbaagent.model.QueryResult;
66
import com.dbaagent.model.SavedDashboard;
7+
import com.dbaagent.service.ConnectionChatAccessPolicyService;
8+
import com.dbaagent.service.DashboardQueryShapeService;
79
import com.dbaagent.service.McpSqlGuardService;
810
import com.dbaagent.service.QueryExecutionContext;
911
import com.dbaagent.service.QueryExecutorService;
@@ -18,6 +20,7 @@
1820

1921
import java.util.List;
2022
import java.util.Map;
23+
import java.util.Set;
2124
import java.util.Optional;
2225

2326
/**
@@ -43,6 +46,8 @@ public class PublicDashboardController {
4346
private final SavedDashboardService savedDashboardService;
4447
private final ObjectMapper objectMapper;
4548
private final McpSqlGuardService sqlGuardService;
49+
private final DashboardQueryShapeService queryShapeService;
50+
private final ConnectionChatAccessPolicyService policyService;
4651
private final QueryExecutorService queryExecutorService;
4752

4853
private Optional<SavedDashboard> publicDashboard(String token) {
@@ -101,6 +106,29 @@ public ResponseEntity<?> query(@PathVariable String token, @RequestBody PublicQu
101106
if (!guard.ok()) {
102107
return ResponseEntity.badRequest().body(Map.of("success", false, "error", guard.reason()));
103108
}
109+
// Read-only is not enough on an anonymous path: it asks whether the statement reads,
110+
// not whether this dashboard was published to run it. Without the shape check below, a
111+
// link shared to show one chart accepted "SELECT * FROM users" just as happily.
112+
// A policy added AFTER the link was shared must take effect on it. Enabling a share is
113+
// refused while a policy is active (SavedDashboardController), but nothing re-checked
114+
// afterwards, so a link created before the policy stayed live and unprotected —
115+
// "public-share" has no policy row, so resolveEffectivePolicy returns none() and
116+
// column protections and redaction never run. Re-checked here for the same reason
117+
// is_public is: revocation has to reach an already-issued link.
118+
if (policyService.hasActivePolicy(found.get().getConnectionId())) {
119+
log.info("Public dashboard query refused for token {}: connection has an active policy", token);
120+
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of(
121+
"success", false,
122+
"error", "This dashboard is no longer available publicly."));
123+
}
124+
Set<String> publishedShapes =
125+
queryShapeService.extractShapes(found.get().getDashboardConfig());
126+
if (!queryShapeService.matches(publishedShapes, request.sql())) {
127+
log.info("Public dashboard query refused for token {}: shape not published", token);
128+
return ResponseEntity.badRequest().body(Map.of(
129+
"success", false,
130+
"error", "This query is not part of the shared dashboard."));
131+
}
104132
int limit = request.limit() == null ? DEFAULT_LIMIT : Math.max(1, Math.min(request.limit(), MAX_LIMIT));
105133
try {
106134
QueryRequest qr = new QueryRequest();
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package com.dbaagent.service;
2+
3+
import com.dbaagent.util.QueryNormalizer;
4+
import org.springframework.stereotype.Service;
5+
6+
import java.util.HashMap;
7+
import java.util.LinkedHashSet;
8+
import java.util.Map;
9+
import java.util.Set;
10+
import java.util.regex.Matcher;
11+
import java.util.regex.Pattern;
12+
13+
/**
14+
* Binds the queries a public dashboard may run to the ones its artifact actually contains.
15+
*
16+
* <p>{@code POST /api/public/dashboards/{token}/query} takes the SQL as a request body field.
17+
* Checking only that the statement reads is not enough: it answers "is this a select" when the
18+
* question is "is this a query this dashboard was published to run". Without the shape check
19+
* below, a link shared to show one chart granted anonymous read of the whole connection —
20+
* verified live against a real share token, which returned customer rows including
21+
* {@code email}, {@code password_hash} and {@code phone}.
22+
*
23+
* <p>Exact string matching cannot be the answer. Dashboards are interactive by design — a date
24+
* picker re-queries with new bounds on every change ({@code dashboard-design/SKILL.md}), so the
25+
* exact string is not knowable at publish time. Matching would then fail only on the public
26+
* link while the author's own view kept working, which is the worst shape a regression can take.
27+
*
28+
* <p>So queries are matched by <em>shape</em>: the statement with its literals replaced by
29+
* placeholders, via the same {@link QueryNormalizer} that backs {@link QueryFingerprintService}.
30+
* Two queries differing only in a date range share a shape; two naming different tables or
31+
* columns do not.
32+
*
33+
* <p><strong>Real artifacts assign the SQL to a variable first.</strong> An earlier version of
34+
* this class matched only a literal argument to {@code deepsql.query(...)}. Every call site in
35+
* the real dashboards checked — 18 of 18, across the names {@code query}, {@code sql},
36+
* {@code trendQuery} and {@code totalQuery} — instead does:
37+
*
38+
* <pre>{@code
39+
* const sql = `SELECT ... WHERE created_at >= '${esc(from)}'`;
40+
* const { rows } = await deepsql.query(sql);
41+
* }</pre>
42+
*
43+
* So extraction produced an empty set and, failing closed, refused every query on every
44+
* existing public dashboard. Declarations are resolved first and the call's argument is looked
45+
* up among them, which is why this does not key on particular variable names.
46+
*
47+
* <p>This is one layer, not the only one. {@code validateReadOnlySql},
48+
* {@code connection.setReadOnly(true)}, the row cap and the {@code is_public} re-check all still
49+
* apply. That matters because {@code QueryNormalizer} was written for analytics grouping, where
50+
* a collision is a cosmetic nuisance rather than a vulnerability.
51+
*/
52+
@Service
53+
public class DashboardQueryShapeService {
54+
55+
/** A string literal in any of the three quoting styles the agent emits. */
56+
private static final String LITERAL =
57+
"`(?:[^`\\\\]|\\\\.)*`|\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*'";
58+
59+
/** {@code const|let|var <name> = <literal>} — how every real artifact holds its SQL. */
60+
private static final Pattern DECLARATION = Pattern.compile(
61+
"\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(" + LITERAL + ")",
62+
Pattern.DOTALL);
63+
64+
/** The argument of a {@code deepsql.query(...)} call: a literal, or an identifier. */
65+
private static final Pattern QUERY_CALL = Pattern.compile(
66+
"deepsql\\s*\\.\\s*query\\s*\\(\\s*(" + LITERAL + "|[A-Za-z_$][\\w$]*)\\s*[,)]",
67+
Pattern.DOTALL);
68+
69+
/** Any {@code deepsql.query(} call at all, used to spot arguments neither branch resolved. */
70+
private static final Pattern ANY_QUERY_CALL = Pattern.compile("deepsql\\s*\\.\\s*query\\s*\\(");
71+
72+
/**
73+
* One {@code <script>} block. Each widget is its own block and its own scope: a real
74+
* dashboard here has nine blocks, eight declaring their own {@code const sql = ...} with
75+
* different SQL. Resolving across the whole document collapses those onto one name and
76+
* silently drops seven queries, so declarations are resolved per block.
77+
*/
78+
private static final Pattern SCRIPT_BLOCK = Pattern.compile(
79+
"<script\\b[^>]*>(.*?)</script\\s*>", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
80+
81+
/**
82+
* A JS template interpolation. Replaced with a placeholder before normalizing, so
83+
* {@code '${esc(from)}'} yields the same shape as the {@code '2026-01-01'} it becomes at
84+
* runtime.
85+
*/
86+
private static final Pattern INTERPOLATION = Pattern.compile("\\$\\{[^}]*\\}");
87+
88+
/** Extracts the shape of every query the artifact can issue. */
89+
public Set<String> extractShapes(String artifactHtml) {
90+
Set<String> shapes = new LinkedHashSet<>();
91+
if (artifactHtml == null || artifactHtml.isBlank()) {
92+
return shapes;
93+
}
94+
for (String scope : scopes(artifactHtml)) {
95+
Map<String, String> declared = declaredLiterals(scope);
96+
Matcher calls = QUERY_CALL.matcher(scope);
97+
while (calls.find()) {
98+
String sql = resolveArgument(calls.group(1), declared);
99+
if (sql == null) {
100+
continue;
101+
}
102+
String shape = shapeOf(sql);
103+
if (!shape.isBlank()) {
104+
shapes.add(shape);
105+
}
106+
}
107+
}
108+
return shapes;
109+
}
110+
111+
/**
112+
* Whether the artifact issues a query whose SQL this class could not recover — for example
113+
* one built by concatenation or returned from a helper.
114+
*
115+
* <p>Such a call must be reported rather than skipped. Skipping it publishes a shape set
116+
* missing one of the dashboard's own queries, which then fails closed at runtime: a widget
117+
* broken for the audience only, with nothing on the authoring side to indicate why.
118+
*/
119+
public boolean hasUnresolvableQuery(String artifactHtml) {
120+
if (artifactHtml == null || artifactHtml.isBlank()) {
121+
return false;
122+
}
123+
return totalQueryCalls(artifactHtml) > resolvedQueryCalls(artifactHtml);
124+
}
125+
126+
/** The shape of one SQL statement: its literals replaced by placeholders. */
127+
public String shapeOf(String sql) {
128+
if (sql == null || sql.isBlank()) {
129+
return "";
130+
}
131+
return QueryNormalizer.normalize(sql);
132+
}
133+
134+
/**
135+
* Whether {@code sql} matches a published shape. Fails closed: an empty shape set, a blank
136+
* statement, or any shape that was not extracted is refused.
137+
*/
138+
public boolean matches(Set<String> publishedShapes, String sql) {
139+
if (publishedShapes == null || publishedShapes.isEmpty() || sql == null || sql.isBlank()) {
140+
return false;
141+
}
142+
String shape = shapeOf(sql);
143+
return !shape.isBlank() && publishedShapes.contains(shape);
144+
}
145+
146+
/**
147+
* The artifact's scopes: each {@code <script>} block, or the whole document when it has
148+
* none, so a call outside a script tag is still seen.
149+
*/
150+
private java.util.List<String> scopes(String artifactHtml) {
151+
java.util.List<String> scopes = new java.util.ArrayList<>();
152+
Matcher blocks = SCRIPT_BLOCK.matcher(artifactHtml);
153+
while (blocks.find()) {
154+
scopes.add(blocks.group(1));
155+
}
156+
if (scopes.isEmpty()) {
157+
scopes.add(artifactHtml);
158+
}
159+
return scopes;
160+
}
161+
162+
private Map<String, String> declaredLiterals(String artifactHtml) {
163+
Map<String, String> declared = new HashMap<>();
164+
Matcher declarations = DECLARATION.matcher(artifactHtml);
165+
while (declarations.find()) {
166+
declared.put(declarations.group(1), unwrapJsLiteral(declarations.group(2)));
167+
}
168+
return declared;
169+
}
170+
171+
/** A literal argument is used directly; an identifier is looked up among the declarations. */
172+
private String resolveArgument(String argument, Map<String, String> declared) {
173+
if (isLiteral(argument)) {
174+
return unwrapJsLiteral(argument);
175+
}
176+
return declared.get(argument);
177+
}
178+
179+
private boolean isLiteral(String argument) {
180+
if (argument == null || argument.length() < 2) {
181+
return false;
182+
}
183+
char first = argument.charAt(0);
184+
return first == '`' || first == '"' || first == '\'';
185+
}
186+
187+
private int totalQueryCalls(String artifactHtml) {
188+
return (int) ANY_QUERY_CALL.matcher(artifactHtml).results().count();
189+
}
190+
191+
private int resolvedQueryCalls(String artifactHtml) {
192+
int resolved = 0;
193+
for (String scope : scopes(artifactHtml)) {
194+
Map<String, String> declared = declaredLiterals(scope);
195+
Matcher calls = QUERY_CALL.matcher(scope);
196+
while (calls.find()) {
197+
if (resolveArgument(calls.group(1), declared) != null) {
198+
resolved++;
199+
}
200+
}
201+
}
202+
return resolved;
203+
}
204+
205+
/**
206+
* Strips the surrounding quotes from a JS string literal and collapses interpolations.
207+
*
208+
* <p>An interpolation becomes {@code ?} so that {@code >= '${esc(from)}'} yields the same
209+
* shape as the runtime statement {@code >= '2026-01-01'}: the quotes around it are already
210+
* in the artifact, and the normalizer turns the quoted placeholder into its own {@code ?}.
211+
*/
212+
private String unwrapJsLiteral(String literal) {
213+
String body = literal.substring(1, literal.length() - 1);
214+
return unescape(INTERPOLATION.matcher(body).replaceAll("?"));
215+
}
216+
217+
/**
218+
* Turns escape sequences into the characters they stand for.
219+
*
220+
* <p>{@code dashboard_config} stores the broker's JSON envelope, so the artifact arrives
221+
* with its newlines as a literal backslash-n and its quotes escaped. {@link QueryNormalizer}
222+
* collapses <em>real</em> whitespace, so without this the published shape keeps
223+
* {@code customer_count\n from} where the runtime statement has a space, and no query on
224+
* the dashboard ever matches.
225+
*
226+
* <p>Worth recording how this was found: an earlier probe unescaped the database dump by
227+
* hand before extracting, so the harness was more forgiving than the production path and
228+
* three rounds of green tests missed it. It surfaced only by calling the real endpoint
229+
* against the real stored row.
230+
*/
231+
private String unescape(String text) {
232+
return text.replace("\\n", "\n")
233+
.replace("\\r", "\r")
234+
.replace("\\t", "\t")
235+
.replace("\\\"", "\"")
236+
.replace("\\'", "'")
237+
.replace("\\`", "`")
238+
.replace("\\\\", "\\");
239+
}
240+
}

0 commit comments

Comments
 (0)