pg-guard-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pg_guard_mcp-0.1.0/.env.example +9 -0
- pg_guard_mcp-0.1.0/.gitignore +8 -0
- pg_guard_mcp-0.1.0/PKG-INFO +67 -0
- pg_guard_mcp-0.1.0/README.md +52 -0
- pg_guard_mcp-0.1.0/pyproject.toml +33 -0
- pg_guard_mcp-0.1.0/scripts/setup_dev_db.sh +30 -0
- pg_guard_mcp-0.1.0/src/pg_guard_mcp/__init__.py +4 -0
- pg_guard_mcp-0.1.0/src/pg_guard_mcp/db.py +97 -0
- pg_guard_mcp-0.1.0/src/pg_guard_mcp/safety.py +197 -0
- pg_guard_mcp-0.1.0/src/pg_guard_mcp/server.py +173 -0
- pg_guard_mcp-0.1.0/tests/test_db.py +110 -0
- pg_guard_mcp-0.1.0/tests/test_safety.py +191 -0
- pg_guard_mcp-0.1.0/tests/test_server.py +109 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Copy to .env or set in your MCP client's env config. Never commit real
|
|
2
|
+
# credentials — this file documents the shape, it holds no real secret.
|
|
3
|
+
|
|
4
|
+
# Full libpq connection string. Preferred over the individual PG* vars below.
|
|
5
|
+
PG_GUARD_DSN=host=127.0.0.1 dbname=mydb user=myapp_readonly password=change_me
|
|
6
|
+
|
|
7
|
+
# Optional tuning (defaults shown):
|
|
8
|
+
# PG_GUARD_ROW_LIMIT=1000
|
|
9
|
+
# PG_GUARD_STATEMENT_TIMEOUT_MS=10000
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pg-guard-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A PostgreSQL MCP server that enforces read-only access at the protocol and privilege level — not by parsing the query string.
|
|
5
|
+
Author: Berkant Acun
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: mcp,model-context-protocol,postgres,postgresql,read-only,security
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: mcp>=1.2.0
|
|
10
|
+
Requires-Dist: psycopg[binary]>=3.2.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# pg-guard-mcp
|
|
17
|
+
|
|
18
|
+
A PostgreSQL MCP server that enforces read-only access at the **protocol and privilege level** — not by parsing the query string and hoping.
|
|
19
|
+
|
|
20
|
+
## Why this exists
|
|
21
|
+
|
|
22
|
+
The official `@modelcontextprotocol/server-postgres` shipped a read-only mode that a single `COMMIT;` could bypass: it wrapped the agent's query in `BEGIN TRANSACTION READ ONLY` and sent the whole thing as one string. Postgres accepts semicolon-separated multiple statements in that mode, so `SELECT 1; COMMIT; DROP SCHEMA public CASCADE;` closed the read-only transaction early and ran the drop as an ordinary write. The package was deprecated over it. ([Datadog Security Labs writeup](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/))
|
|
23
|
+
|
|
24
|
+
pg-guard-mcp exists because that bug class — "read-only" enforced only by string inspection — is still common across the MCP ecosystem. It defends in three independent layers, so no single mistake is fatal:
|
|
25
|
+
|
|
26
|
+
1. **Protocol layer (the real boundary).** Every query runs through Postgres's *extended* query protocol (`Parse`/`Bind`/`Execute`), never the simple query protocol. The extended protocol structurally rejects more than one statement per `Parse` message — Postgres itself refuses it, before any of our code runs. This is why the Datadog exploit cannot work here regardless of what string is submitted.
|
|
27
|
+
2. **Session layer.** Every connection sets `default_transaction_read_only = on` at the session level, so even a query that somehow reached the database as a write is rejected by Postgres.
|
|
28
|
+
3. **Pre-flight layer.** Before a query is even sent, it's checked for multiple statements and transaction-control keywords (`COMMIT`, `ROLLBACK`, `BEGIN`, `SAVEPOINT`, ...) and rejected with a clear error. This exists to fail fast and loud, not as the primary defense.
|
|
29
|
+
|
|
30
|
+
On top of that, connecting with a database role that has had write privileges `REVOKE`d is the recommended (and startup-checked) setup — belt and suspenders at the privilege layer too.
|
|
31
|
+
|
|
32
|
+
## Tools
|
|
33
|
+
|
|
34
|
+
| Tool | Does |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `pg_run_query(sql)` | Run one read-only statement, return rows |
|
|
37
|
+
| `pg_explain_query(sql)` | Return the query plan without running it |
|
|
38
|
+
| `pg_list_tables(schema="public")` | List tables/views in a schema |
|
|
39
|
+
| `pg_describe_table(table_name, schema="public")` | List a table's columns |
|
|
40
|
+
| `pg_check_privileges()` | Report any write grant the connected role actually holds — should always come back empty |
|
|
41
|
+
|
|
42
|
+
## Setup
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install -e ".[dev]"
|
|
46
|
+
export PG_GUARD_DSN="host=127.0.0.1 dbname=mydb user=myapp_readonly password=..."
|
|
47
|
+
python -m pg_guard_mcp.server
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
See `.env.example` for all supported environment variables, and `scripts/setup_dev_db.sh` for a working example of setting up a properly-restricted read-only role (the setup this project's own tests run against).
|
|
51
|
+
|
|
52
|
+
## Testing
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install -e ".[dev]"
|
|
56
|
+
pytest tests/ -v
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`tests/test_safety.py` is pure-Python and needs no database. `tests/test_db.py` and `tests/test_server.py` run against a real local PostgreSQL instance — including the exact exploit payload that deprecated the official Postgres MCP server — and skip automatically if `pgguard_test` isn't reachable. Run `scripts/setup_dev_db.sh` once to create it.
|
|
60
|
+
|
|
61
|
+
## Status
|
|
62
|
+
|
|
63
|
+
Early build, 58 passing tests (unit + live-Postgres integration). Not yet published to PyPI.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# pg-guard-mcp
|
|
2
|
+
|
|
3
|
+
A PostgreSQL MCP server that enforces read-only access at the **protocol and privilege level** — not by parsing the query string and hoping.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
The official `@modelcontextprotocol/server-postgres` shipped a read-only mode that a single `COMMIT;` could bypass: it wrapped the agent's query in `BEGIN TRANSACTION READ ONLY` and sent the whole thing as one string. Postgres accepts semicolon-separated multiple statements in that mode, so `SELECT 1; COMMIT; DROP SCHEMA public CASCADE;` closed the read-only transaction early and ran the drop as an ordinary write. The package was deprecated over it. ([Datadog Security Labs writeup](https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/))
|
|
8
|
+
|
|
9
|
+
pg-guard-mcp exists because that bug class — "read-only" enforced only by string inspection — is still common across the MCP ecosystem. It defends in three independent layers, so no single mistake is fatal:
|
|
10
|
+
|
|
11
|
+
1. **Protocol layer (the real boundary).** Every query runs through Postgres's *extended* query protocol (`Parse`/`Bind`/`Execute`), never the simple query protocol. The extended protocol structurally rejects more than one statement per `Parse` message — Postgres itself refuses it, before any of our code runs. This is why the Datadog exploit cannot work here regardless of what string is submitted.
|
|
12
|
+
2. **Session layer.** Every connection sets `default_transaction_read_only = on` at the session level, so even a query that somehow reached the database as a write is rejected by Postgres.
|
|
13
|
+
3. **Pre-flight layer.** Before a query is even sent, it's checked for multiple statements and transaction-control keywords (`COMMIT`, `ROLLBACK`, `BEGIN`, `SAVEPOINT`, ...) and rejected with a clear error. This exists to fail fast and loud, not as the primary defense.
|
|
14
|
+
|
|
15
|
+
On top of that, connecting with a database role that has had write privileges `REVOKE`d is the recommended (and startup-checked) setup — belt and suspenders at the privilege layer too.
|
|
16
|
+
|
|
17
|
+
## Tools
|
|
18
|
+
|
|
19
|
+
| Tool | Does |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `pg_run_query(sql)` | Run one read-only statement, return rows |
|
|
22
|
+
| `pg_explain_query(sql)` | Return the query plan without running it |
|
|
23
|
+
| `pg_list_tables(schema="public")` | List tables/views in a schema |
|
|
24
|
+
| `pg_describe_table(table_name, schema="public")` | List a table's columns |
|
|
25
|
+
| `pg_check_privileges()` | Report any write grant the connected role actually holds — should always come back empty |
|
|
26
|
+
|
|
27
|
+
## Setup
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install -e ".[dev]"
|
|
31
|
+
export PG_GUARD_DSN="host=127.0.0.1 dbname=mydb user=myapp_readonly password=..."
|
|
32
|
+
python -m pg_guard_mcp.server
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
See `.env.example` for all supported environment variables, and `scripts/setup_dev_db.sh` for a working example of setting up a properly-restricted read-only role (the setup this project's own tests run against).
|
|
36
|
+
|
|
37
|
+
## Testing
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install -e ".[dev]"
|
|
41
|
+
pytest tests/ -v
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`tests/test_safety.py` is pure-Python and needs no database. `tests/test_db.py` and `tests/test_server.py` run against a real local PostgreSQL instance — including the exact exploit payload that deprecated the official Postgres MCP server — and skip automatically if `pgguard_test` isn't reachable. Run `scripts/setup_dev_db.sh` once to create it.
|
|
45
|
+
|
|
46
|
+
## Status
|
|
47
|
+
|
|
48
|
+
Early build, 58 passing tests (unit + live-Postgres integration). Not yet published to PyPI.
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pg-guard-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A PostgreSQL MCP server that enforces read-only access at the protocol and privilege level — not by parsing the query string."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Berkant Acun" }]
|
|
9
|
+
keywords = ["mcp", "model-context-protocol", "postgresql", "postgres", "read-only", "security"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcp>=1.2.0",
|
|
12
|
+
"psycopg[binary]>=3.2.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
pg-guard-mcp = "pg_guard_mcp.server:main"
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0.0",
|
|
21
|
+
"pytest-asyncio>=0.24.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/pg_guard_mcp"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Sets up a local pgguard_test database and a genuinely-restricted
|
|
3
|
+
# pgguard_readonly role for running the integration test suite.
|
|
4
|
+
# Requires a local PostgreSQL superuser connection (adjust -U/-h as needed).
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
PSQL="${PSQL:-psql}"
|
|
8
|
+
SUPERUSER="${PGGUARD_SETUP_SUPERUSER:-postgres}"
|
|
9
|
+
HOST="${PGGUARD_SETUP_HOST:-127.0.0.1}"
|
|
10
|
+
|
|
11
|
+
"$PSQL" -h "$HOST" -U "$SUPERUSER" -v ON_ERROR_STOP=1 <<SQL
|
|
12
|
+
DROP DATABASE IF EXISTS pgguard_test;
|
|
13
|
+
CREATE DATABASE pgguard_test;
|
|
14
|
+
DROP ROLE IF EXISTS pgguard_readonly;
|
|
15
|
+
CREATE ROLE pgguard_readonly WITH LOGIN PASSWORD 'pgguard_readonly_dev_pw';
|
|
16
|
+
ALTER ROLE pgguard_readonly SET default_transaction_read_only = on;
|
|
17
|
+
SQL
|
|
18
|
+
|
|
19
|
+
"$PSQL" -h "$HOST" -U "$SUPERUSER" -d pgguard_test -v ON_ERROR_STOP=1 <<SQL
|
|
20
|
+
CREATE TABLE users (id serial PRIMARY KEY, email text NOT NULL);
|
|
21
|
+
INSERT INTO users (email) VALUES ('a@example.com'), ('b@example.com');
|
|
22
|
+
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM pgguard_readonly;
|
|
23
|
+
REVOKE ALL ON SCHEMA public FROM pgguard_readonly;
|
|
24
|
+
GRANT CONNECT ON DATABASE pgguard_test TO pgguard_readonly;
|
|
25
|
+
GRANT USAGE ON SCHEMA public TO pgguard_readonly;
|
|
26
|
+
GRANT SELECT ON ALL TABLES IN SCHEMA public TO pgguard_readonly;
|
|
27
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO pgguard_readonly;
|
|
28
|
+
SQL
|
|
29
|
+
|
|
30
|
+
echo "pgguard_test database and pgguard_readonly role are ready."
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The real security boundary.
|
|
3
|
+
|
|
4
|
+
Every query is executed through psycopg's *extended* query protocol
|
|
5
|
+
(Parse/Bind/Execute), never the simple query protocol. Postgres's extended
|
|
6
|
+
protocol structurally refuses to run more than one statement inside a
|
|
7
|
+
single Parse message — that is a server-side wire-protocol rule, enforced
|
|
8
|
+
by Postgres itself, not a client-side check that a cleverly-crafted string
|
|
9
|
+
could talk its way around.
|
|
10
|
+
|
|
11
|
+
psycopg only falls back to the simple protocol when `execute()` is called
|
|
12
|
+
with `params=None`. `ReadOnlyConnection.query()` always passes params
|
|
13
|
+
(defaulting to an empty tuple), which forces the extended-protocol path
|
|
14
|
+
even for queries that bind no parameters at all. This is the mechanism
|
|
15
|
+
that makes the Datadog-documented exploit against the official
|
|
16
|
+
`@modelcontextprotocol/server-postgres` — smuggling `COMMIT;` plus a write
|
|
17
|
+
past a `BEGIN TRANSACTION READ ONLY` wrapper — structurally impossible
|
|
18
|
+
here: Postgres rejects the multi-statement string before any of it runs.
|
|
19
|
+
|
|
20
|
+
This is layered with two more independent defenses, so no single mistake
|
|
21
|
+
in this file is fatal:
|
|
22
|
+
- session layer: `default_transaction_read_only = on` is set right after
|
|
23
|
+
connecting, so even a write that somehow reached Postgres is refused;
|
|
24
|
+
- pre-flight layer: `validate_readonly_query()` (safety.py) rejects an
|
|
25
|
+
obviously dangerous query before it's sent at all, with a clear error.
|
|
26
|
+
|
|
27
|
+
The privilege layer — connecting as a role with write grants revoked — is
|
|
28
|
+
enforced by Postgres itself and is the caller's responsibility to set up;
|
|
29
|
+
see README.md.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from types import TracebackType
|
|
35
|
+
|
|
36
|
+
import psycopg
|
|
37
|
+
from psycopg.rows import dict_row
|
|
38
|
+
|
|
39
|
+
from .safety import validate_readonly_query
|
|
40
|
+
|
|
41
|
+
DEFAULT_STATEMENT_TIMEOUT_MS = 10_000
|
|
42
|
+
DEFAULT_ROW_LIMIT = 1_000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReadOnlyConnection:
|
|
46
|
+
"""A single-use, read-only-enforced Postgres connection. Use as a
|
|
47
|
+
context manager: `with ReadOnlyConnection(dsn) as conn: conn.query(...)`."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
dsn: str,
|
|
52
|
+
*,
|
|
53
|
+
statement_timeout_ms: int = DEFAULT_STATEMENT_TIMEOUT_MS,
|
|
54
|
+
row_limit: int = DEFAULT_ROW_LIMIT,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._dsn = dsn
|
|
57
|
+
self._statement_timeout_ms = statement_timeout_ms
|
|
58
|
+
self._row_limit = row_limit
|
|
59
|
+
self._conn: psycopg.Connection | None = None
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> "ReadOnlyConnection":
|
|
62
|
+
self._conn = psycopg.connect(self._dsn, row_factory=dict_row, autocommit=True)
|
|
63
|
+
with self._conn.cursor() as cur:
|
|
64
|
+
# Session layer: belt-and-suspenders even if a write somehow
|
|
65
|
+
# reached Postgres despite the protocol and pre-flight layers.
|
|
66
|
+
cur.execute("SET default_transaction_read_only = on", ())
|
|
67
|
+
# SET does not accept a bind parameter for this value (it's a
|
|
68
|
+
# utility statement, not a regular query) — coerce through
|
|
69
|
+
# int() first so there's nothing to inject even though this
|
|
70
|
+
# particular string is built with an f-string.
|
|
71
|
+
cur.execute(f"SET statement_timeout = {int(self._statement_timeout_ms)}", ())
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(
|
|
75
|
+
self,
|
|
76
|
+
exc_type: type[BaseException] | None,
|
|
77
|
+
exc: BaseException | None,
|
|
78
|
+
tb: TracebackType | None,
|
|
79
|
+
) -> None:
|
|
80
|
+
if self._conn is not None:
|
|
81
|
+
self._conn.close()
|
|
82
|
+
self._conn = None
|
|
83
|
+
|
|
84
|
+
def query(self, sql: str, params: tuple | list | None = None) -> list[dict]:
|
|
85
|
+
if self._conn is None:
|
|
86
|
+
raise RuntimeError("ReadOnlyConnection must be used as a context manager")
|
|
87
|
+
|
|
88
|
+
# Pre-flight layer: reject obviously dangerous input with a clear,
|
|
89
|
+
# specific error before it ever reaches the network.
|
|
90
|
+
validate_readonly_query(sql)
|
|
91
|
+
|
|
92
|
+
with self._conn.cursor() as cur:
|
|
93
|
+
# Protocol layer — the real boundary. Passing params, even an
|
|
94
|
+
# empty tuple, forces psycopg's extended query protocol, which
|
|
95
|
+
# Postgres refuses to run more than one statement through.
|
|
96
|
+
cur.execute(sql, params if params is not None else ())
|
|
97
|
+
return cur.fetchmany(self._row_limit)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pre-flight read-only query validation.
|
|
3
|
+
|
|
4
|
+
IMPORTANT: this module is the *second* line of defense, not the first. The
|
|
5
|
+
real boundary is that db.py always executes queries through Postgres's
|
|
6
|
+
extended query protocol, which cannot run more than one statement per call
|
|
7
|
+
no matter what string is submitted — see db.py's module docstring. This
|
|
8
|
+
module exists to reject an obviously dangerous query early, with a clear
|
|
9
|
+
error, instead of relying on protocol behaviour alone.
|
|
10
|
+
|
|
11
|
+
It works by masking out everything that cannot affect statement structure —
|
|
12
|
+
string literals, quoted identifiers, dollar-quoted bodies, and comments —
|
|
13
|
+
before looking for statement-separating semicolons or dangerous keywords.
|
|
14
|
+
This is what lets `WHERE message = 'a; DROP TABLE users;'` pass (the
|
|
15
|
+
semicolons are just data) while `SELECT 1; DROP TABLE users;` is rejected
|
|
16
|
+
(that semicolon is a real statement boundary).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
__all__ = ["UnsafeQueryError", "validate_readonly_query"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnsafeQueryError(ValueError):
|
|
27
|
+
"""Raised when a query is not a single, plain read-only statement."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# A statement is only accepted if it opens with one of these. This alone
|
|
31
|
+
# rejects every bare write/DDL/admin statement (INSERT, DROP, GRANT, ...)
|
|
32
|
+
# without needing to name each one individually.
|
|
33
|
+
_ALLOWED_START_KEYWORDS = {"SELECT", "WITH", "EXPLAIN", "SHOW"}
|
|
34
|
+
|
|
35
|
+
# Extra defense for dangerous keywords that can appear *inside* an
|
|
36
|
+
# otherwise SELECT/WITH-shaped statement: transaction control smuggled
|
|
37
|
+
# past the outer wrapper, data-modifying CTEs (`WITH d AS (DELETE ...)`),
|
|
38
|
+
# and admin functions callable from a plain SELECT list.
|
|
39
|
+
_DANGEROUS_KEYWORDS = [
|
|
40
|
+
"COMMIT", "ROLLBACK", "BEGIN", "SAVEPOINT", "RELEASE",
|
|
41
|
+
"SET", "INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE",
|
|
42
|
+
"ALTER", "CREATE", "GRANT", "REVOKE", "VACUUM", "CALL",
|
|
43
|
+
"COPY", "MERGE", "REINDEX", "CLUSTER", "LOCK", "DO",
|
|
44
|
+
"EXECUTE", "PREPARE", "DEALLOCATE", "LISTEN", "NOTIFY",
|
|
45
|
+
"PG_TERMINATE_BACKEND", "PG_CANCEL_BACKEND", "PG_RELOAD_CONF",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
_DOLLAR_TAG_RE = re.compile(r"\$[A-Za-z_]*\$")
|
|
49
|
+
_LEADING_WORD_RE = re.compile(r"\s*([A-Za-z_][A-Za-z_0-9]*)")
|
|
50
|
+
_KEYWORD_RE_CACHE = {
|
|
51
|
+
kw: re.compile(rf"(?<![A-Za-z0-9_]){re.escape(kw)}(?![A-Za-z0-9_])", re.IGNORECASE)
|
|
52
|
+
for kw in _DANGEROUS_KEYWORDS
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _mask(sql: str) -> str:
|
|
57
|
+
"""Return a string the same length as `sql` with the contents of every
|
|
58
|
+
string literal, quoted identifier, dollar-quoted body, and comment
|
|
59
|
+
replaced by spaces. Everything else is left untouched."""
|
|
60
|
+
out: list[str] = []
|
|
61
|
+
i = 0
|
|
62
|
+
n = len(sql)
|
|
63
|
+
while i < n:
|
|
64
|
+
ch = sql[i]
|
|
65
|
+
|
|
66
|
+
if ch == "'":
|
|
67
|
+
# An E'...' / e'...' string uses backslash escapes (\' , \\, ...)
|
|
68
|
+
# in addition to the standard '' doubling. A plain '...' string
|
|
69
|
+
# does not treat backslash specially at all under
|
|
70
|
+
# standard_conforming_strings=on, which has been Postgres's
|
|
71
|
+
# default since 9.1 — so backslash is ignored for those, and a
|
|
72
|
+
# lone "'" always closes them, exactly like Postgres parses it.
|
|
73
|
+
prev = sql[i - 1] if i > 0 else ""
|
|
74
|
+
prev_prev = sql[i - 2] if i > 1 else ""
|
|
75
|
+
is_e_string = prev in ("E", "e") and not (prev_prev.isalnum() or prev_prev == "_")
|
|
76
|
+
|
|
77
|
+
out.append(" ")
|
|
78
|
+
i += 1
|
|
79
|
+
while i < n:
|
|
80
|
+
if is_e_string and sql[i] == "\\" and i + 1 < n:
|
|
81
|
+
out.append(" ")
|
|
82
|
+
i += 2
|
|
83
|
+
continue
|
|
84
|
+
if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
|
|
85
|
+
out.append(" ")
|
|
86
|
+
i += 2
|
|
87
|
+
continue
|
|
88
|
+
is_closing = sql[i] == "'"
|
|
89
|
+
out.append(" ")
|
|
90
|
+
i += 1
|
|
91
|
+
if is_closing:
|
|
92
|
+
break
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
if ch == '"':
|
|
96
|
+
out.append(" ")
|
|
97
|
+
i += 1
|
|
98
|
+
while i < n and sql[i] != '"':
|
|
99
|
+
out.append(" ")
|
|
100
|
+
i += 1
|
|
101
|
+
if i < n:
|
|
102
|
+
out.append(" ")
|
|
103
|
+
i += 1
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if sql[i:i + 2] == "--":
|
|
107
|
+
while i < n and sql[i] != "\n":
|
|
108
|
+
out.append(" ")
|
|
109
|
+
i += 1
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if sql[i:i + 2] == "/*":
|
|
113
|
+
out.append(" ")
|
|
114
|
+
i += 2
|
|
115
|
+
while i < n and sql[i:i + 2] != "*/":
|
|
116
|
+
out.append(" ")
|
|
117
|
+
i += 1
|
|
118
|
+
if i < n:
|
|
119
|
+
out.append(" ")
|
|
120
|
+
i += 2
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if ch == "$":
|
|
124
|
+
tag_match = _DOLLAR_TAG_RE.match(sql, i)
|
|
125
|
+
if tag_match:
|
|
126
|
+
tag = tag_match.group(0)
|
|
127
|
+
out.append(" " * len(tag))
|
|
128
|
+
i += len(tag)
|
|
129
|
+
end = sql.find(tag, i)
|
|
130
|
+
if end == -1:
|
|
131
|
+
out.append(" " * (n - i))
|
|
132
|
+
i = n
|
|
133
|
+
else:
|
|
134
|
+
out.append(" " * (end - i))
|
|
135
|
+
out.append(" " * len(tag))
|
|
136
|
+
i = end + len(tag)
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
out.append(ch)
|
|
140
|
+
i += 1
|
|
141
|
+
|
|
142
|
+
return "".join(out)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _split_statements(sql: str, masked: str) -> list[tuple[str, str]]:
|
|
146
|
+
"""Split on semicolons that survived masking (i.e. real statement
|
|
147
|
+
separators), returning (original_text, masked_text) pairs so keyword
|
|
148
|
+
checks can run on the masked text while errors can quote the original."""
|
|
149
|
+
pairs: list[tuple[str, str]] = []
|
|
150
|
+
start = 0
|
|
151
|
+
for i, ch in enumerate(masked):
|
|
152
|
+
if ch == ";":
|
|
153
|
+
pairs.append((sql[start:i], masked[start:i]))
|
|
154
|
+
start = i + 1
|
|
155
|
+
pairs.append((sql[start:], masked[start:]))
|
|
156
|
+
return pairs
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def validate_readonly_query(sql: str) -> None:
|
|
160
|
+
"""Raise UnsafeQueryError unless `sql` is exactly one plain read-only
|
|
161
|
+
statement (SELECT / WITH / EXPLAIN / SHOW) with no transaction-control
|
|
162
|
+
or write/DDL/admin keywords anywhere in it. Returns None if it's fine."""
|
|
163
|
+
if not sql or not sql.strip():
|
|
164
|
+
raise UnsafeQueryError("Empty query.")
|
|
165
|
+
|
|
166
|
+
masked = _mask(sql)
|
|
167
|
+
statements = [
|
|
168
|
+
(orig, masked_stmt)
|
|
169
|
+
for orig, masked_stmt in _split_statements(sql, masked)
|
|
170
|
+
if masked_stmt.strip()
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
if not statements:
|
|
174
|
+
raise UnsafeQueryError("Empty query.")
|
|
175
|
+
|
|
176
|
+
if len(statements) > 1:
|
|
177
|
+
raise UnsafeQueryError(
|
|
178
|
+
f"Multiple statements are not allowed (found {len(statements)}). "
|
|
179
|
+
"Submit exactly one read-only statement per call."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
original, masked_stmt = statements[0]
|
|
183
|
+
|
|
184
|
+
leading = _LEADING_WORD_RE.match(masked_stmt)
|
|
185
|
+
first_word = leading.group(1).upper() if leading else ""
|
|
186
|
+
if first_word not in _ALLOWED_START_KEYWORDS:
|
|
187
|
+
raise UnsafeQueryError(
|
|
188
|
+
f"Statement must start with SELECT, WITH, EXPLAIN, or SHOW — "
|
|
189
|
+
f"found '{first_word or original.strip()[:30]}'."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
for keyword, pattern in _KEYWORD_RE_CACHE.items():
|
|
193
|
+
if pattern.search(masked_stmt):
|
|
194
|
+
raise UnsafeQueryError(
|
|
195
|
+
f"Query contains a disallowed keyword: {keyword}. "
|
|
196
|
+
"Only plain read-only SELECT/WITH/EXPLAIN/SHOW statements are permitted."
|
|
197
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP tool surface for pg-guard-mcp.
|
|
3
|
+
|
|
4
|
+
Every tool here goes through ReadOnlyConnection (db.py), so every query —
|
|
5
|
+
no matter which tool calls it — passes through the same three-layer
|
|
6
|
+
defense (protocol / session / pre-flight) described there. There is
|
|
7
|
+
deliberately no "run arbitrary write SQL" escape hatch and no shell-out to
|
|
8
|
+
the `psql` binary: the only way this server talks to Postgres is through
|
|
9
|
+
psycopg's extended query protocol.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
# SDK >= 2.0: FastMCP was renamed to MCPServer, same .tool()/.run() API.
|
|
19
|
+
from mcp.server import MCPServer as _MCPServerImpl
|
|
20
|
+
except ImportError: # SDK < 2.0
|
|
21
|
+
from mcp.server.fastmcp import FastMCP as _MCPServerImpl # type: ignore[no-redef]
|
|
22
|
+
|
|
23
|
+
from .db import DEFAULT_ROW_LIMIT, DEFAULT_STATEMENT_TIMEOUT_MS, ReadOnlyConnection
|
|
24
|
+
from .safety import UnsafeQueryError, validate_readonly_query
|
|
25
|
+
|
|
26
|
+
mcp = _MCPServerImpl("pg-guard-mcp")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _dsn() -> str:
|
|
30
|
+
dsn = os.environ.get("PG_GUARD_DSN")
|
|
31
|
+
if dsn:
|
|
32
|
+
return dsn
|
|
33
|
+
# Fall back to standard libpq environment variables (PGHOST, PGPORT,
|
|
34
|
+
# PGDATABASE, PGUSER, PGPASSWORD, ...) — psycopg reads these itself
|
|
35
|
+
# when given an empty DSN, so this just documents the expectation.
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _row_limit() -> int:
|
|
40
|
+
raw = os.environ.get("PG_GUARD_ROW_LIMIT")
|
|
41
|
+
return int(raw) if raw else DEFAULT_ROW_LIMIT
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _statement_timeout_ms() -> int:
|
|
45
|
+
raw = os.environ.get("PG_GUARD_STATEMENT_TIMEOUT_MS")
|
|
46
|
+
return int(raw) if raw else DEFAULT_STATEMENT_TIMEOUT_MS
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _connect() -> ReadOnlyConnection:
|
|
50
|
+
return ReadOnlyConnection(
|
|
51
|
+
_dsn(),
|
|
52
|
+
statement_timeout_ms=_statement_timeout_ms(),
|
|
53
|
+
row_limit=_row_limit(),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run(sql: str, params: tuple | list | None = None) -> dict:
|
|
58
|
+
try:
|
|
59
|
+
with _connect() as conn:
|
|
60
|
+
rows = conn.query(sql, params)
|
|
61
|
+
return {"rows": rows, "row_count": len(rows)}
|
|
62
|
+
except UnsafeQueryError as e:
|
|
63
|
+
return {"error": "UnsafeQuery", "message": str(e)}
|
|
64
|
+
except Exception as e: # psycopg errors, connection failures, etc.
|
|
65
|
+
return {"error": type(e).__name__, "message": str(e)}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@mcp.tool()
|
|
69
|
+
def pg_run_query(sql: str) -> dict:
|
|
70
|
+
"""Run a single read-only SQL statement (SELECT / WITH / EXPLAIN / SHOW)
|
|
71
|
+
against the configured PostgreSQL database and return the rows.
|
|
72
|
+
|
|
73
|
+
Rejects anything that isn't exactly one plain read-only statement —
|
|
74
|
+
multiple statements, transaction-control keywords (COMMIT, ROLLBACK,
|
|
75
|
+
BEGIN, ...), and any write/DDL/admin keyword anywhere in the query are
|
|
76
|
+
all refused before the query is sent to the database. Results are
|
|
77
|
+
capped at PG_GUARD_ROW_LIMIT rows (default 1000).
|
|
78
|
+
"""
|
|
79
|
+
return _run(sql)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@mcp.tool()
|
|
83
|
+
def pg_explain_query(sql: str) -> dict:
|
|
84
|
+
"""Return the PostgreSQL query plan for a read-only SELECT/WITH
|
|
85
|
+
statement, without running it. Useful for checking whether a query
|
|
86
|
+
will be slow before running it for real."""
|
|
87
|
+
# Validate the user's actual input *before* wrapping it in EXPLAIN.
|
|
88
|
+
# This is deliberate: wrapping first and validating the composed
|
|
89
|
+
# string would still catch anything on the dangerous-keyword
|
|
90
|
+
# blocklist, but it makes the error about what the caller typed, and
|
|
91
|
+
# it means a change to that composition logic can never quietly
|
|
92
|
+
# start validating something other than the real input.
|
|
93
|
+
try:
|
|
94
|
+
validate_readonly_query(sql)
|
|
95
|
+
except UnsafeQueryError as e:
|
|
96
|
+
return {"error": "UnsafeQuery", "message": str(e)}
|
|
97
|
+
|
|
98
|
+
stripped = sql.strip().rstrip(";")
|
|
99
|
+
return _run(f"EXPLAIN {stripped}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@mcp.tool()
|
|
103
|
+
def pg_list_tables(schema: str = "public") -> dict:
|
|
104
|
+
"""List the tables and views visible to the connected role in the
|
|
105
|
+
given schema (default: public)."""
|
|
106
|
+
return _run(
|
|
107
|
+
"SELECT table_name, table_type FROM information_schema.tables "
|
|
108
|
+
"WHERE table_schema = %s ORDER BY table_name",
|
|
109
|
+
(schema,),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@mcp.tool()
|
|
114
|
+
def pg_check_privileges() -> dict:
|
|
115
|
+
"""Report any write privilege (INSERT/UPDATE/DELETE/TRUNCATE) the
|
|
116
|
+
connected role actually holds on any table. An empty list is the
|
|
117
|
+
expected, safe result — anything else means the privilege layer of
|
|
118
|
+
defense is missing and the role should be locked down (see README.md),
|
|
119
|
+
even though the protocol and session layers still hold on their own."""
|
|
120
|
+
return _run(
|
|
121
|
+
"SELECT table_schema, table_name, privilege_type "
|
|
122
|
+
"FROM information_schema.role_table_grants "
|
|
123
|
+
"WHERE grantee = current_user "
|
|
124
|
+
"AND privilege_type IN ('INSERT', 'UPDATE', 'DELETE', 'TRUNCATE') "
|
|
125
|
+
"ORDER BY table_schema, table_name, privilege_type"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@mcp.tool()
|
|
130
|
+
def pg_describe_table(table_name: str, schema: str = "public") -> dict:
|
|
131
|
+
"""List the columns of a table: name, data type, nullability, and
|
|
132
|
+
default, in column order."""
|
|
133
|
+
return _run(
|
|
134
|
+
"SELECT column_name, data_type, is_nullable, column_default "
|
|
135
|
+
"FROM information_schema.columns "
|
|
136
|
+
"WHERE table_schema = %s AND table_name = %s "
|
|
137
|
+
"ORDER BY ordinal_position",
|
|
138
|
+
(schema, table_name),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _warn_if_role_can_write() -> None:
|
|
143
|
+
"""Best-effort startup check: if the connected role holds any write
|
|
144
|
+
privilege, say so loudly. Never fails startup — this is advisory."""
|
|
145
|
+
try:
|
|
146
|
+
result = pg_check_privileges()
|
|
147
|
+
except Exception:
|
|
148
|
+
return
|
|
149
|
+
grants = result.get("rows") or []
|
|
150
|
+
if grants:
|
|
151
|
+
print(
|
|
152
|
+
f"pg-guard-mcp: WARNING — the connected role has {len(grants)} write "
|
|
153
|
+
"grant(s) (see pg_check_privileges). The protocol and session layers "
|
|
154
|
+
"still block writes, but a role with write access revoked is the "
|
|
155
|
+
"recommended setup. See README.md.",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main() -> None:
|
|
161
|
+
if not os.environ.get("PG_GUARD_DSN") and not os.environ.get("PGDATABASE"):
|
|
162
|
+
print(
|
|
163
|
+
"pg-guard-mcp: no PG_GUARD_DSN or PGDATABASE set — connections will fail "
|
|
164
|
+
"until one is configured. See README.md for setup.",
|
|
165
|
+
file=sys.stderr,
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
_warn_if_role_can_write()
|
|
169
|
+
mcp.run()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
main()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Integration tests against a real local PostgreSQL instance.
|
|
3
|
+
|
|
4
|
+
These prove the actual security boundary: that pg-guard-mcp's connection
|
|
5
|
+
layer executes every query through the Postgres *extended* query protocol,
|
|
6
|
+
which structurally refuses to run more than one statement per call — the
|
|
7
|
+
exact mechanism the official @modelcontextprotocol/server-postgres lacked,
|
|
8
|
+
which is what let a single `COMMIT;` bypass its read-only wrapper.
|
|
9
|
+
|
|
10
|
+
Skipped automatically if PG_GUARD_TEST_DSN is not set, so the fast unit
|
|
11
|
+
suite (test_safety.py) still runs everywhere with no external dependency.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
import psycopg
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from pg_guard_mcp.db import ReadOnlyConnection
|
|
20
|
+
|
|
21
|
+
TEST_DSN = os.environ.get(
|
|
22
|
+
"PG_GUARD_TEST_DSN",
|
|
23
|
+
"host=127.0.0.1 dbname=pgguard_test user=pgguard_readonly password=pgguard_readonly_dev_pw",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _connectable() -> bool:
|
|
28
|
+
try:
|
|
29
|
+
with psycopg.connect(TEST_DSN, connect_timeout=3):
|
|
30
|
+
return True
|
|
31
|
+
except Exception:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
pytestmark = pytest.mark.skipif(
|
|
36
|
+
not _connectable(),
|
|
37
|
+
reason="no local pgguard_test database reachable — set PG_GUARD_TEST_DSN or run the dev setup script",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestOrdinaryReadsWork:
|
|
42
|
+
def test_select_returns_rows(self):
|
|
43
|
+
with ReadOnlyConnection(TEST_DSN) as conn:
|
|
44
|
+
rows = conn.query("SELECT id, email FROM users ORDER BY id")
|
|
45
|
+
assert len(rows) == 2
|
|
46
|
+
assert rows[0]["email"] == "a@example.com"
|
|
47
|
+
|
|
48
|
+
def test_parameterized_select_works(self):
|
|
49
|
+
with ReadOnlyConnection(TEST_DSN) as conn:
|
|
50
|
+
rows = conn.query("SELECT email FROM users WHERE id = %s", (1,))
|
|
51
|
+
assert rows == [{"email": "a@example.com"}]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestTheDatadogExploitFailsAgainstARealServer:
|
|
55
|
+
"""The exact payload that deprecated the official Postgres MCP server,
|
|
56
|
+
run against a real connection, to prove the defense is structural and
|
|
57
|
+
not just a string check that could itself have a bypass."""
|
|
58
|
+
|
|
59
|
+
def test_commit_bypass_drop_table_is_refused_by_postgres_itself(self):
|
|
60
|
+
with ReadOnlyConnection(TEST_DSN) as conn:
|
|
61
|
+
with pytest.raises(Exception):
|
|
62
|
+
conn.query("SELECT 1; COMMIT; DROP TABLE users;")
|
|
63
|
+
|
|
64
|
+
# Prove the table really does still exist and still has its rows —
|
|
65
|
+
# not just that *an* exception was raised.
|
|
66
|
+
with ReadOnlyConnection(TEST_DSN) as conn:
|
|
67
|
+
rows = conn.query("SELECT count(*) AS n FROM users")
|
|
68
|
+
assert rows[0]["n"] == 2
|
|
69
|
+
|
|
70
|
+
def test_multi_statement_select_select_is_refused(self):
|
|
71
|
+
with ReadOnlyConnection(TEST_DSN) as conn:
|
|
72
|
+
with pytest.raises(Exception):
|
|
73
|
+
conn.query("SELECT 1; SELECT 2;")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestWritesAreRefusedEvenIfSomehowSubmittedAlone:
|
|
77
|
+
"""Belt-and-suspenders: even if the pre-flight keyword check in
|
|
78
|
+
safety.py were somehow bypassed, the role itself has no write grant
|
|
79
|
+
and the session is read-only, so Postgres refuses the write."""
|
|
80
|
+
|
|
81
|
+
def test_insert_is_refused_by_the_database(self):
|
|
82
|
+
with ReadOnlyConnection(TEST_DSN) as conn:
|
|
83
|
+
with pytest.raises(Exception):
|
|
84
|
+
conn.query("INSERT INTO users (email) VALUES ('hacker@example.com')")
|
|
85
|
+
|
|
86
|
+
with ReadOnlyConnection(TEST_DSN) as conn:
|
|
87
|
+
rows = conn.query("SELECT count(*) AS n FROM users")
|
|
88
|
+
assert rows[0]["n"] == 2
|
|
89
|
+
|
|
90
|
+
def test_delete_is_refused_by_the_database(self):
|
|
91
|
+
with ReadOnlyConnection(TEST_DSN) as conn:
|
|
92
|
+
with pytest.raises(Exception):
|
|
93
|
+
conn.query("DELETE FROM users")
|
|
94
|
+
|
|
95
|
+
with ReadOnlyConnection(TEST_DSN) as conn:
|
|
96
|
+
rows = conn.query("SELECT count(*) AS n FROM users")
|
|
97
|
+
assert rows[0]["n"] == 2
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestConnectionRefusesUnsafeQueriesBeforeSendingThem:
|
|
101
|
+
def test_dangerous_query_never_reaches_the_network(self):
|
|
102
|
+
# safety.py should reject this before ReadOnlyConnection even opens
|
|
103
|
+
# a cursor — confirmed indirectly: it still raises, and the data is
|
|
104
|
+
# untouched, whether or not a network round-trip happened.
|
|
105
|
+
with ReadOnlyConnection(TEST_DSN) as conn:
|
|
106
|
+
with pytest.raises(Exception):
|
|
107
|
+
conn.query("DROP TABLE users")
|
|
108
|
+
with ReadOnlyConnection(TEST_DSN) as conn:
|
|
109
|
+
rows = conn.query("SELECT count(*) AS n FROM users")
|
|
110
|
+
assert rows[0]["n"] == 2
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the pre-execution query safety check.
|
|
3
|
+
|
|
4
|
+
This is deliberately layer 2 of the defense, not layer 1. Layer 1 (the real
|
|
5
|
+
boundary) is that every query is executed through the Postgres *extended*
|
|
6
|
+
query protocol, which structurally cannot run more than one statement per
|
|
7
|
+
call — see db.py and test_db.py for that proof. This module exists so a
|
|
8
|
+
dangerous query is rejected with a clear error *before* it ever reaches the
|
|
9
|
+
network, and so an obviously-malicious payload never depends on protocol
|
|
10
|
+
subtlety alone to be stopped.
|
|
11
|
+
|
|
12
|
+
The exact exploit these tests are written against is the one that got the
|
|
13
|
+
official @modelcontextprotocol/server-postgres deprecated: wrap the query in
|
|
14
|
+
`BEGIN TRANSACTION READ ONLY`, then let the attacker's string smuggle a
|
|
15
|
+
`COMMIT;` followed by a write statement, closing the read-only transaction
|
|
16
|
+
early. See: https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from pg_guard_mcp.safety import UnsafeQueryError, validate_readonly_query
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestObviouslySafeQueries:
|
|
25
|
+
def test_simple_select_is_allowed(self):
|
|
26
|
+
validate_readonly_query("SELECT * FROM users")
|
|
27
|
+
|
|
28
|
+
def test_select_with_where_clause_is_allowed(self):
|
|
29
|
+
validate_readonly_query("SELECT id, email FROM users WHERE active = true")
|
|
30
|
+
|
|
31
|
+
def test_select_with_join_is_allowed(self):
|
|
32
|
+
validate_readonly_query(
|
|
33
|
+
"SELECT u.id, o.total FROM users u JOIN orders o ON o.user_id = u.id"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def test_select_with_trailing_semicolon_is_allowed(self):
|
|
37
|
+
validate_readonly_query("SELECT * FROM users;")
|
|
38
|
+
|
|
39
|
+
def test_select_with_trailing_semicolon_and_whitespace_is_allowed(self):
|
|
40
|
+
validate_readonly_query("SELECT * FROM users; \n")
|
|
41
|
+
|
|
42
|
+
def test_cte_select_is_allowed(self):
|
|
43
|
+
validate_readonly_query(
|
|
44
|
+
"WITH recent AS (SELECT * FROM orders WHERE created_at > now() - interval '1 day') "
|
|
45
|
+
"SELECT * FROM recent"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def test_explain_select_is_allowed(self):
|
|
49
|
+
validate_readonly_query("EXPLAIN SELECT * FROM users")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestTheDatadogExploit:
|
|
53
|
+
"""The exact real-world attack that deprecated the official Postgres MCP server."""
|
|
54
|
+
|
|
55
|
+
def test_commit_bypass_is_rejected(self):
|
|
56
|
+
with pytest.raises(UnsafeQueryError):
|
|
57
|
+
validate_readonly_query("SELECT 1; COMMIT; DROP TABLE users;")
|
|
58
|
+
|
|
59
|
+
def test_commit_bypass_with_schema_drop_is_rejected(self):
|
|
60
|
+
with pytest.raises(UnsafeQueryError):
|
|
61
|
+
validate_readonly_query("SELECT 1; COMMIT; DROP SCHEMA public CASCADE;")
|
|
62
|
+
|
|
63
|
+
def test_commit_alone_is_rejected(self):
|
|
64
|
+
with pytest.raises(UnsafeQueryError):
|
|
65
|
+
validate_readonly_query("COMMIT;")
|
|
66
|
+
|
|
67
|
+
def test_rollback_alone_is_rejected(self):
|
|
68
|
+
with pytest.raises(UnsafeQueryError):
|
|
69
|
+
validate_readonly_query("ROLLBACK;")
|
|
70
|
+
|
|
71
|
+
def test_begin_is_rejected(self):
|
|
72
|
+
with pytest.raises(UnsafeQueryError):
|
|
73
|
+
validate_readonly_query("BEGIN; SELECT * FROM users;")
|
|
74
|
+
|
|
75
|
+
def test_savepoint_is_rejected(self):
|
|
76
|
+
with pytest.raises(UnsafeQueryError):
|
|
77
|
+
validate_readonly_query("SAVEPOINT sp1; SELECT * FROM users;")
|
|
78
|
+
|
|
79
|
+
def test_release_savepoint_is_rejected(self):
|
|
80
|
+
with pytest.raises(UnsafeQueryError):
|
|
81
|
+
validate_readonly_query("RELEASE SAVEPOINT sp1;")
|
|
82
|
+
|
|
83
|
+
def test_set_transaction_is_rejected(self):
|
|
84
|
+
with pytest.raises(UnsafeQueryError):
|
|
85
|
+
validate_readonly_query("SET TRANSACTION READ WRITE; SELECT 1;")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestMultiStatementPayloads:
|
|
89
|
+
"""Even without transaction-control keywords, a second statement is refused."""
|
|
90
|
+
|
|
91
|
+
def test_two_selects_are_rejected(self):
|
|
92
|
+
with pytest.raises(UnsafeQueryError):
|
|
93
|
+
validate_readonly_query("SELECT 1; SELECT 2;")
|
|
94
|
+
|
|
95
|
+
def test_select_then_insert_is_rejected(self):
|
|
96
|
+
with pytest.raises(UnsafeQueryError):
|
|
97
|
+
validate_readonly_query("SELECT * FROM users; INSERT INTO users (id) VALUES (1);")
|
|
98
|
+
|
|
99
|
+
def test_select_then_delete_is_rejected(self):
|
|
100
|
+
with pytest.raises(UnsafeQueryError):
|
|
101
|
+
validate_readonly_query("SELECT * FROM users; DELETE FROM users;")
|
|
102
|
+
|
|
103
|
+
def test_semicolon_inside_a_string_literal_is_still_allowed(self):
|
|
104
|
+
# A literal semicolon inside quotes is not a statement boundary.
|
|
105
|
+
validate_readonly_query("SELECT * FROM logs WHERE message = 'a; b; c'")
|
|
106
|
+
|
|
107
|
+
def test_semicolon_inside_a_string_literal_followed_by_real_second_statement_is_rejected(self):
|
|
108
|
+
with pytest.raises(UnsafeQueryError):
|
|
109
|
+
validate_readonly_query(
|
|
110
|
+
"SELECT * FROM logs WHERE message = 'a; b'; DROP TABLE logs;"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestWriteStatementsAreRejectedEvenAsTheOnlyStatement:
|
|
115
|
+
@pytest.mark.parametrize(
|
|
116
|
+
"sql",
|
|
117
|
+
[
|
|
118
|
+
"INSERT INTO users (id) VALUES (1)",
|
|
119
|
+
"UPDATE users SET active = false",
|
|
120
|
+
"DELETE FROM users",
|
|
121
|
+
"DROP TABLE users",
|
|
122
|
+
"DROP SCHEMA public CASCADE",
|
|
123
|
+
"TRUNCATE users",
|
|
124
|
+
"ALTER TABLE users ADD COLUMN x int",
|
|
125
|
+
"CREATE TABLE evil (id int)",
|
|
126
|
+
"GRANT ALL ON users TO public",
|
|
127
|
+
"REVOKE ALL ON users FROM public",
|
|
128
|
+
"VACUUM users",
|
|
129
|
+
"CALL some_procedure()",
|
|
130
|
+
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity",
|
|
131
|
+
],
|
|
132
|
+
)
|
|
133
|
+
def test_write_or_admin_statement_is_rejected(self, sql):
|
|
134
|
+
with pytest.raises(UnsafeQueryError):
|
|
135
|
+
validate_readonly_query(sql)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TestEscapeStringEdgeCases:
|
|
139
|
+
"""Postgres has two incompatible quoting dialects live at once:
|
|
140
|
+
standard '...' strings (backslash is NOT special — the default since
|
|
141
|
+
PG 9.1, standard_conforming_strings=on) and E'...' strings (backslash
|
|
142
|
+
IS an escape character). Getting this wrong in either direction is
|
|
143
|
+
the classic SQL-parser-mismatch bug class. These pin down that the
|
|
144
|
+
masker matches Postgres's real behavior for both."""
|
|
145
|
+
|
|
146
|
+
def test_standard_string_backslash_is_not_an_escape(self):
|
|
147
|
+
# Under standard_conforming_strings=on (the default), '...' ends
|
|
148
|
+
# at the first single quote no matter what precedes it — a
|
|
149
|
+
# trailing backslash does NOT protect it. This mirrors real
|
|
150
|
+
# Postgres parsing, so a payload relying on backslash-escaping a
|
|
151
|
+
# standard string is correctly seen as closing early, exposing
|
|
152
|
+
# the rest as real (and here, dangerous) SQL.
|
|
153
|
+
with pytest.raises(UnsafeQueryError):
|
|
154
|
+
validate_readonly_query("SELECT * FROM t WHERE x = 'abc\\'; DROP TABLE users; --'")
|
|
155
|
+
|
|
156
|
+
def test_e_string_backslash_escaped_quote_stays_inside_the_string(self):
|
|
157
|
+
# E'...' DOES treat backslash as an escape. \' must not be read
|
|
158
|
+
# as the closing quote, or a real semicolon later in the same
|
|
159
|
+
# E-string would be wrongly treated as data by Postgres but as a
|
|
160
|
+
# statement boundary by an unaware masker (a false positive, not
|
|
161
|
+
# a bypass — but still wrong).
|
|
162
|
+
validate_readonly_query(r"SELECT * FROM logs WHERE msg = E'it\'s fine; still one row'")
|
|
163
|
+
|
|
164
|
+
def test_e_string_is_case_insensitive(self):
|
|
165
|
+
validate_readonly_query(r"SELECT * FROM logs WHERE msg = e'it\'s fine too'")
|
|
166
|
+
|
|
167
|
+
def test_e_string_does_not_falsely_trigger_on_a_column_named_ending_in_e(self):
|
|
168
|
+
# The "is this an E-string" check must not fire just because some
|
|
169
|
+
# unrelated identifier happens to end in e/E right before a
|
|
170
|
+
# genuinely standard string starts.
|
|
171
|
+
validate_readonly_query("SELECT * FROM t WHERE name = 'value'")
|
|
172
|
+
|
|
173
|
+
def test_dollar_quoted_body_is_masked(self):
|
|
174
|
+
validate_readonly_query("SELECT $tag$anything; DROP TABLE users;$tag$ AS literal")
|
|
175
|
+
|
|
176
|
+
def test_dollar_quoted_body_does_not_end_on_a_different_tag(self):
|
|
177
|
+
validate_readonly_query("SELECT $a$ text with $b$ inside $a$ AS literal")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestErrorMessageQuality:
|
|
181
|
+
def test_error_names_the_offending_keyword(self):
|
|
182
|
+
with pytest.raises(UnsafeQueryError, match="DROP"):
|
|
183
|
+
validate_readonly_query("DROP TABLE users")
|
|
184
|
+
|
|
185
|
+
def test_error_on_empty_query_is_clear(self):
|
|
186
|
+
with pytest.raises(UnsafeQueryError):
|
|
187
|
+
validate_readonly_query("")
|
|
188
|
+
|
|
189
|
+
def test_error_on_whitespace_only_query_is_clear(self):
|
|
190
|
+
with pytest.raises(UnsafeQueryError):
|
|
191
|
+
validate_readonly_query(" \n\t ")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Integration tests for the MCP tool layer, against the same local
|
|
2
|
+
pgguard_test database used by test_db.py."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import psycopg
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
TEST_DSN = os.environ.get(
|
|
10
|
+
"PG_GUARD_TEST_DSN",
|
|
11
|
+
"host=127.0.0.1 dbname=pgguard_test user=pgguard_readonly password=pgguard_readonly_dev_pw",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _connectable() -> bool:
|
|
16
|
+
try:
|
|
17
|
+
with psycopg.connect(TEST_DSN, connect_timeout=3):
|
|
18
|
+
return True
|
|
19
|
+
except Exception:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
pytestmark = pytest.mark.skipif(
|
|
24
|
+
not _connectable(),
|
|
25
|
+
reason="no local pgguard_test database reachable — set PG_GUARD_TEST_DSN or run the dev setup script",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(autouse=True)
|
|
30
|
+
def _configure_dsn(monkeypatch):
|
|
31
|
+
monkeypatch.setenv("PG_GUARD_DSN", TEST_DSN)
|
|
32
|
+
yield
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestPgRunQuery:
|
|
36
|
+
def test_returns_rows_for_a_plain_select(self):
|
|
37
|
+
from pg_guard_mcp.server import pg_run_query
|
|
38
|
+
|
|
39
|
+
result = pg_run_query("SELECT id, email FROM users ORDER BY id")
|
|
40
|
+
assert result["row_count"] == 2
|
|
41
|
+
assert result["rows"][0]["email"] == "a@example.com"
|
|
42
|
+
|
|
43
|
+
def test_returns_a_typed_error_instead_of_raising_for_unsafe_query(self):
|
|
44
|
+
from pg_guard_mcp.server import pg_run_query
|
|
45
|
+
|
|
46
|
+
result = pg_run_query("DROP TABLE users")
|
|
47
|
+
assert result["error"] == "UnsafeQuery"
|
|
48
|
+
assert "DROP" in result["message"]
|
|
49
|
+
|
|
50
|
+
def test_returns_a_typed_error_for_the_datadog_exploit(self):
|
|
51
|
+
from pg_guard_mcp.server import pg_run_query
|
|
52
|
+
|
|
53
|
+
result = pg_run_query("SELECT 1; COMMIT; DROP TABLE users;")
|
|
54
|
+
assert "error" in result
|
|
55
|
+
|
|
56
|
+
# and the table really is untouched
|
|
57
|
+
followup = pg_run_query("SELECT count(*) AS n FROM users")
|
|
58
|
+
assert followup["rows"][0]["n"] == 2
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestPgListTables:
|
|
62
|
+
def test_finds_the_users_table(self):
|
|
63
|
+
from pg_guard_mcp.server import pg_list_tables
|
|
64
|
+
|
|
65
|
+
result = pg_list_tables()
|
|
66
|
+
names = [row["table_name"] for row in result["rows"]]
|
|
67
|
+
assert "users" in names
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestPgDescribeTable:
|
|
71
|
+
def test_lists_columns_of_users(self):
|
|
72
|
+
from pg_guard_mcp.server import pg_describe_table
|
|
73
|
+
|
|
74
|
+
result = pg_describe_table("users")
|
|
75
|
+
names = [row["column_name"] for row in result["rows"]]
|
|
76
|
+
assert "id" in names
|
|
77
|
+
assert "email" in names
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestPgExplainQuery:
|
|
81
|
+
def test_returns_a_plan_without_error(self):
|
|
82
|
+
from pg_guard_mcp.server import pg_explain_query
|
|
83
|
+
|
|
84
|
+
result = pg_explain_query("SELECT * FROM users")
|
|
85
|
+
assert "error" not in result
|
|
86
|
+
assert result["row_count"] > 0
|
|
87
|
+
|
|
88
|
+
def test_rejects_a_write_statement_instead_of_explaining_it(self):
|
|
89
|
+
from pg_guard_mcp.server import pg_explain_query
|
|
90
|
+
|
|
91
|
+
# EXPLAIN ANALYZE of a write statement actually *runs* the write
|
|
92
|
+
# in real Postgres — this must never reach the database at all.
|
|
93
|
+
result = pg_explain_query("ANALYZE DELETE FROM users")
|
|
94
|
+
assert result.get("error") == "UnsafeQuery"
|
|
95
|
+
|
|
96
|
+
def test_table_is_untouched_after_a_rejected_explain_analyze_write(self):
|
|
97
|
+
from pg_guard_mcp.server import pg_explain_query, pg_run_query
|
|
98
|
+
|
|
99
|
+
pg_explain_query("ANALYZE DELETE FROM users")
|
|
100
|
+
followup = pg_run_query("SELECT count(*) AS n FROM users")
|
|
101
|
+
assert followup["rows"][0]["n"] == 2
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestPgCheckPrivileges:
|
|
105
|
+
def test_readonly_role_has_no_write_grants(self):
|
|
106
|
+
from pg_guard_mcp.server import pg_check_privileges
|
|
107
|
+
|
|
108
|
+
result = pg_check_privileges()
|
|
109
|
+
assert result["rows"] == []
|