Skip to content

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

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions