|
| 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. |
0 commit comments