Skip to content

fix(editor): close SQL guard bypasses in the query editor - #63

Merged
geekypunk merged 3 commits into
mainfrom
fix/editor-sql-guard-hardening
Aug 18, 2026
Merged

fix(editor): close SQL guard bypasses in the query editor#63
geekypunk merged 3 commits into
mainfrom
fix/editor-sql-guard-hardening

Conversation

@notSumit25

Copy link
Copy Markdown
Collaborator

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.

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>
@venkateshsakamuri-lab

Copy link
Copy Markdown
Contributor

@notSumit25 code scan failed. Pl check and fix it.

geekypunk
geekypunk previously approved these changes Aug 17, 2026
geekypunk and others added 2 commits August 17, 2026 16:02
CodeQL flagged three java/polynomial-redos alerts (140, 141, 142) on the
text backstop added in 9cb0584. Both were real and reachable by any
authenticated Editor user, measured on JDK 25:

  224KB of repeated "SELECT " -> 43.8s of CPU in SELECT_INTO_PATTERN
  96KB of repeated "a/*"      -> 14.1s of CPU in stripComments

The work happened in classifyStatement, before the query reached the
database, so a single request stalled a request thread for the better part
of a minute. The nginx sqlexec limiter (30r/m + burst 20) bounds the rate
but not the per-request cost.

- SELECT_INTO_PATTERN dropped its leading `\bSELECT\b[\s\S]*?` wildcard and
  now anchors on `\bINTO\s+...` alone. The lazy wildcard was what backtracked;
  it also bought nothing, since the caller already knows the statement shape.
  Because a bare INTO also appears in `INSERT INTO t SELECT ...`,
  detectHiddenWrite now consults the pattern only for statements that
  actually start with SELECT or WITH — otherwise an INSERT would be
  relabelled SELECT INTO.
- stripComments replaced its two regexes with one linear scan. The block
  form matched a lazy wildcard between delimiters and degraded on an
  unterminated comment.

After: both payloads classify in ~15ms, and 1.4MB of the same input stays
at ~16ms — linear, not quadratic. Verified against the live endpoint: both
hostile payloads return 400 in ~0.12s.

stripQuotedLiterals on the same path was measured and left alone; Java
matches its alternation linearly (27 chars of unterminated literal: <1ms).

Adds two regression tests: an adversarial-input timing bound, and
insertIntoSelect_isNotMisreadAsSelectInto covering the widened pattern.
Full guard behavior re-verified live under real non-admin auth — every CTE
write variant and SELECT INTO still blocked, reads and confirmed admin
writes unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@geekypunk
geekypunk merged commit 1fbaaf1 into main Aug 18, 2026
9 checks passed
@geekypunk
geekypunk deleted the fix/editor-sql-guard-hardening branch August 18, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants