Skip to content

Commit 89e8018

Browse files
authored
fix(security): block SQL functions that read or write outside the session (#112)
Denylist dangerous SQL functions (dblink, pg_read_file, etc.) in McpSqlGuard and MCP parity guard.
1 parent 62dc056 commit 89e8018

6 files changed

Lines changed: 377 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,24 @@ it against a real database — not a theoretical hardening pass.
726726
the parse tree (`detectSelectWrite`) **and** runs a text backstop
727727
(`detectHiddenWrite`) so an unparseable variant fails closed instead of falling
728728
through to the keyword path.
729+
- **A verb-based guard cannot see a dangerous *function*, and `setReadOnly(true)` cannot stop
730+
one that leaves the session.** `SELECT dblink_exec('dbname=app …','DELETE FROM orders')`
731+
defeated **both** layers at once: it begins `SELECT` so the allowlist passes it and no
732+
forbidden verb appears, and `dblink` opens a **new outbound connection** whose transaction is
733+
not read-only — the flag constrains the session it is set on, never one the query dials out
734+
and creates. Reproduced against a real PostgreSQL: inside `BEGIN TRANSACTION READ ONLY`, the
735+
statement reported `DELETE 3` and the table went from 3 rows to 0; `pg_read_file` likewise
736+
read the server's filesystem under read-only. Until public dashboard queries were bound to
737+
published shapes this was reachable **unauthenticated** through the share endpoint, which
738+
runs the same executor. `DANGEROUS_SQL_FUNCTIONS` now denies dblink*, `pg_read_file`,
739+
`pg_read_binary_file`, `pg_ls_dir`, `pg_stat_file`, `lo_import`, `lo_export` and MySQL
740+
`LOAD_FILE`. A denylist is the right shape *here* only because the allowlist governs verbs
741+
and there is no allowlist of functions that may sit inside a `SELECT`. **Match the call, not
742+
the name**`(?<![\w$.])(name)\s*\(` — or you reject a `dblink_audit` table and a
743+
`load_file_name` column, the same mistake the old `\bCOMMENT\b` rule made with
744+
`SELECT * FROM comment`. Mirrored in `mcp/deepsql-phase1-lib.js`; a statement one guard
745+
blocks and the other allows *is* the bypass, so parity is asserted over 20 payloads. See
746+
`docs/security/2026-09-11-sql-guard-dangerous-functions.md`.
729747
- **Read-only contexts open read-only JDBC sessions.** `QueryExecutorService` calls
730748
`connection.setReadOnly(true)` whenever `mutationMode() == READ_ONLY_ONLY`, so
731749
PostgreSQL refuses the write itself even if classification is wrong. Classification

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,51 @@ public class McpSqlGuardService {
4545
"COMMENT"
4646
);
4747

48+
/**
49+
* Functions that read or write outside the current read-only session.
50+
*
51+
* <p>Every check above classifies by statement <em>verb</em>, so a {@code SELECT} that calls
52+
* one of these passes cleanly: the allowlist sees SELECT and no forbidden verb appears.
53+
* {@code connection.setReadOnly(true)} does not stop them either — {@code dblink} opens a
54+
* <em>new outbound connection</em> whose transaction is not read-only, so the flag
55+
* constrains the session it is set on but never one the query dials out and creates.
56+
*
57+
* <p>Verified against a real PostgreSQL, not inferred: inside an explicitly
58+
* {@code BEGIN TRANSACTION READ ONLY}, {@code SELECT dblink_exec(..., 'DELETE FROM t')}
59+
* reported {@code DELETE 3} and the table went from three rows to zero;
60+
* {@code pg_read_file('/etc/hostname')} returned its contents from the database server's
61+
* filesystem. Both of the product's layers failed at once.
62+
*
63+
* <p>A denylist is the wrong shape in general, but it is the right shape here: the guard's
64+
* allowlist governs <em>verbs</em>, and there is no allowlist of functions to sit inside
65+
* a SELECT. Names are matched as calls (see {@link #DANGEROUS_FUNCTION_CALL}) so an
66+
* ordinary identifier of the same name still works.
67+
*/
68+
private static final List<String> DANGEROUS_SQL_FUNCTIONS = List.of(
69+
// outbound connections — escape the read-only session entirely
70+
"dblink", "dblink_exec", "dblink_connect", "dblink_open", "dblink_send_query",
71+
// server-side file access
72+
"pg_read_file", "pg_read_binary_file", "pg_ls_dir", "pg_stat_file",
73+
"lo_import", "lo_export",
74+
// MySQL equivalents
75+
"load_file"
76+
);
77+
78+
private static final String DANGEROUS_FUNCTION_ALTERNATION =
79+
String.join("|", DANGEROUS_SQL_FUNCTIONS);
80+
81+
/**
82+
* A dangerous function being <em>called</em>: the name, optional whitespace, then an open
83+
* paren. Matching the bare name would reject ordinary identifiers — plenty of schemas have
84+
* a {@code dblink_audit} table or a {@code load_file_name} column, the same mistake
85+
* CLAUDE.md records for the old {@code \bCOMMENT\b} rule that rejected
86+
* {@code SELECT * FROM comment}. A leading word-boundary check keeps {@code my_dblink(} —
87+
* a different function — from matching.
88+
*/
89+
private static final Pattern DANGEROUS_FUNCTION_CALL = Pattern.compile(
90+
"(?<![\\w$.])(" + DANGEROUS_FUNCTION_ALTERNATION + ")\\s*\\(",
91+
Pattern.CASE_INSENSITIVE);
92+
4893
private static final Set<String> FORBIDDEN_SQL_KEYWORD_SET = Set.copyOf(FORBIDDEN_SQL_KEYWORDS);
4994

5095
private static final String FORBIDDEN_ALTERNATION = String.join("|", FORBIDDEN_SQL_KEYWORDS);
@@ -102,9 +147,29 @@ public ValidationOutcome validateReadOnlySql(String sql, boolean allowExplain) {
102147
);
103148
}
104149

150+
String dangerousFunction = containsDangerousFunction(statement);
151+
if (dangerousFunction != null) {
152+
return ValidationOutcome.invalid(
153+
"Blocked SQL function that reads or writes outside this session: "
154+
+ dangerousFunction + "."
155+
);
156+
}
157+
105158
return ValidationOutcome.valid(stripTrailingSemicolons(sql), keyword);
106159
}
107160

161+
/**
162+
* The first dangerous function call in the statement, or null.
163+
*
164+
* <p>Inspected with comments and string literals stripped, so neither
165+
* {@code /*x*} + {@code /dblink_exec(} nor a name mentioned inside a quoted literal can
166+
* hide or falsely trigger a match.
167+
*/
168+
String containsDangerousFunction(String statement) {
169+
var matcher = DANGEROUS_FUNCTION_CALL.matcher(normalizeSqlForInspection(statement));
170+
return matcher.find() ? matcher.group(1).toLowerCase(Locale.ROOT) : null;
171+
}
172+
108173
String normalizeSqlForInspection(String sql) {
109174
return compactWhitespace(stripSqlStringLiterals(stripSqlComments(sql)));
110175
}

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,95 @@ void rejectsExplainOfDeleteButAllowsExplainOfCommentTable() {
122122
var commentPlan = service.validateReadOnlySql("EXPLAIN SELECT * FROM comment", true);
123123
assertTrue(commentPlan.ok());
124124
}
125+
126+
// ── dangerous functions ───────────────────────────────────────────────────
127+
//
128+
// The guard classifies by statement *verb*, so a SELECT that calls a dangerous
129+
// function passes every check: the allowlist sees SELECT, and no forbidden verb
130+
// appears anywhere. Verified against a real PostgreSQL 17 — inside an explicitly
131+
// READ ONLY transaction, `SELECT dblink_exec(..., 'DELETE FROM t')` reported
132+
// `DELETE 3` and the table went from 3 rows to 0.
133+
//
134+
// connection.setReadOnly(true) cannot stop it either: dblink opens a *new outbound
135+
// connection* whose transaction is not read-only. The read-only flag constrains the
136+
// session it is set on, never one the query dials out and creates. So both of the
137+
// product's layers fail at once, and until the public dashboard path was bound to
138+
// published query shapes this was reachable anonymously.
139+
140+
@Test
141+
void rejectsDblinkExec() {
142+
var result = service.validateReadOnlySql(
143+
"SELECT dblink_exec('dbname=app user=postgres host=127.0.0.1','DELETE FROM orders')", true);
144+
145+
assertFalse(result.ok());
146+
assertTrue(result.reason().toLowerCase().contains("dblink"),
147+
"reason should name the function it refused, was: " + result.reason());
148+
}
149+
150+
@Test
151+
void rejectsEveryDblinkEntryPoint() {
152+
for (String sql : new String[] {
153+
"SELECT * FROM dblink('dbname=app','SELECT 1') AS t(a int)",
154+
"SELECT dblink_connect('dbname=app')",
155+
"SELECT dblink_send_query('conn','DELETE FROM orders')",
156+
"SELECT dblink_open('conn','cur','SELECT 1')"
157+
}) {
158+
assertFalse(service.validateReadOnlySql(sql, true).ok(), "should refuse: " + sql);
159+
}
160+
}
161+
162+
@Test
163+
void rejectsServerSideFileReads() {
164+
for (String sql : new String[] {
165+
"SELECT pg_read_file('/etc/passwd')",
166+
"SELECT pg_read_binary_file('/etc/passwd')",
167+
"SELECT pg_ls_dir('/var/lib/postgresql/data')",
168+
"SELECT pg_stat_file('/etc/passwd')",
169+
"SELECT lo_import('/etc/passwd')",
170+
"SELECT lo_export(1,'/tmp/out')"
171+
}) {
172+
assertFalse(service.validateReadOnlySql(sql, true).ok(), "should refuse: " + sql);
173+
}
174+
}
175+
176+
@Test
177+
void rejectsMySqlFileReads() {
178+
assertFalse(service.validateReadOnlySql("SELECT LOAD_FILE('/etc/passwd')", true).ok());
179+
}
180+
181+
@Test
182+
void rejectsDangerousFunctionRegardlessOfSpacingOrCase() {
183+
for (String sql : new String[] {
184+
"SELECT DBLINK_EXEC('x','DELETE FROM t')",
185+
"SELECT dblink_exec ('x','DELETE FROM t')",
186+
"SELECT pg_read_file\n('/etc/passwd')",
187+
"WITH x AS (SELECT pg_read_file('/etc/passwd') AS f) SELECT * FROM x"
188+
}) {
189+
assertFalse(service.validateReadOnlySql(sql, true).ok(), "should refuse: " + sql);
190+
}
191+
}
192+
193+
/**
194+
* The guard must match a function *call*, not a name that merely appears. Column and
195+
* table names are ordinary identifiers and plenty of schemas contain them — the same
196+
* mistake CLAUDE.md records for the old \\bCOMMENT\\b rule, which rejected
197+
* `SELECT * FROM comment`.
198+
*/
199+
@Test
200+
void stillAllowsIdentifiersThatMerelyResembleADangerousFunction() {
201+
for (String sql : new String[] {
202+
"SELECT * FROM public.dblink_audit",
203+
"SELECT t.pg_read_file_count FROM public.stats t",
204+
"SELECT load_file_name FROM public.imports",
205+
"SELECT * FROM comment"
206+
}) {
207+
assertTrue(service.validateReadOnlySql(sql, true).ok(), "should allow: " + sql);
208+
}
209+
}
210+
211+
@Test
212+
void stillAllowsOrdinaryAnalyticQueries() {
213+
assertTrue(service.validateReadOnlySql(
214+
"SELECT count(*), sum(o.total) FROM public.orders o WHERE o.created_at >= '2026-01-01'", true).ok());
215+
}
125216
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# A SELECT could write through both read-only layers
2+
3+
*Found 2026-09-10 in a repository-wide security audit; reproduced against a live PostgreSQL
4+
2026-09-11. Severity: critical.*
5+
6+
## What was wrong
7+
8+
Both of the product's read-only defences were bypassed by one statement:
9+
10+
```sql
11+
SELECT dblink_exec('dbname=app user=postgres host=127.0.0.1', 'DELETE FROM orders')
12+
```
13+
14+
**The guard failed** because `McpSqlGuardService` classifies by statement *verb*. The statement
15+
begins `SELECT`, which is on `ALLOWED_READ_ONLY_KEYWORDS`, and none of the 15
16+
`FORBIDDEN_SQL_KEYWORDS` appears anywhere in it. `dblink_exec` is a *function call* — invisible
17+
to a verb-based parser.
18+
19+
**`connection.setReadOnly(true)` failed** for a subtler reason, and this is the part worth
20+
understanding: `dblink` opens a **new outbound connection** to the database. That second
21+
connection runs its own transaction, which is not read-only. The read-only flag constrains the
22+
session it is set on; it cannot constrain a session the query itself dials out and creates.
23+
24+
CLAUDE.md describes `setReadOnly(true)` as the backstop that "keeps the *next* parser gap from
25+
becoming data loss". That holds for ordinary writes — `SELECT … INTO`, `nextval`, `lo_import`
26+
and volatile writer functions are all correctly refused by it. It does not hold for a function
27+
that leaves the session.
28+
29+
## Reproduced, not inferred
30+
31+
Against a real PostgreSQL, in an isolated `zz_sec` schema (created and dropped for the test):
32+
33+
```
34+
rows before 3
35+
BEGIN TRANSACTION READ ONLY
36+
SELECT dblink_exec('dbname=dba_agent …','DELETE FROM zz_sec.victim')
37+
dblink_exec
38+
-------------
39+
DELETE 3
40+
COMMIT
41+
rows after 0
42+
```
43+
44+
A `DELETE` ran to completion inside an explicitly read-only transaction.
45+
46+
The same class of function reads the database server's filesystem, also under read-only:
47+
48+
```
49+
BEGIN TRANSACTION READ ONLY
50+
SELECT length(pg_read_file('/etc/hostname')) -> 13
51+
```
52+
53+
And the guard permitted every one of them. Running the shipped `validateReadOnlySql` over the
54+
payloads directly returned `ALLOWED` for `dblink_exec`, `dblink`, `pg_read_file`, `pg_ls_dir`,
55+
`pg_read_binary_file`, `lo_import` and `LOAD_FILE`.
56+
57+
Until public dashboard queries were bound to their published shapes, this was reachable from
58+
the **unauthenticated** share endpoint, which runs through the same executor.
59+
60+
## The fix
61+
62+
A denylist of functions that read or write outside the session, checked after the verb checks
63+
in both guards:
64+
65+
```java
66+
private static final List<String> DANGEROUS_SQL_FUNCTIONS = List.of(
67+
"dblink", "dblink_exec", "dblink_connect", "dblink_open", "dblink_send_query",
68+
"pg_read_file", "pg_read_binary_file", "pg_ls_dir", "pg_stat_file",
69+
"lo_import", "lo_export",
70+
"load_file");
71+
```
72+
73+
A denylist is usually the wrong shape. It is the right shape *here* because the guard's
74+
allowlist governs **verbs**, and there is no allowlist of functions that may appear inside a
75+
`SELECT` — the set of legitimate functions is open-ended, while the set that escapes the
76+
session is small and nameable.
77+
78+
**Matched as a call, not as a name.** The pattern requires the name, optional whitespace, then
79+
an open paren, with a leading boundary check:
80+
81+
```java
82+
"(?<![\\w$.])(" + DANGEROUS_FUNCTION_ALTERNATION + ")\\s*\\("
83+
```
84+
85+
Matching the bare name would reject ordinary identifiers — a `dblink_audit` table, a
86+
`load_file_name` column — which is exactly the mistake CLAUDE.md records for the old
87+
`\bCOMMENT\b` rule that rejected `SELECT * FROM comment`. The boundary also stops a different
88+
function such as `my_dblink(` from matching. Inspection runs on text with comments and string
89+
literals already stripped, so neither `/*x*/dblink_exec(` nor a name inside a quoted literal
90+
can hide or falsely trigger a match.
91+
92+
## Both guards, or neither
93+
94+
`McpSqlGuardService.java` and `mcp/deepsql-phase1-lib.js` are a functional mirror of each
95+
other. A statement one blocks and the other allows *is* the bypass, so the change landed in
96+
both and parity is verified directly: 20 payloads — 14 attacks, 6 legitimate queries including
97+
the identifier false-positives — run through both implementations, **0 mismatches**.
98+
99+
## Verification
100+
101+
| Step | Result |
102+
|---|---|
103+
| Tests before the fix (RED) | 5 failures, all "expected false but was true" |
104+
| Tests after the fix (GREEN) | 19 pass |
105+
| Denylist stubbed to `return null` (mutation) | 5 fail again — the tests guard the fix |
106+
| Java/JS parity over 20 payloads | 0 mismatches |
107+
| Live attack replayed after the fix | blocked; table still 3 rows, unchanged |
108+
| Backend suites | 82 tests, 0 failures |
109+
| MCP suite | 272 tests, 0 failures |
110+
111+
The `zz_sec` schema and the `dblink` extension created for this test were dropped; the database
112+
is back to its prior state.
113+
114+
## Residual work
115+
116+
- **`ExplainPlanService` opens its own connection and never calls `setReadOnly(true)`** — a
117+
`grep` for `setReadOnly` over `src/main/java` returns exactly one hit, in
118+
`QueryExecutorService`. The guard now covers the function class on that path too, but the
119+
database-level backstop is still absent there.
120+
- **`COPY … FROM/TO PROGRAM`** is blocked today by the `COPY` verb being on the forbidden list,
121+
not by this denylist. That is sufficient, but it means the protection depends on a verb rule
122+
rather than the function rule, which is worth knowing if the verb list is ever narrowed.
123+
- Revoking `EXECUTE` on these functions from the connection role, and not provisioning
124+
superuser connection users, remains the stronger control. The guard reduces blast radius; it
125+
does not replace database-level permissions.

mcp/deepsql-phase1-lib.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,27 @@ const FORBIDDEN_SQL_KEYWORDS = [
2727
"COMMENT",
2828
];
2929

30+
// Functions that read or write outside the current read-only session. Every check in this
31+
// guard classifies by statement *verb*, so a SELECT calling one of these passes cleanly, and
32+
// connection.setReadOnly(true) cannot stop them: dblink opens a new outbound connection whose
33+
// transaction is not read-only. Verified against a real PostgreSQL — inside an explicit
34+
// BEGIN TRANSACTION READ ONLY, `SELECT dblink_exec(..., 'DELETE FROM t')` reported DELETE 3
35+
// and the table went from three rows to zero. Mirrors DANGEROUS_SQL_FUNCTIONS in
36+
// McpSqlGuardService.java; the two must stay in sync or a statement one blocks the other allows.
37+
const DANGEROUS_SQL_FUNCTIONS = [
38+
"dblink", "dblink_exec", "dblink_connect", "dblink_open", "dblink_send_query",
39+
"pg_read_file", "pg_read_binary_file", "pg_ls_dir", "pg_stat_file",
40+
"lo_import", "lo_export",
41+
"load_file",
42+
];
43+
44+
// The function being *called* — name, optional whitespace, open paren. Matching the bare name
45+
// would reject ordinary identifiers like a dblink_audit table or a load_file_name column.
46+
const DANGEROUS_FUNCTION_CALL = new RegExp(
47+
`(?<![\\w$.])(${DANGEROUS_SQL_FUNCTIONS.join("|")})\\s*\\(`,
48+
"i"
49+
);
50+
3051
const FORBIDDEN_SQL_KEYWORD_SET = new Set(FORBIDDEN_SQL_KEYWORDS);
3152
const FORBIDDEN_ALTERNATION = FORBIDDEN_SQL_KEYWORDS.join("|");
3253
const CTE_MUTATION_PATTERN = new RegExp(
@@ -1158,6 +1179,11 @@ function containsForbiddenKeyword(sql) {
11581179
return findForbiddenMutation(inspect);
11591180
}
11601181

1182+
function containsDangerousFunction(sql) {
1183+
const match = DANGEROUS_FUNCTION_CALL.exec(normalizeSqlForInspection(sql));
1184+
return match ? match[1].toLowerCase() : null;
1185+
}
1186+
11611187
function validateReadOnlySql(sql, { allowExplain = true } = {}) {
11621188
if (!sql || !String(sql).trim()) {
11631189
return {
@@ -1205,6 +1231,14 @@ function validateReadOnlySql(sql, { allowExplain = true } = {}) {
12051231
};
12061232
}
12071233

1234+
const dangerousFunction = containsDangerousFunction(statement);
1235+
if (dangerousFunction) {
1236+
return {
1237+
ok: false,
1238+
reason: `Blocked SQL function that reads or writes outside this session: ${dangerousFunction}.`,
1239+
};
1240+
}
1241+
12081242
return {
12091243
ok: true,
12101244
normalizedQuery: stripTrailingSemicolons(sql),
@@ -2717,6 +2751,7 @@ module.exports = {
27172751
clampInteger,
27182752
compactWhitespace,
27192753
containsForbiddenKeyword,
2754+
containsDangerousFunction,
27202755
createConfigFromEnv,
27212756
firstKeyword,
27222757
getAuthToken,

0 commit comments

Comments
 (0)