fix(security): block SQL functions that read or write outside the session - #112
Open
notSumit25 wants to merge 1 commit into
Open
fix(security): block SQL functions that read or write outside the session#112notSumit25 wants to merge 1 commit into
notSumit25 wants to merge 1 commit into
Conversation
…sion
Both read-only defences were bypassed by one statement:
SELECT dblink_exec('dbname=app user=postgres host=127.0.0.1',
'DELETE FROM orders')
The guard failed because McpSqlGuardService classifies by statement verb: the
statement begins SELECT, which is on the allowlist, and none of the 15
forbidden verbs appears anywhere in it. dblink_exec is a function call, which
a verb-based parser cannot see.
connection.setReadOnly(true) failed for a subtler reason. dblink opens a NEW
OUTBOUND CONNECTION whose transaction is not read-only. The flag constrains the
session it is set on; it cannot constrain one the query itself dials out and
creates. The backstop holds for ordinary writes — SELECT INTO, nextval,
lo_import are all correctly refused by it — but not for a function that leaves
the session.
Reproduced against a real PostgreSQL in an isolated schema: inside an explicit
BEGIN TRANSACTION READ ONLY, the statement reported DELETE 3 and the table went
from three rows to zero. pg_read_file('/etc/hostname') likewise returned the
database server's file contents under read-only. Running the shipped guard over
the payloads returned ALLOWED for dblink_exec, dblink, pg_read_file, pg_ls_dir,
pg_read_binary_file, lo_import and LOAD_FILE. Until public dashboard queries
were bound to published shapes, this was reachable unauthenticated through the
share endpoint, which runs the same executor.
Adds DANGEROUS_SQL_FUNCTIONS to both guards. A denylist is the right shape here
only because the allowlist governs verbs and there is no allowlist of functions
that may appear inside a SELECT: the legitimate set is open-ended, the escaping
set is small and nameable.
Matched as a call, not a name — (?<![\w$.])(name)\s*\( — since matching the
bare name would reject a dblink_audit table or a load_file_name column, the
same mistake the old \bCOMMENT\b rule made with "SELECT * FROM comment".
Inspection runs on text with comments and string literals stripped.
Mirrored in mcp/deepsql-phase1-lib.js: a statement one guard blocks and the
other allows is itself the bypass. Parity asserted over 20 payloads — 14
attacks and 6 legitimate queries — with 0 mismatches.
Verified: 5 tests fail before the fix, pass after, and fail again when the
denylist is stubbed out. The live attack replayed after the fix is blocked and
the table stays at 3 rows. 84 backend tests and 272 MCP tests green. The test
schema and dblink extension were dropped; the database is back to its prior
state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
notSumit25
requested review from
a team,
geekypunk and
venkateshsakamuri-lab
as code owners
September 11, 2026 08:33
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.
Both read-only layers bypassed by one statement
The guard failed because
McpSqlGuardServiceclassifies by statement verb. This beginsSELECT(on the allowlist) and none of the 15 forbidden verbs appears anywhere.dblink_execis a function call — invisible to a verb-based parser.connection.setReadOnly(true)failed for a subtler reason worth understanding:dblinkopens a new outbound connection, whose transaction is not read-only. The flag constrains the session it's set on — it cannot constrain one the query dials out and creates.CLAUDE.md calls
setReadOnly(true)the backstop that "keeps the next parser gap from becoming data loss." That holds for ordinary writes (SELECT … INTO,nextval,lo_importare all correctly refused). It does not hold for a function that leaves the session.Reproduced against a real PostgreSQL, not inferred
Isolated
zz_secschema, created and dropped for the test:A
DELETEran to completion inside an explicitly read-only transaction. Same story for filesystem reads:And the shipped guard returned ALLOWED for every one:
dblink_exec,dblink,pg_read_file,pg_ls_dir,pg_read_binary_file,lo_import,LOAD_FILE.The fix
A denylist is usually the wrong shape. It's right here because the allowlist governs verbs, and there is no allowlist of functions permitted inside a
SELECT— the legitimate set is open-ended, the escaping set is small and nameable.Matched as a call, not a name:
(?<![\w$.])(name)\s*\(. Matching the bare name would reject adblink_audittable or aload_file_namecolumn — the exact mistake CLAUDE.md records for the old\bCOMMENT\brule that rejectedSELECT * FROM comment. The boundary also prevents a different function likemy_dblink(from matching. Inspection runs on text with comments and string literals stripped, so/*x*/dblink_exec(can't hide and a name inside a quoted literal can't falsely trigger.Both guards, or neither
McpSqlGuardService.javaandmcp/deepsql-phase1-lib.jsare a functional mirror. A statement one blocks and the other allows is the bypass — so the change landed in both, and parity is verified directly: 20 payloads (14 attacks, 6 legitimate including the identifier false-positives), 0 mismatches.Verification
return null(mutation)zz_secand thedblinkextension were dropped — database back to its prior state.Residual work (deliberately not here)
ExplainPlanServiceopens its own connection and never callssetReadOnly(true)—grep setReadOnlyoversrc/main/javareturns exactly one hit, inQueryExecutorService. The function denylist now covers that path, but the database-level backstop is still absent there.COPY … FROM/TO PROGRAMis blocked by theCOPYverb being forbidden, not by this denylist — sufficient today, but worth knowing if the verb list is ever narrowed.EXECUTEon these functions from the connection role, and not provisioning superuser connection users, remains the stronger control. This reduces blast radius; it does not replace database permissions.Write-up:
docs/security/2026-09-11-sql-guard-dangerous-functions.md🤖 Generated with Claude Code