The Function Was Forbidden. Changing Where It Appeared in the Query Made It Authorized.
CVE-2026-85620, published 4 September 2026, describes Postgres MCP Pro's restricted mode blocking pg_read_file by name when it appears as an ordinary function call, then executing the identical function and returning file contents when the same call appears inside a FROM clause instead. This record independently ran the affected validator against the real source and confirmed the exact traversal gap: a FROM clause function parses as a RangeFunction node whose functions attribute nests each call inside a tuple, and the validator's own recursive walk checks whether each item is a node directly, never unwrapping that tuple to find the call hidden inside it. A proposed fix, pull request 200, remains open and unmerged, and this record confirmed the same gap still stands on the project's current default branch.
Event analysed: . This analysis was published on 5 September 2026.
Nobody's decision. It was an unopened container. GitHub Security Advisory GHSA-cm87-gp55-7cgj formally assigns CVE-2026-85620 on 4 September 2026, reporting a CVSS 4.0 base score of 9.2, Critical, under CWE-863, Incorrect Authorization, against Postgres MCP Pro, the PyPI package postgres-mcp maintained by Crystal DBA, through version 0.3.0. The advisory rests on GitHub issue 178, opened 6 June 2026, which states the server's restricted mode, entered with --access-mode=restricted and confirmed directly in this session's own read of server.py, wraps every query in a SafeSqlDriver that parses the SQL with the pglast library and walks the parsed tree, refusing any function call whose name is absent from an explicit ALLOWED_FUNCTIONS set. Moona Intelligence downloaded the exact affected source at the v0.3.0 tag and ran its real _validate method directly, rather than relying on the issue's own description alone. SELECT pg_read_file('/etc/passwd') is rejected. SELECT * FROM pg_read_file('/etc/passwd') AS t, run against the same unmodified validator in the same session, is accepted. This record then parsed the second query with pglast itself to see why: a function named directly in a FROM clause becomes a RangeFunction node, and RangeFunction's own functions attribute does not hold the FuncCall node directly, it holds a plain Python tuple pairing the FuncCall with its alias, and that pairing sits inside a further tuple. The validator's recursive walk, read directly in safe_sql.py, asks of each item in a list or tuple attribute only whether that item is itself a syntax tree node; the pairing is a tuple, not a node, so the walk moves on without ever opening it, and the FuncCall it contains, the one thing the allow list exists to check, is never reached by that check on this one grammar path. This record independently confirmed pg_ls_dir, pg_read_binary_file and pg_stat_file all reach execution the same way, and that a write control, CREATE TABLE, is still blocked by the same validator, which keeps the finding to what the evidence supports: a gap in which functions restricted mode's own existing check reaches, not a general collapse of restricted mode or of PostgreSQL's read only transaction handling. Whether reading an arbitrary server file actually succeeds still depends on the connected PostgreSQL role holding pg_read_server_files or superuser privilege, a precondition this record states explicitly rather than implying every restricted mode deployment is equally exposed. Pull request 200, opened 16 August 2026 and unmerged as of this record, adds the missing unwrapping step directly to the same recursive walk and new tests for exactly the blocked and permitted cases; this record ran the patched validator directly and confirmed it closes the bypass while still permitting an allow listed function, unnest, in the same FROM clause position. The identical gap this record ran against the tagged v0.3.0 release also reproduces, confirmed directly, against the project's current default branch commit, so as of this record no shipped release contains the fix.
Read Postgres MCP Pro's own validator directly and the finding is not that a security check is missing. It is present, it is written correctly by the one standard this record could test against it, target list function calls, and it correctly refuses pg_read_file every time this record asked it to. The finding is narrower and more precise: the same check never gets asked the question a second time, once the identical function call is wrapped inside a container the validator's own recursive walk does not open.
What the primary issue and this record's own run of the source establish
GitHub issue 178, filed against crystaldba/postgres-mcp, states its own title plainly: a restricted, read only mode bypass permitting arbitrary server side file read through a FROM clause function. The issue was opened 6 June 2026, naming PostgreSQL 16 as the tested server version and the latest release at the time, 0.3.0, as the affected package. Rather than accept that description on its own, this record downloaded the real source at the v0.3.0 tag, src/postgres_mcp/sql/safe_sql.py, and ran its own SafeSqlDriver class directly against both forms of the same call, with no PostgreSQL connection required, since the class's own _validate method parses and rejects a query before any execution step.
The exact mechanism, read from the parser's own output
SafeSqlDriver's _validate_node method, read directly, does two things relevant here. First, when the node it is looking at is a FuncCall, the class this library uses to represent an ordinary function call, it reads the function's name, strips a pg_catalog schema prefix if present, and raises an error if that name is absent from ALLOWED_FUNCTIONS. Second, once that check runs, the method recurses into the node's own attributes to keep checking whatever they contain: a list attribute is walked item by item, a tuple attribute is walked item by item, and a single node attribute is recursed into directly, in each case asking only whether what it finds is itself a syntax tree node before validating it further.
This record parsed SELECT * FROM pg_read_file('/etc/passwd') AS t with pglast directly to see what that recursion actually meets. The FROM clause item is not a FuncCall. It is a RangeFunction, a node type the validator's own ALLOWED_NODE_TYPES set already permits outright, with no function specific check of its own. RangeFunction carries a functions attribute, and this record confirmed directly that its value is a tuple containing one further tuple, the FuncCall paired with its alias, none. The validator's recursive walk reaches RangeFunction, finds its functions attribute is a tuple, and iterates it exactly once, as its own code requires, asking of that single item, the paired tuple, whether it is a Node. It is not; a tuple is not a Node instance, so the walk moves on without opening it, and the FuncCall pglast placed one level inside it, carrying the very function name the allow list exists to check, is never visited by that check at all.
What this record does not claim: the role privilege boundary
This record keeps two distinct facts separate rather than collapsing them into one claim. The first is the restricted mode bypass itself: a function name check that exists and works on one grammar path does not run at all on another, evidenced directly by this record's own execution of the real validator. The second is a PostgreSQL privilege precondition the primary issue states directly: reading pg_read_file requires the connected role to hold the pg_read_server_files privilege or superuser status. A role without either privilege would have the FROM clause query reach PostgreSQL itself, past the MCP layer's own check, and then be refused there by PostgreSQL's own privilege system; that refusal is PostgreSQL's authority working as intended, not evidence against the MCP layer's own gap. This record states plainly what it does not know: what share of real Postgres MCP Pro deployments run their restricted mode connection under a role holding either privilege, since nothing in the primary issue, the advisory or the affected source establishes that figure, and this record does not claim every restricted mode deployment is equally exposed on that account.
Why the read only transaction wrapper does not stop this
SafeSqlDriver's own execute_query method, read directly, forces force_readonly=True on every query it runs, regardless of what a caller passes, a real and separately correct control against data modifying statements. This record's own point is that a read only PostgreSQL transaction constrains writes to database state, not every effect a function can have. pg_read_file opens and reads a file from the server's own filesystem; nothing about that action modifies a row, a table or a sequence, so PostgreSQL's own read only transaction semantics have no independent reason to refuse it once a query reaches the server at all. The authority question restricted mode exists to answer, which functions an MCP agent may invoke through this PostgreSQL connection, is narrower than and separate from whether the resulting transaction can write, and this record treats a query executing under a read only transaction as a fact about database mutability, never as evidence that the function it ran was an authorized one to reach.
The proposed fix, verified by running it
Pull request 200, titled by its own author fix: Block dangerous functions in FROM clause (RangeFunction allowlist bypass), was opened 16 August 2026 against issue 178 and remained open and unmerged as of this record. Rather than take the pull request's own description of its change at face value, this record applied its diff to the same v0.3.0 source this record had already tested and ran the patched validator directly. SELECT * FROM pg_read_file('/etc/passwd') AS t, accepted a moment earlier by the unpatched validator, is rejected by the patched one with the same Error validating query result the target list form already produced. SELECT * FROM unnest(ARRAY[1,2,3]) AS t, an allow listed function reached through the identical FROM clause position, continues to validate cleanly, confirming the patch narrows coverage to exactly the previously unreached case rather than blocking the grammar path outright. The pull request's own diff adds a further isinstance check, elif isinstance(item, tuple), inside both the list and the tuple branches of the same recursive walk, unwrapping one further level and validating whatever Node instances it finds there, precisely the step this record's own reading of the AST had already identified as absent.
The Authority Provenance ledger
Authority grantor. The operator who deploys Postgres MCP Pro, chooses --access-mode=restricted specifically to constrain what an agent may do through it, and configures the PostgreSQL role the restricted connection authenticates as.
Caller identity. Whatever MCP client or agent the operator connects to the restricted server; nothing in the primary issue or the affected source describes an authentication layer on the MCP transport itself as a factor in this specific bypass.
Caller mandate. Whatever task the operator intended restricted mode to permit, database queries and read only operations, expressly not arbitrary server side file access, which is the entire reason restricted mode maintains an explicit function name allow list rather than permitting any function PostgreSQL itself would accept.
Intermediary. Postgres MCP Pro's own SafeSqlDriver, specifically its _validate_node method and the recursive walk over a parsed query's syntax tree.
Intermediary credential. The PostgreSQL role the restricted connection authenticates as, whatever privileges that role separately holds.
Intermediary network authority. Not the operative boundary here; the consequential reach is filesystem access on the PostgreSQL server's own host, mediated through a PostgreSQL function rather than through a network connection this session's mechanism opens.
Downstream target. Before the bypass, presumably the database objects and read operations restricted mode's allow list was built to permit. As demonstrated by this record's own run of the real validator, any server side file the connected role's own PostgreSQL privileges can reach through pg_read_file, pg_ls_dir, pg_read_binary_file or pg_stat_file.
Failed limit. The function name allow list check, present and correctly enforced on a FuncCall node reached directly, never reached at all on the identical function nested inside a RangeFunction's own functions attribute, confirmed directly against the real recursive walk in safe_sql.py.
Inherited assumption. That every node type the validator's own ALLOWED_NODE_TYPES set permits was itself either safe to allow outright or fully opened by the same recursive walk that validates a FuncCall reached directly; RangeFunction satisfied the first condition, being a legitimate way to select rows from a set returning function such as unnest, without satisfying the second, since its own function carrying attribute nests one container deeper than that walk was written to unwrap.
Credential binding. Not the mechanism at issue; the PostgreSQL role's own credentials are unchanged between the blocked and the bypassing query, confirming the difference is entirely in which grammar path the identical function name takes through the parser, not in any credential the query carries.
Destination binding. Absent on this one grammar path at the time of this record's own test. The allow list exists and is correctly bound to a FuncCall's own function name; it is not bound to the equivalent FuncCall this record confirmed sits nested inside a RangeFunction's functions attribute.
Challenge authority. None on the MCP layer for the specific function once the query passes _validate; whatever the connected PostgreSQL role's own privilege grants separately permit or refuse once the query reaches the server, a distinct and already correctly enforced boundary this record does not describe as broken.
Revocation or modification. Not applicable; no fix has shipped in a released version as of this record, confirmed directly against the project's own tag list and current default branch.
Recovery path. Pull request 200 proposes the specific corrective, unwrapping the nested tuple the recursive walk currently stops short of, and this record independently ran the patched validator and confirmed it closes exactly this gap while leaving an allow listed FROM clause function, unnest, unaffected. It remains open and unmerged.
Exploitation. Unknown. No source available to this record states the vulnerability has been exploited against a production Postgres MCP Pro deployment. The primary issue's own proof of concept is a controlled demonstration, not evidence of exploitation in the wild.
Provenance evidence quality. Strong for the mechanism itself: this record downloaded the affected source directly at the v0.3.0 tag and at the project's current default branch head, ran the real, unmodified SafeSqlDriver class against both forms of four distinct dangerous functions and against a write control, parsed the bypass query with pglast directly to confirm the RangeFunction and nested tuple structure, and applied and ran pull request 200's own diff to confirm what it does and does not change. Weaker for the underlying CVE Program record itself: cve.org and nvd.nist.gov were both blocked to direct fetch in this session on every attempt, and this record relies on the GitHub Advisory Database's own record rather than a direct read of the Program's own page; that same advisory record attributes publication to the National Vulnerability Database without this record independently confirming which body served as the assigning CVE Numbering Authority.
Where this sits in the pattern
OGX's own advisory for CVE-2026-85666 already showed a validation function that exists, is written correctly, and already governs two sibling caller controlled inputs elsewhere in the same codebase, simply never connected to the one path that needed it. This record reads Postgres MCP Pro's own mechanism as a further variant of the same shape at a different layer: the missing connection here is not between a check and a call site a developer forgot to add it to, but inside the security validator's own recursive traversal, between one syntax tree container the validator opens correctly and a semantically identical container, produced by the same parser for the same underlying operation, that the walk does not open. The wider thesis that execution authority has to be evaluated against the action actually taken, not assumed from an earlier, adjacent form of the same request, extends here to a boundary drawn by a parser library's own node taxonomy rather than by the MCP server's own authors: PostgreSQL's grammar accepts the identical function call in more than one syntactic position, and restricted mode's own authority to say no held only for the one position its validator was written to fully open.
What this record does not establish
This record does not claim CVE-2026-85620 has been exploited against a real Postgres MCP Pro deployment, that every restricted mode deployment connects as a PostgreSQL role holding pg_read_server_files or superuser privilege, that restricted mode permits arbitrary SQL writes, that PostgreSQL's own read only transaction handling is broken, that pull request 200 has merged or shipped in a released version, or that every alternate way PostgreSQL's own grammar can express a function call bypasses this validator, beyond the FROM clause position this record directly tested. Where the evidence available to this record does not establish a fact, this record states it as unknown rather than inferring it from the pattern this weakness class already shows elsewhere.
Sources
This analysis interprets third-party reporting, research and announcements. Moona is not the original reporter of the underlying events.
Protocol evidence
This record does not assess these architectures. The connection runs through the Risk Registry requirement each one bears on, and these published authority architectures are what the evidence says about that requirement.
Protocol evidence related through AEW-020 An operation blocked in one syntactic form is authorized in an equivalent one
- Supports requirement
Agent Control Standard (ACS)
OWASP GenAI Security Project, originally Zenity
Requirement The specification does not guarantee complete mediation of every consequential action a host can take
ACS's own conformance document states directly that the specification does not guarantee complete mediation of every consequential action a host can take, and that a primitive bypassing its own toolCallRequest hook is invisible to policy. Postgres MCP Pro's restricted mode is concrete evidence for exactly the concern that admission names in the abstract: a mediation point that is present, correctly implemented and independently confirmed to work against one representation of a consequential operation still does not guarantee coverage of every representation the same underlying grammar can produce for it. This entry's own execution of the real, patched and unpatched validator against the same four functions supports the requirement that policy coverage be verified against the full range of representations a parser can produce, not only the one a mediation point's own author tested against. MCPHub's isBlockedIpv6 is the same admission read against a destination guard rather than a tool call hook: this entry's own direct reading of the pre fix and post fix source confirms a mediation point that correctly and completely covered every address form its own test suite exercised still left three standardized transition encodings of the same prohibited resource unmediated, supporting the requirement that coverage be verified against every representation an addressing standard can produce, not only the ones a guard's own author tested against.
This record is the cited evidence for this relationship.
- Supports requirement
Verifiable Attenuated Delegation for AI Agent Chains (draft-asor-wimse-agent-delegation-chain)
Rafael Asor, Attenu
Requirement A verifier encountering an unrecognized constraint type must deny rather than treat it as unconstrained
This draft's own verified requirement is that a verifier encountering an unrecognized constraint type must deny rather than treat it as unconstrained, so that a newer, unrecognized restriction is never silently read as no restriction at all. Postgres MCP Pro's SafeSqlDriver shows the inverse failure at a different layer, its own recursive AST traversal encountering a container it does not open, the tuple nested inside RangeFunction's own functions attribute, and treating what it never inspected as though it carried nothing requiring a check, rather than denying by default. This entry's own execution of the real validator confirms the same allow list check works correctly wherever the traversal does open the container it needs to, which is exactly why an unopened one silently passing is the more precise finding here, direct evidence for why a verifier's default posture toward a structure it does not fully parse matters generally, not only for this one draft's own constraint objects. MCPHub's isBlockedIpv6 supports the same requirement at a resource identity boundary rather than a syntax tree boundary: an IPv6 address encoded through the NAT64, 6to4 or Teredo transition prefixes was an unrecognized representation isBlockedIpv6 silently treated as unconstrained, returning false rather than denying by default, until the fix taught the function to recognize those three encodings as the blocked addresses they carry.
This record is the cited evidence for this relationship.
