Skip to content

Commit c48e350

Browse files
fix(editor): report unparseable SQL as a syntax error, not a permissions denial
A user pasted a SELECT that still carried the double quotes it had in source code ("select h.id, ...) and was told "Only admins can execute DDL or DML from the SQL Editor". That reads as a permissions problem and sent people looking for a role fix. The statement is neither DDL nor DML — with an unclosed double quote the whole thing is one quoted identifier, so it is not valid SQL at all. Two keyword heuristics disagreed and the disagreement was resolved as "mutation". QueryNormalizer.detectQueryType sanitizes a prefix away and answers SELECT; the provider's isReadOnlyQuery strips only comments, still sees the leading quote, and answers false. mutating was computed as (!readOnly && type != UNKNOWN), so a statement classifyStatement had itself labelled SELECT became a mutation. classifyStatement now records that the parser rejected the statement and, when the detected verb is read-only and no hidden write was found, returns notParseable. enforce throws STATEMENT_NOT_PARSEABLE ahead of both the read-only branch and the mutation-confirmation branch, with a message naming the likely cause. The statement is still blocked, admins included — only the diagnosis changed. An admin is deliberately not offered a confirmation prompt for a statement nothing managed to classify, since confirming past the guard is the one way this could become a bypass. The reclassification is gated on isReadOnlyVerb(queryType) and hiddenWrite == null, which is what keeps a write the parser happens to reject from being excused as a typo: an unparseable DELETE, and a malformed data-modifying CTE, both keep their mutation handling, and both are covered by tests. QueryExecutionPolicyServiceTest: 49 tests pass, including every pre-existing guard case. Full backend suite diffed against pristine HEAD — failure sets identical, no regressions. The MCP guard already reported this case honestly ("Only read-only SQL is allowed ...") and was left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9577e9d commit c48e350

4 files changed

Lines changed: 224 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,25 @@ it against a real database — not a theoretical hardening pass.
724724
it killed **every** active query on the connection, including other users' work.
725725
The cancel endpoint is scoped to the connection *and* the user who started the
726726
run, so an execution id is not a kill primitive for someone else's query.
727+
- **A statement DeepSQL cannot parse is a syntax error, not DDL/DML — say so.** A user
728+
pasted a SELECT still carrying the double quotes it had in source code
729+
(`"select h.id, ...`) and got **"Only admins can execute DDL or DML from the SQL
730+
Editor"**, which reads as a permissions problem and sends people hunting for a role fix.
731+
Two keyword heuristics disagreed and the code resolved the disagreement as "mutation":
732+
`QueryNormalizer.detectQueryType` sanitizes a prefix away and answered `SELECT`, while
733+
the provider's `isReadOnlyQuery` strips only *comments*, still saw the leading `"`, and
734+
answered false — so `mutating = !readOnly && type != UNKNOWN` labelled a SELECT a
735+
mutation. `classifyStatement` now records that the parser rejected the statement and,
736+
when the detected verb is read-only and no hidden write was found, returns
737+
`notParseable`; `enforce` throws `STATEMENT_NOT_PARSEABLE` ahead of both the read-only
738+
and confirmation branches. **The statement is still blocked, for admins too** — only the
739+
diagnosis changed, and an admin is deliberately *not* offered a confirmation prompt for
740+
something nothing managed to classify. The reclassification is gated on
741+
`isReadOnlyVerb(queryType)` and `hiddenWrite == null`, which is what keeps it from
742+
becoming a bypass: an unparseable `DELETE`, and a malformed data-modifying CTE, both keep
743+
their mutation handling (covered by `anUnparseableWriteIsStillTreatedAsAMutation` and
744+
`aMalformedDataModifyingCteIsStillBlockedAsAWrite`). The MCP guard already reported this
745+
case honestly ("Only read-only SQL is allowed …") and was left alone.
727746
- **Keep the client timeout under the proxy's.** `docker/nginx/default.conf` gives
728747
up at `proxy_read_timeout 300s`; the Editor used to ask for 600s, so a 6-minute
729748
query returned an opaque 504 while still running. `QUERY_TIMEOUT_SECONDS = 240`

backend/src/main/java/com/dbaagent/service/QueryExecutionPolicyException.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public class QueryExecutionPolicyException extends RuntimeException {
1212
public static final String UNSAFE_MUTATION_BLOCKED = "UNSAFE_MUTATION_BLOCKED";
1313
public static final String DB_WRITE_PRIVILEGE_DENIED = "DB_WRITE_PRIVILEGE_DENIED";
1414
public static final String MULTI_STATEMENT_MISSING_SEMICOLONS = "MULTI_STATEMENT_MISSING_SEMICOLONS";
15+
public static final String STATEMENT_NOT_PARSEABLE = "STATEMENT_NOT_PARSEABLE";
1516

1617
private final String errorCode;
1718
private final HttpStatus httpStatus;
@@ -57,6 +58,33 @@ public static QueryExecutionPolicyException editorMutationForbidden(String query
5758
);
5859
}
5960

61+
/**
62+
* The statement reads as a SELECT by keyword but is not valid SQL, so DeepSQL cannot
63+
* verify it is read-only.
64+
*
65+
* <p>It stays blocked — an unclassifiable statement is exactly what the guard exists
66+
* to stop — but it is <em>not</em> DDL or DML, and saying so sent a user hunting for a
67+
* permissions problem that did not exist. The trigger was a query pasted with the
68+
* surrounding double quotes it had in source code: {@code QueryNormalizer.detectQueryType}
69+
* sanitizes the prefix away and answers SELECT, while {@code isReadOnlyQuery} strips only
70+
* comments, still sees a leading {@code "}, and answers "not read-only". Those two
71+
* answers together mean "malformed", not "mutation".
72+
*/
73+
public static QueryExecutionPolicyException statementNotParseable(String queryType) {
74+
return new QueryExecutionPolicyException(
75+
STATEMENT_NOT_PARSEABLE,
76+
HttpStatus.BAD_REQUEST,
77+
"DeepSQL could not parse this statement, so it was blocked before running. "
78+
+ "It starts like a SELECT but is not valid SQL — check for a stray quote, "
79+
+ "bracket or backtick. SQL copied out of code or JSON often keeps the "
80+
+ "surrounding \" characters, which makes the whole statement one quoted "
81+
+ "identifier.",
82+
false,
83+
queryType,
84+
List.of()
85+
);
86+
}
87+
6088
public static QueryExecutionPolicyException confirmationRequired(String queryType, List<String> warnings) {
6189
return new QueryExecutionPolicyException(
6290
EDITOR_MUTATION_CONFIRMATION_REQUIRED,

backend/src/main/java/com/dbaagent/service/QueryExecutionPolicyService.java

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,19 @@ public PolicyDecision enforce(
121121
throw QueryExecutionPolicyException.multiStatementMissingSemicolons();
122122
}
123123

124+
// Same idea, one step earlier: a statement DeepSQL could not parse is blocked for
125+
// everyone, but it is a syntax error, not DDL/DML. Reported ahead of both the
126+
// read-only branch and the mutation-confirmation branch, so an admin gets the same
127+
// accurate diagnosis instead of being offered a confirmation prompt for a
128+
// statement nothing has actually classified.
129+
StatementClassification unparseable = classifications.stream()
130+
.filter(StatementClassification::notParseable)
131+
.findFirst()
132+
.orElse(null);
133+
if (unparseable != null) {
134+
throw QueryExecutionPolicyException.statementNotParseable(unparseable.queryType());
135+
}
136+
124137
if (effectiveContext.mutationMode() == QueryExecutionContext.MutationMode.READ_ONLY_ONLY) {
125138
if (!allReadOnlyOrPreamble || anyMutation) {
126139
if (origin == QueryExecutionOrigin.CHAT) {
@@ -210,6 +223,7 @@ private StatementClassification classifyStatement(String statement, QueryExecuti
210223
// classified as a read.
211224
String hiddenWrite = detectHiddenWrite(trimmed);
212225

226+
boolean parseFailed = false;
213227
try {
214228
Statement parsed = CCJSqlParserUtil.parse(trimmed);
215229
if (parsed instanceof Select select) {
@@ -272,6 +286,7 @@ private StatementClassification classifyStatement(String statement, QueryExecuti
272286
return new StatementClassification("TRUNCATE", false, true, false, true, false);
273287
}
274288
} catch (Exception parseError) {
289+
parseFailed = true;
275290
log.debug("Falling back to keyword SQL classification: {}", parseError.getMessage());
276291
}
277292

@@ -294,9 +309,41 @@ private StatementClassification classifyStatement(String statement, QueryExecuti
294309
boolean mutating = !readOnly && !"UNKNOWN".equalsIgnoreCase(queryType);
295310
boolean requiresWhere = "UPDATE".equalsIgnoreCase(queryType) || "DELETE".equalsIgnoreCase(queryType);
296311
boolean hasWhere = !requiresWhere || containsWhereClause(trimmed);
312+
313+
// The two keyword heuristics can contradict each other, and their disagreement
314+
// means "malformed", not "mutation". `QueryNormalizer.detectQueryType` sanitizes a
315+
// prefix away before matching, so it answers SELECT for `"select ...`; the
316+
// provider's `isReadOnlyQuery` strips only comments, still sees the leading quote,
317+
// and answers false. That combination used to fall through as mutating=true, and a
318+
// user pasting a SELECT with the double quotes it carried in source code was told
319+
// "Only admins can execute DDL or DML" — a permissions error for a syntax problem.
320+
//
321+
// It stays blocked: the parser rejected it, so nothing here can vouch for it being
322+
// read-only, and this is deliberately reported the same way to admins rather than
323+
// routed into the mutation-confirmation flow. Only the diagnosis changes.
324+
if (parseFailed && mutating && hiddenWrite == null && isReadOnlyVerb(queryType)) {
325+
return new StatementClassification(queryType, false, false, false, false, false, true);
326+
}
327+
297328
return new StatementClassification(queryType, readOnly, mutating, requiresWhere, hasWhere, false);
298329
}
299330

331+
/**
332+
* True for the statement verbs that never write. Narrow on purpose: it gates the
333+
* "malformed, not a mutation" reclassification above, and a write verb the parser
334+
* happens to reject (a Postgres DDL form JSqlParser does not model, say) must keep its
335+
* existing mutation handling rather than be re-labelled a syntax error.
336+
*/
337+
private boolean isReadOnlyVerb(String queryType) {
338+
if (queryType == null) {
339+
return false;
340+
}
341+
return switch (queryType.toUpperCase(Locale.ROOT)) {
342+
case "SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN" -> true;
343+
default -> false;
344+
};
345+
}
346+
300347
/**
301348
* Detects a write hidden inside a statement that parses as a {@link Select}:
302349
* a data-modifying CTE ({@code WITH x AS (DELETE ...) SELECT ...}, which
@@ -548,7 +595,18 @@ public record StatementClassification(
548595
boolean mutating,
549596
boolean requiresWhereClause,
550597
boolean hasWhereClause,
551-
boolean sessionPreamble
598+
boolean sessionPreamble,
599+
boolean notParseable
552600
) {
601+
public StatementClassification(
602+
String queryType,
603+
boolean readOnly,
604+
boolean mutating,
605+
boolean requiresWhereClause,
606+
boolean hasWhereClause,
607+
boolean sessionPreamble
608+
) {
609+
this(queryType, readOnly, mutating, requiresWhereClause, hasWhereClause, sessionPreamble, false);
610+
}
553611
}
554612
}

backend/src/test/java/com/dbaagent/service/QueryExecutionPolicyServiceTest.java

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,4 +615,122 @@ void mcpAdminExplainDrop_isBlockedEvenWhenConfirmed() {
615615
.isEqualTo(QueryExecutionPolicyException.UNSAFE_MUTATION_BLOCKED);
616616
assertThat(exception.getMessage()).contains("DROP and TRUNCATE");
617617
}
618+
619+
// ── A malformed statement is a syntax error, not a permissions problem ──────────
620+
//
621+
// Reported from the field: a user pasted a SELECT that still carried the double
622+
// quotes it had in source code and was told "Only admins can execute DDL or DML from
623+
// the SQL Editor", which reads as a permissions problem and sent them looking for a
624+
// role fix. The statement is neither DDL nor DML — it is not valid SQL at all.
625+
//
626+
// The cause is two keyword heuristics disagreeing: QueryNormalizer.detectQueryType
627+
// sanitizes the prefix away and answers SELECT, while the provider's isReadOnlyQuery
628+
// strips only comments, still sees the leading quote, and answers false. mutating was
629+
// computed as (!readOnly && type != UNKNOWN), so "SELECT" became a mutation.
630+
631+
private static final String QUOTED_SELECT =
632+
"\"select h.id, h.name, h.city, case when h.country = 'India' then 'IN' "
633+
+ "when h.country = 'United States' then 'US' else 'XX' end country_code from hotel h";
634+
635+
@Test
636+
void selectPastedWithItsSurroundingQuotes_isReportedAsASyntaxErrorNotAPermissionError() {
637+
QueryExecutionPolicyException exception = assertThrows(
638+
QueryExecutionPolicyException.class,
639+
() -> service.enforce(
640+
new QueryRequest(QUOTED_SELECT, 10, 30),
641+
QueryExecutionContext.editor("analyst", false, false),
642+
"mysql"
643+
)
644+
);
645+
646+
assertThat(exception.getErrorCode()).isEqualTo(QueryExecutionPolicyException.STATEMENT_NOT_PARSEABLE);
647+
assertThat(exception.getMessage()).doesNotContain("Only admins");
648+
assertThat(exception.getMessage()).contains("could not parse");
649+
}
650+
651+
/**
652+
* An admin gets the same diagnosis rather than a confirmation prompt. Offering to
653+
* "confirm this DDL/DML" for a statement nothing managed to classify would invite
654+
* confirming past the guard, and the statement cannot run anyway.
655+
*/
656+
@Test
657+
void aMalformedSelectIsNotOfferedToAdminsAsAConfirmableMutation() {
658+
QueryExecutionPolicyException exception = assertThrows(
659+
QueryExecutionPolicyException.class,
660+
() -> service.enforce(
661+
new QueryRequest(QUOTED_SELECT, 10, 30),
662+
QueryExecutionContext.editor("admin", true, false),
663+
"mysql"
664+
)
665+
);
666+
667+
assertThat(exception.getErrorCode()).isEqualTo(QueryExecutionPolicyException.STATEMENT_NOT_PARSEABLE);
668+
assertThat(exception.isRequiresConfirmation()).isFalse();
669+
}
670+
671+
/** Confirmation cannot get a malformed statement through either. */
672+
@Test
673+
void aConfirmedAdminStillCannotRunAMalformedStatement() {
674+
QueryExecutionPolicyException exception = assertThrows(
675+
QueryExecutionPolicyException.class,
676+
() -> service.enforce(
677+
new QueryRequest(QUOTED_SELECT, 10, 30),
678+
QueryExecutionContext.editor("admin", true, true),
679+
"mysql"
680+
)
681+
);
682+
683+
assertThat(exception.getErrorCode()).isEqualTo(QueryExecutionPolicyException.STATEMENT_NOT_PARSEABLE);
684+
}
685+
686+
/**
687+
* The reclassification is gated on the detected verb being read-only, so a write the
688+
* parser rejects keeps its mutation handling instead of being excused as a typo. This
689+
* is the half that stops the fix from becoming a bypass.
690+
*/
691+
@Test
692+
void anUnparseableWriteIsStillTreatedAsAMutation() {
693+
QueryExecutionPolicyException exception = assertThrows(
694+
QueryExecutionPolicyException.class,
695+
() -> service.enforce(
696+
new QueryRequest("DELETE FROM hotel WHERE (((", 10, 30),
697+
QueryExecutionContext.editor("analyst", false, false),
698+
"mysql"
699+
)
700+
);
701+
702+
assertThat(exception.getErrorCode()).isEqualTo(QueryExecutionPolicyException.EDITOR_MUTATION_FORBIDDEN);
703+
}
704+
705+
/**
706+
* The data-modifying CTE this whole guard exists for must not slip through the new
707+
* branch: `detectHiddenWrite` vetoes it before the parse result is consulted, so a
708+
* malformed variant is still a blocked write rather than a reported typo.
709+
*/
710+
@Test
711+
void aMalformedDataModifyingCteIsStillBlockedAsAWrite() {
712+
QueryExecutionPolicyException exception = assertThrows(
713+
QueryExecutionPolicyException.class,
714+
() -> service.enforce(
715+
new QueryRequest("WITH x AS (DELETE FROM hotel RETURNING *) SELECT * FROM x WHERE (((", 10, 30),
716+
QueryExecutionContext.editor("analyst", false, false),
717+
"mysql"
718+
)
719+
);
720+
721+
assertThat(exception.getErrorCode()).isEqualTo(QueryExecutionPolicyException.EDITOR_MUTATION_FORBIDDEN);
722+
}
723+
724+
/** The same query without the stray quote is an ordinary read. */
725+
@Test
726+
void theSameSelectWithoutTheStrayQuoteIsAllowed() {
727+
QueryExecutionPolicyService.PolicyDecision decision = service.enforce(
728+
new QueryRequest(QUOTED_SELECT.substring(1), 10, 30),
729+
QueryExecutionContext.editor("analyst", false, false),
730+
"mysql"
731+
);
732+
733+
assertThat(decision.mutating()).isFalse();
734+
assertThat(decision.primaryQueryType()).isEqualTo("SELECT");
735+
}
618736
}

0 commit comments

Comments
 (0)