Skip to content

Commit 1fbaaf1

Browse files
notSumit25claudegeekypunk
authored
fix(editor): close SQL guard bypasses in the query editor (#63)
A CHAT_EDITOR (non-admin) user could delete or overwrite every row in any table by wrapping the write in a CTE. `WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x` parses as a Select, so classification returned read-only and returned before the admin check ever ran. PostgreSQL executes data-modifying CTEs for real. Verified end to end: 3 rows -> 0, success:true, no confirmation prompt, logged as an ordinary EDITOR_QUERY_EXECUTED / SUCCESS. - classifyStatement now walks the parse tree for data-modifying CTEs and SELECT ... INTO (detectSelectWrite), with a text backstop (detectHiddenWrite) so an unparseable variant fails closed instead of reaching isReadOnlyQuery, which reports anything starting with WITH as safe. - READ_ONLY_ONLY contexts now open read-only JDBC sessions, so the database refuses the write even when classification is wrong. Classification is a parser heuristic; this is what keeps the next parser gap from being data loss. HikariCP resets the flag on return to the pool, verified, so it cannot leak into an admin's later write. - Row caps are enforced with setMaxRows instead of a `\blimit\s+\d+` text match that hit inside comments, string literals and subqueries. An inner LIMIT returned 200k rows against a 1,000 cap, into an unbounded ArrayList and an unvirtualized table. - Cancel terminates the query instead of only aborting the HTTP request, which left the statement holding one of the pool's 10 connections. The client sends an executionId, RunningQueryRegistry maps it to the backend session pid, and the new cancel endpoint kills exactly that session, scoped to the connection and the user who started it. The previous UI behavior killed *every* active query on the connection, including other users' work. - Fix pg_terminate_backend binding: setLong sent bigint, so PostgreSQL found no matching overload and every kill failed, including the Active Queries screen's own button. - Editor timeout 600s -> 240s, under nginx's 300s proxy_read_timeout, so a slow query reports a real error rather than an opaque 504 while still running. - Rate-limit /api/connections/*/query (30r/m + burst 20, 429 on reject). QueryExecutionPolicyServiceTest stubbed isReadOnlyQuery to always return false — the opposite of what the shipped providers do for WITH — so it asserted behavior no deployment had, and withInsert_isTreatedAsMutation passed *because* of the stub. It now uses a real MySQLQueryExecutionProvider, plus 12 regression tests covering each bypass and the reads that must keep working. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Krishna Sasank Talasila <sasanktk@gmail.com>
1 parent 659394f commit 1fbaaf1

18 files changed

Lines changed: 717 additions & 36 deletions

CLAUDE.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,60 @@ broken. Assert the *outcome*, never the attempt:
267267
schemas have a `comment` table. Assert `SELECT * FROM comment` is allowed *and*
268268
that `WITH x AS (DELETE …) SELECT …` / `WITH x AS (…) DELETE …` still are not.
269269

270+
### SQL Editor Guard Rules
271+
272+
The Editor (`EditorSection``SqlRunnerTab``POST /connections/{id}/query`
273+
`QueryExecutionPolicyService``QueryExecutorService`) is the only surface where a
274+
user submits arbitrary SQL. Everything below was a live bug, verified by executing
275+
it against a real database — not a theoretical hardening pass.
276+
277+
- **Never classify SQL by its leading keyword alone.** `WITH x AS (DELETE FROM t
278+
RETURNING *) SELECT * FROM x` parses as a `Select` and *every* leading-keyword
279+
check calls it read-only — including `isReadOnlyQuery`, which reports anything
280+
starting with `WITH` as safe. PostgreSQL executes data-modifying CTEs for real,
281+
so a **non-admin** wiped whole tables through the Editor with no confirmation
282+
prompt, logged as an ordinary `EDITOR_QUERY_EXECUTED / SUCCESS`. `SELECT … INTO
283+
newtab` is the same class of bug (it is DDL). `classifyStatement` now inspects
284+
the parse tree (`detectSelectWrite`) **and** runs a text backstop
285+
(`detectHiddenWrite`) so an unparseable variant fails closed instead of falling
286+
through to the keyword path.
287+
- **Read-only contexts open read-only JDBC sessions.** `QueryExecutorService` calls
288+
`connection.setReadOnly(true)` whenever `mutationMode() == READ_ONLY_ONLY`, so
289+
PostgreSQL refuses the write itself even if classification is wrong. Classification
290+
is a parser heuristic; this is what keeps the *next* parser gap from being data
291+
loss. A driver that rejects the hint raises rather than silently continuing
292+
writable. HikariCP resets the flag on return to the pool (verified), so it cannot
293+
leak into an admin's later write.
294+
- **Row caps are enforced with `setMaxRows`, not by appending `LIMIT n`.** The old
295+
check skipped its own LIMIT whenever the regex `\blimit\s+\d+` matched anywhere —
296+
including inside a comment, a string literal, or a subquery. `WITH a AS (SELECT …
297+
LIMIT 100) SELECT * FROM a` is ordinary analyst SQL and returned **200k rows**
298+
against a 1,000 cap, straight into an unbounded `ArrayList` and then an
299+
unvirtualized table. The SQL `LIMIT` is still appended for simple SELECTs, but
300+
only as an optimization — correctness no longer depends on that text match.
301+
- **Cancel must terminate the query, not just the HTTP request.** `abortController
302+
.abort()` only closes the socket; the statement runs on holding one of the pool's
303+
10 connections for up to its timeout. The client now sends an `executionId`,
304+
`RunningQueryRegistry` maps it to the backend session pid (via the dialect's
305+
`getSessionPidQuery()`), and `POST /connections/{id}/query/{executionId}/cancel`
306+
terminates exactly that session. The previous UI behavior was worse than nothing:
307+
it killed **every** active query on the connection, including other users' work.
308+
The cancel endpoint is scoped to the connection *and* the user who started the
309+
run, so an execution id is not a kill primitive for someone else's query.
310+
- **Keep the client timeout under the proxy's.** `docker/nginx/default.conf` gives
311+
up at `proxy_read_timeout 300s`; the Editor used to ask for 600s, so a 6-minute
312+
query returned an opaque 504 while still running. `QUERY_TIMEOUT_SECONDS = 240`
313+
in `SqlRunnerTab.js` — change both together or not at all.
314+
- **`/api/connections/*/query` is rate-limited in nginx** (`limit_req zone=sqlexec`,
315+
30r/m + burst 20, `429` on reject). It is the most expensive authenticated call
316+
in the product.
317+
- **Test the policy against the real providers.** `QueryExecutionPolicyServiceTest`
318+
used to stub `isReadOnlyQuery` to always return `false` — the exact opposite of
319+
what the shipped providers do for `WITH`. It asserted behavior no deployment had,
320+
and `withInsert_isTreatedAsMutation` passed *because* of the stub. It now
321+
constructs a real `MySQLQueryExecutionProvider`. Do not reintroduce a stubbed
322+
dialect here; the mock is what let the blocker ship.
323+
270324
### Data Model Rules
271325

272326
- **`mcp_tokens.user_id` is a non-null FK with no cascade.** Deleting a user who holds

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import com.dbaagent.service.CredentialService;
77
import com.dbaagent.service.QueryExecutionContext;
88
import com.dbaagent.service.QueryExecutionPolicyException;
9+
import com.dbaagent.service.ActiveQueryService;
910
import com.dbaagent.service.QueryExecutorService;
11+
import com.dbaagent.service.RunningQueryRegistry;
1012
import com.dbaagent.service.SqlExecutionAuditService;
1113
import com.dbaagent.service.UserDataAccessPolicyException;
1214
import com.dbaagent.service.SchemaScannerService;
@@ -36,6 +38,8 @@ public class SchemaController {
3638
private final QueryExecutorService queryExecutorService;
3739
private final AccessControlService accessControlService;
3840
private final SqlExecutionAuditService sqlExecutionAuditService;
41+
private final RunningQueryRegistry runningQueryRegistry;
42+
private final ActiveQueryService activeQueryService;
3943

4044
@PostMapping("/scan")
4145
public ResponseEntity<Map<String, Object>> scanSchema(@PathVariable String connectionId) {
@@ -260,6 +264,63 @@ public ResponseEntity<Map<String, Object>> executeQuery(
260264
}
261265
}
262266

267+
/**
268+
* Terminates a query this caller started but abandoned. Aborting the HTTP
269+
* request only closes the socket — the statement keeps running and holds a
270+
* pooled connection until it finishes, so the client must ask for it to stop.
271+
*/
272+
@PostMapping("/query/{executionId}/cancel")
273+
public ResponseEntity<Map<String, Object>> cancelQuery(
274+
@PathVariable String connectionId,
275+
@PathVariable String executionId) {
276+
Map<String, Object> response = new HashMap<>();
277+
try {
278+
if (!credentialService.connectionExists(connectionId)) {
279+
response.put("success", false);
280+
response.put("message", "Connection not found");
281+
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
282+
}
283+
accessControlService.assertCanUseChatEditor(connectionId);
284+
285+
var running = runningQueryRegistry.find(executionId);
286+
if (running.isEmpty()) {
287+
// Already finished, or never started. Nothing to cancel.
288+
response.put("success", true);
289+
response.put("cancelled", false);
290+
response.put("message", "Query is no longer running");
291+
return ResponseEntity.ok(response);
292+
}
293+
294+
RunningQueryRegistry.RunningQuery target = running.get();
295+
// The execution id is a bearer token for a kill: scope it to this
296+
// connection and to the user who started it, so one caller cannot
297+
// terminate another's query by guessing an id.
298+
String currentUser = accessControlService.getCurrentUsername();
299+
if (!connectionId.equals(target.connectionId())
300+
|| (target.username() != null && currentUser != null && !target.username().equals(currentUser))) {
301+
response.put("success", false);
302+
response.put("message", "Query not found for this connection");
303+
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
304+
}
305+
306+
activeQueryService.killQuery(connectionId, target.sessionPid());
307+
runningQueryRegistry.unregister(executionId);
308+
response.put("success", true);
309+
response.put("cancelled", true);
310+
response.put("message", "Query cancelled");
311+
return ResponseEntity.ok(response);
312+
} catch (ResponseStatusException e) {
313+
response.put("success", false);
314+
response.put("message", e.getReason());
315+
return ResponseEntity.status(e.getStatusCode()).body(response);
316+
} catch (Exception e) {
317+
log.warn("Failed to cancel query {} on connection {}: {}", executionId, connectionId, e.getMessage());
318+
response.put("success", false);
319+
response.put("message", "Failed to cancel query: " + e.getMessage());
320+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
321+
}
322+
}
323+
263324
// `{tableName:.+}` keeps schema-qualified ids (`crm.orders`) as one segment.
264325
@GetMapping("/tables/{tableName:.+}/indexes")
265326
public ResponseEntity<Map<String, Object>> getTableIndexes(

backend/src/main/java/com/dbaagent/model/QueryRequest.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ public class QueryRequest {
1111
private Integer timeoutSeconds; // Optional per-query timeout override (null = use server default)
1212
private QueryExecutionOrigin executionOrigin = QueryExecutionOrigin.INTERNAL;
1313
private Boolean mutationConfirmed = Boolean.FALSE;
14+
// Client-generated id for this run, used to cancel the query if the caller
15+
// gives up before it finishes.
16+
private String executionId;
1417

1518
public QueryRequest(String query, Integer limit, Integer timeoutSeconds) {
1619
this.query = query;

backend/src/main/java/com/dbaagent/model/QueryResult.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,23 @@ public class QueryResult {
1717
private Boolean isLimited; // True when a row limit was applied to this query
1818
private Long executionTimeMs;
1919
private String query;
20+
// Server-side session id this query ran on, so a client that gives up can ask
21+
// the backend to terminate it instead of leaving it holding a connection.
22+
private String sessionPid;
23+
24+
/**
25+
* Kept so adding {@code sessionPid} did not break every positional caller.
26+
* Prefer the setter for new code — this class is a response DTO that grows.
27+
*/
28+
public QueryResult(
29+
List<String> columns,
30+
List<List<Object>> rows,
31+
Integer rowCount,
32+
Long totalRowCount,
33+
Boolean isLimited,
34+
Long executionTimeMs,
35+
String query
36+
) {
37+
this(columns, rows, rowCount, totalRowCount, isLimited, executionTimeMs, query, null);
38+
}
2039
}

backend/src/main/java/com/dbaagent/provider/api/QueryExecutionProvider.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,16 @@ public interface QueryExecutionProvider {
5151
* @return The query signature
5252
*/
5353
String generateQuerySignature(String query);
54+
55+
/**
56+
* SQL returning the server-side session id of the current connection, in the
57+
* form accepted by this dialect's kill statement. Callers use it to cancel a
58+
* still-running query after the client has gone away.
59+
*
60+
* @return a single-column, single-row query, or null when the dialect has no
61+
* session identifier we can act on
62+
*/
63+
default String getSessionPidQuery() {
64+
return null;
65+
}
5466
}

backend/src/main/java/com/dbaagent/provider/mysql/MySQLQueryExecutionProvider.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,9 @@ public String generateQuerySignature(String query) {
123123
return String.valueOf(normalized.hashCode());
124124
}
125125
}
126+
127+
@Override
128+
public String getSessionPidQuery() {
129+
return "SELECT CONNECTION_ID()";
130+
}
126131
}

backend/src/main/java/com/dbaagent/provider/postgres/PostgresQueryExecutionProvider.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,9 @@ public String generateQuerySignature(String query) {
127127
return String.valueOf(normalized.hashCode());
128128
}
129129
}
130+
131+
@Override
132+
public String getSessionPidQuery() {
133+
return "SELECT pg_backend_pid()";
134+
}
130135
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,15 @@ public void killQuery(String connectionId, String pid) {
203203
String dbType = providerRegistry.getCanonicalName(connRequest.getDbType());
204204

205205
if ("postgres".equals(dbType)) {
206+
// pg_terminate_backend takes an integer; binding a long makes
207+
// the driver send bigint and PostgreSQL then finds no matching
208+
// overload ("function pg_terminate_backend(bigint) does not
209+
// exist"), so every kill failed.
210+
if (backendPid > Integer.MAX_VALUE || backendPid < Integer.MIN_VALUE) {
211+
throw new IllegalArgumentException("Not a valid PostgreSQL backend pid: " + backendPid);
212+
}
206213
try (PreparedStatement ps = conn.prepareStatement("SELECT pg_terminate_backend(?)")) {
207-
ps.setLong(1, backendPid);
214+
ps.setInt(1, (int) backendPid);
208215
ps.execute();
209216
}
210217
} else if ("mysql".equals(dbType)) {

0 commit comments

Comments
 (0)