Skip to content

[Security] Restricted (read-only) mode bypass: arbitrary server-side file read via FROM-clause function #178

@geo-chen

Description

@geo-chen

Hi. info@crystaldba.ai bounced and security advisory isnt enabled so I’m sharing my report here.

Package: postgres-mcp (Postgres MCP Pro, PyPI)
Affected Versions: confirmed on 0.3.0 (latest)
CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N
CWE: CWE-863 Incorrect Authorization (safe-SQL allowlist bypass) leading to CWE-22 file disclosure

Summary

In --access-mode=restricted, the server validates SQL with an AST allowlist (SafeSqlDriver) and a read-only transaction. The function-name allowlist is checked only on FuncCall AST nodes, but a function placed in the FROM clause parses as a RangeFunction node (which is in the allowed node-type set) and never has its name checked against ALLOWED_FUNCTIONS. As a result, dangerous server-side functions such as pg_read_file can be invoked from the FROM clause, reading arbitrary files the database process can access, while bypassing the restricted-mode safety control. Confirmed on 0.3.0: SELECT pg_read_file('/etc/passwd') in the target list is blocked, but SELECT * FROM pg_read_file('/etc/passwd') returns the file.

Details

postgres_mcp/sql/safe_sql.py: the function-name allowlist check is gated on FuncCall nodes (around line 894: if isinstance(node, FuncCall): ... if unqualified_name not in self.ALLOWED_FUNCTIONS: reject). RangeFunction (a function used as a table in FROM) is included in ALLOWED_NODE_TYPES (around line 713) and is walked, but a FROM-clause function parses as a RangeFunction whose inner function name is not a FuncCall in the position the check inspects, so it is never validated against ALLOWED_FUNCTIONS. SafeSqlDriver.execute_query validates then runs the query force_readonly=True; pg_read_file is a read, so the read-only transaction (sql_driver.py) does not stop it. The same applies to pg_ls_dir, pg_read_binary_file, pg_stat_file.

PoC

validated on postgres-mcp 0.3.0 by driving the real SafeSqlDriver (restricted mode) against PostgreSQL 16 (default superuser, which has pg_read_server_files):

control (target-list pg_read_file) -> BLOCKED: Error validating query: SELECT pg_read_file('/etc/passwd')
FROM-clause pg_read_file -> "{'t': 'root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/..."
LFI BYPASS: CONFIRMED (restricted read-only mode read /etc/passwd via FROM-clause function)
write CREATE TABLE -> BLOCKED

SELECT * FROM pg_read_file('/etc/passwd') AS t returned the contents of /etc/passwd; the target-list form and writes remained blocked, confirming the gap is specifically the unchecked FROM-clause (RangeFunction) function.

Impact

A server deliberately placed in restricted/read-only mode (the safe configuration for exposing a database to an LLM agent) can be made to read arbitrary files on the database host (configuration, secrets, key material, /etc/passwd) when the connection role has pg_read_server_files or is superuser (the common default). The SQL is attacker-controllable through the execute_sql tool arguments, which are LLM-produced and steerable via prompt injection, so the read-only safety control is bypassed for arbitrary file disclosure.

Remediation

Apply the function-name allowlist to FROM-clause functions: when validating a RangeFunction, resolve and check the underlying function name against ALLOWED_FUNCTIONS exactly as for FuncCall (recurse into RangeFunction.functions). Additionally, run restricted mode under a least-privilege role without pg_read_server_files / superuser so server-side file functions are unavailable even if a parser gap is found.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions