fix(editor): close SQL guard bypasses in the query editor - #63
Merged
Conversation
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>
notSumit25
requested review from
a team,
geekypunk and
venkateshsakamuri-lab
as code owners
August 17, 2026 16:20
Contributor
|
@notSumit25 code scan failed. Pl check and fix it. |
geekypunk
previously approved these changes
Aug 17, 2026
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
approved these changes
Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 xparses 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.\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.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.