mutating-sql

Mutating SQL blocked

shipped

The sections below are generated from the CLI (methodology 0.3-draft, CLI 0.3.0); the same text is printed by sealmark explain mutating-sql.

pack
db
weight
critical
scope
server (tested as a role that can write)
decided by
db-stateresponse
since
0.1-draft (methodology 0.3-draft)
config keys
checks.mutating-sql.corpus.addchecks.mutating-sql.corpus.removechecks.mutating-sql.enabledchecks.mutating-sql.severity

Probes

  • stacked statements (DROP) SELECT 1; DROP TABLE audit_probe core
  • stacked statements (INSERT) SELECT 1; INSERT INTO probe_t (id, v) VALUES (9002, 'stacked') core
  • COMMIT then write (read-only transaction escape) COMMIT; INSERT INTO probe_t (id, v) VALUES (9005, 'commit-escape') core
  • ROLLBACK then write (read-only transaction escape) ROLLBACK; INSERT INTO probe_t (id, v) VALUES (9014, 'rollback-escape') core
  • END then write (read-only transaction escape) END; INSERT INTO probe_t (id, v) VALUES (9015, 'end-escape') core
  • ABORT then write (read-only transaction escape) ABORT; INSERT INTO probe_t (id, v) VALUES (9016, 'abort-escape') core
  • case and comment obfuscation (INSERT) /*x*/ iNsErT INTO probe_t VALUES (9008,'obf') core
  • data-modifying CTE (DELETE) WITH d AS (DELETE FROM probe_t WHERE id = 9003 RETURNING id) SELECT count(*) FROM d
  • data-modifying CTE (INSERT) WITH i AS (INSERT INTO probe_t (id, v) VALUES (9004, 'cte') RETURNING id) SELECT * FROM i
  • function with side effects (defeats any prefix filter by construction) SELECT sm_mutate()
  • anonymous DO block DO $$ BEGIN INSERT INTO probe_t VALUES (9007,'do'); END $$
  • TRUNCATE TRUNCATE probe_t
  • DDL (CREATE TABLE) CREATE TABLE sm_ddl_probe (x int)
  • DDL via SELECT INTO SELECT 1 INTO sm_ddl_probe
  • PREPARE then EXECUTE PREPARE p AS INSERT INTO probe_t VALUES (9011,'prep') -> EXECUTE p
  • session state (disable read-only, then write) SELECT set_config('default_transaction_read_only', 'off', false) -> INSERT INTO probe_t (id, v) VALUES (9006, 'session')
  • PREPARE and EXECUTE in one call PREPARE p2 AS INSERT INTO probe_t VALUES (9012,'prep1'); EXECUTE p2
  • function with side effects in FROM SELECT * FROM sm_mutate()
  • EXPLAIN ANALYZE runs the statement EXPLAIN ANALYZE INSERT INTO probe_t (id, v) VALUES (9013, 'explain')

core marks the canonical probes of the check; the others are extra coverage. How they decide the verdict is stated under How the verdict is established.

Settings and thresholds

Every check also takes checks.mutating-sql.enabled and checks.mutating-sql.severity; they are described once in the configuration reference.

This check has no numeric threshold.

KeyDefaultStandardMeaning
checks.mutating-sql.corpus.add[]lockedExtra probes for mutating-sql, validated against the pack's corpus schema. Appends across layers. Any addition makes the run CUSTOM.
checks.mutating-sql.corpus.remove[]lockedProbe ids or exact strings to drop from the mutating-sql corpus. Appends across layers. Any removal makes the run CUSTOM.

How the verdict is established

  • Scoring. critical and scored: pass = 1 point, part = 0.5, fail = 0. A fail caps the pack's grade at D, whatever the other checks scored.

What to fix

Parse the statement (do not prefix-match); reject stacked statements, every transaction terminator that can end a read-only wrapper (COMMIT, ROLLBACK, END and ABORT, each followed by a write), data-modifying CTEs, DO blocks and SELECT INTO; run reads in a READ ONLY transaction as a role without write grants. Filtering the word COMMIT alone leaves the other three open. A SQL tool that has another required parameter is graded with it filled; if the control read fails, name the parameter under sql_tools: [{tool, arg, args: {<param>: <value>}}].

False positives

Only a change the harness finds in the database counts as executed. There is one transaction-escape case per terminator keyword Postgres accepts (COMMIT, ROLLBACK, END, ABORT); COMMIT WORK and COMMIT TRANSACTION share the keyword a filter would match, and ... AND CHAIN starts a new transaction with the same read-only characteristic, so neither is an escape and neither is a case. The side-effect function cases (in the select list and in FROM) defeat any prefix filter by construction, so they are not core cases and only cost half a point; so do the single-call PREPARE and EXECUTE and the EXPLAIN ANALYZE of a write.

False negatives

The corpus is finite and published. A server that blocks every listed form can still be bypassed by one that is not listed.

Notes

A database MCP server that calls itself read-only usually enforces that with a text filter or a read-only transaction. Both have well-known bypasses. This check sends 16 mutation forms as the admin role and reads the database afterwards to see whether any of them took effect.

Probe details#

Run as sm_admin, once per SQL tool. Before every case the harness resets the scratch state over its own connection (truncates probe_t, restores tenant names, drops sm_ddl_probe, recreates audit_probe if a probe dropped it). The same admin control read as in read-only must pass first. Where a case has several steps, each step is its own tool call, run in order. The statements are in the Probes list above, in this order; the numbers below match it, and the comment lines the SQL carries are not sent.

After each case the harness reads the database for the effect below. The server's response text is recorded in the evidence but does not decide.

#CaseEffect looked for
1stacked statements (DROP)audit_probe no longer exists
2stacked statements (INSERT)probe_t row 9002 exists
3COMMIT then write (read-only transaction escape)row 9005 exists
4case and comment obfuscation (INSERT)row 9008 exists
5data-modifying CTE (DELETE)row 9003 is gone (seeded first)
6data-modifying CTE (INSERT)row 9004 exists
7function with side effectsprobe_t has a row with v = 'fn'
8anonymous DO blockrow 9007 exists
9TRUNCATErow 9009 is gone (seeded first)
10DDL (CREATE TABLE)table sm_ddl_probe exists
11DDL via SELECT INTOtable sm_ddl_probe exists
12PREPARE then EXECUTErow 9011 exists
13session state (disable read-only, then write)row 9006 exists
14PREPARE and EXECUTE in one callrow 9012 exists
15function with side effects in FROMprobe_t has a row with v = 'fn'
16EXPLAIN ANALYZE runs the statementrow 9013 exists

Cases 1 to 4 are the core cases: the canonical bypasses of a "read-only SQL tool".

Notes on the corpus:

  • Cases 1 and 2 need multi-statement support, which only the simple-query protocol provides. A server that uses the extended protocol rejects multiple statements, so it legitimately "blocks" them.
  • Case 3 works only if the server wraps queries in a transaction and does not stop COMMIT.
  • Case 13 relies on a session setting outliving the call, which a rollback undoes, and on both steps reaching the same database session. A server that opens a new connection per call blocks it by accident, and the corpus cannot tell the difference.
  • sm_mutate() is a volatile SQL function in the seed that inserts (9100, 'fn') into probe_t. Case 7 calls it in the select list and case 15 in the FROM clause: a filter that blocks function calls in the select list can still let the second form through.
  • Cases 12 and 14 are the same statement sent two ways. A server that opens a new connection per call blocks the two-call form by accident (the prepared statement does not exist on the next connection) but not the single-call form.
  • Case 16 is a read form: EXPLAIN ANALYZE executes the statement it explains, so a filter that allows everything starting with EXPLAIN lets a write through.

Thresholds#

VerdictWhen
passall 16 forms had no effect, on every SQL tool
partialthe four core forms were blocked, but an indirect form changed the database
faila core form (stacked statement, COMMIT escape or obfuscated write) changed the database
inconclusivethe server stopped answering part-way through the corpus, and no core case had taken effect

When the server goes quiet part-way, the remaining cases are not run. A core effect seen before that still gives fail. A non-core effect seen earlier stays in the evidence, but the verdict is inconclusive.

Why the split: stacked statements, a COMMIT that ends a read-only wrapper, and case or comment obfuscation defeat naive filters and read-only wrappers alike. A server that fails them is not read-only in any meaningful sense. Cases 5 to 16 are blocked reliably only by a read-only transaction or a read-only role, not by text filtering, so a miss there indicates a weaker enforcement design rather than a bypass of an advertised filter. The split is a judgment call and may change before 1.0.

Limits#

  • The corpus is finite: COPY ... TO PROGRAM, large objects, pg_read_file, sequences (nextval), advisory locks, extensions and privilege escalation are not tried, and the strings are published, so a server that matches the harness's exact strings is not caught.
  • A tool you list under sql_tools is judged as a read tool, so a server with a legitimate write tool listed there fails.
  • The state reader is a separate connection, so a false positive from a side route the server cannot control is very unlikely. A different Postgres version may behave differently.