Skip to main content
Glama
les-k

pg-readonly-mcp

pg-readonly-mcp

읽기 전용 Postgres MCP 서버로, 패턴 매칭 대신 SQL을 파싱합니다. 대안이 이미 공개적으로 실패했고, 그 실패에 대해 구체적으로 언급할 가치가 있기 때문입니다.

이 서버가 막고자 하는 우회 방법

Anthropic의 참조 Postgres MCP 서버는 각 쿼리를 읽기 전용 트랜잭션으로 감싸서 "읽기 전용"을 강제했습니다. 또한 세미콜론으로 구분된 다중 명령문 입력을 허용했습니다. 이 조합은 악용 가능합니다:

SELECT 1; COMMIT; DROP SCHEMA public CASCADE;

COMMIT은 읽기 전용 트랜잭션을 조기에 종료시킵니다. 그 이후의 모든 것은 전체 세션 권한으로 실행됩니다. Datadog Security Labs가 2026년에 이를 공개했습니다; 해당 서버는 더 이상 사용되지 않고 보관 처리되었습니다. 하지만 취약한 패키지는 그 후에도 주간 21,000회 다운로드를 기록하고 있었습니다.

읽기 전용 트랜잭션은 쿼리가 어떻게 실행되는지에 대한 속성입니다. 쿼리가 무엇인지에 대해서는 아무것도 말하지 않으며, 이것이 바로 우회가 가능했던 이유입니다. 이 서버는 두 번째 것을 대신 확인합니다.

Related MCP server: postgres-mcp-readonly

두 개의 독립적인 계층

둘 중 하나만 있어도 공개된 우회 방법을 막을 수 있었을 것입니다. 둘 다 존재하는 이유는 한 계층의 결함이 에이전트와 쓰기 작업 사이의 유일한 장벽이 되어서는 안 되기 때문입니다.

1. SQL이 스캔되지 않고 파싱됩니다. guard.py는 sqlglot을 사용하여 실제 구문 트리를 구축하고 세 가지 검사를 적용합니다:

  • 정확히 하나의 명령문. sqlglot.parse는 드라이버가 하는 것처럼 명령문 종료 세미콜론에서 분할하므로, 위의 페이로드는 세 개의 명령문이 되어 그중 어떤 것도 연결에 도달하기 전에 거부됩니다.

  • 외부 명령문은 읽기 형태입니다 — SELECT, UNION, INTERSECT, EXCEPT, 또는 이들로 구성된 CTE. DROP TABLE users는 여기서 거부됩니다.

  • 트리 전체에서 쓰기가 없음을 완전히 탐색합니다. 이것은 다른 두 검사가 대체할 수 없는 검사입니다: Postgres는 CTE가 데이터 수정 명령문을 포함할 수 있으므로, WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x는 외부 수준에서 SELECT입니다. 외부 형태만 확인하는 검사는 이것을 완전히 놓칩니다. 모든 노드를 탐색하면 DELETE가 몇 수준 아래에 있든 관계없이 찾아냅니다.

인식되지 않은 명령문 형태 — sqlglot에 특정 규칙이 없는 모든 것 — 는 다른 모든 것과 동일한 규칙으로 거부됩니다. 익숙하지 않은 것이 안전한 것과 같지는 않습니다.

2. 연결 자체가 실행 요청과 무관하게 쓸 수 없습니다. check_connection_is_readonly는 연결된 역할이 슈퍼유저이거나, 데이터베이스나 역할을 생성할 수 있거나, 행 수준 보안을 우회할 수 있거나, 볼 수 있는 모든 테이블에 대해 SELECT를 초과하는 권한을 보유한 경우 시작을 거부합니다. 완전하지는 않습니다 — Postgres 권한은 소유권, PUBLIC 권한 부여, 또는 이 검사가 열거하지 않는 RLS 정책을 통해서도 올 수 있습니다 — 하지만 잘못된 구성이 가장 일반적으로 나타나는 두 가지 방식을 포착하며, 암시적으로 말하지 않고 명시적으로 설명합니다.

보호하지 않는 것

  • 어쨌든 전달된 권한 있는 연결 문자열. 시작 검사는 과도한 권한의 일반적인 형태를 포착합니다. 완전한 권한 감사는 아니며, 위에서 암시하지 않고 명시적으로 설명합니다.

  • 한계 내에서의 리소스 고갈. 합법적으로 1,000행의 매우 넓은 데이터를 반환하는 쿼리, 또는 합법적으로 계획하는 데 비용이 많이 드는 쿼리는 여전히 그 비용만큼 듭니다. 행 제한과 명령문 시간 제한이 피해를 제한합니다. 비용이 많이 드는 읽기를 무료로 만들지는 않습니다.

  • 에이전트가 데이터를 얻은 후에 하는 일. 이것은 쿼리 게이트이지 데이터 손실 방지 도구가 아닙니다. 테이블에 대한 읽기 액세스는 그 안에 있는 모든 것에 대한 읽기 액세스입니다.

  • 파서 불일치. sqlglot과 Postgres 자체 파서는 동일한 문법의 두 독립적인 구현입니다. Postgres가 받아들이는 모든 경계 사례에 대해 일치한다는 것이 입증되지 않았습니다 — 단일 계층을 신뢰하지 않는다는 전체 전제를 가진 프로젝트에서 진정하지만 좁은 격차입니다. 두 번째 계층이 부분적으로 존재하는 이유입니다: 파서 차이로 인해 의도하지 않은 것이 가드를 통과하더라도, 그 아래의 연결은 여전히 쓸 수 없습니다.

스캐너 결과

2026년 8월 18일 agent-audit 0.19.2에 대해 실행: 15개의 발견, 11개 자동 억제, 4개 조치 가능 — 1 BLOCK, 3 WARN.

BLOCK 발견이 가장 흥미롭고, 전문을 읽을 가치가 있습니다. server.py:156, 신뢰도 1.0: cur.execute(sql) — 매개변수화되지 않은 실행을 통한 SQL 인젝션으로 플래그 지정됨. 해당 라인은 실제입니다. 또한 이 코드베이스에서 가장 많이 방어된 라인이기도 합니다: sql이 해당 라인에 도달할 때쯤이면 validate_readonly()가 이미 이를 파싱하고, 정확히 하나의 명령문임을 확인하고, 해당 명령문이 읽기 형태임을 확인하고, 쓰기를 확인하기 위해 모든 노드를 탐색했습니다. 스캐너는 이 중 어떤 것도 볼 방법이 없습니다 — 단일 파일 패턴 매치이며, 유효성 검사는 다른 함수, 다른 모듈, 여러 라인 앞에서 발생합니다. 일반적으로 위험한 형태를 올바르게 식별하지만, 그 형태가 이미 확인되었음을 볼 수 없습니다.

또한 제안하는 대로 수행했다면 만족될 수 없었을 것입니다. 매개변수화된 쿼리는 고정된 쿼리 형태에 대입된 값을 보호합니다 — WHERE id = %s. 여기에는 적용되지 않습니다. 왜냐하면 쿼리의 구조가 이 도구가 받아들이기 위해 존재하는 입력이기 때문입니다. "호출자가 요청하는 모든 읽기 전용 SQL을 실행"하는 것에 대한 값 전용 매개변수화 체계는 없습니다. 이 클래스의 도구에 대한 수정은 구조를 검증하는 것이며, 이것이 이 파일의 나머지 부분이 하는 일입니다.

query() 정의에 대한 WARN (AGENT-034, "함수 본문에 입력 검증 없음")은 다른 각도에서 본 동일한 사각지대입니다: 함수의 첫 번째 줄은 try/except 내에서 validate_readonly(sql)을 호출합니다. 가져온 함수에 대한 호출은 스캐너가 검증으로 인정하는 패턴이 아닙니다.

하드코딩된 자격 증명에 대한 두 개의 WARN은 tests/conftest.py에 있습니다: 기본 관리자 DSN (postgres:postgres@localhost:5432/postgres, 표준 로컬/CI Postgres 기본값)과 테스트 스위트가 동일한 테스트 내에서 생성하고 삭제하는 역할에 사용된 리터럴 비밀번호입니다. 둘 다 자격 증명 형태의 문자열로 올바르게 식별됩니다. 둘 다 아무것도 보호하지 않는 자격 증명입니다 — 하나는 일회용 로컬 데이터베이스를 가리키고, 다른 하나는 단일 테스트 기간 동안만 존재합니다.

나머지 11개의 발견 — 모두 AGENT-041, 모두 uuid.uuid4()에서 파생된 이름으로 CREATE SCHEMA / GRANT / DROP ROLE 명령문을 구축하는 테스트 픽스처에 있음 — 은 이미 스캐너 자체에 의해 자동 억제되었습니다.

패턴 스캐너는 연기 감지기이지 판사가 아닙니다. 발견한 내용과 그 이유를 공개하는 것이 깔끔한 숫자만 있는 것보다 더 가치 있습니다.

설치

pip install -e .

구성

연결 문자열이 필요하며, --dsn으로 제공되거나 PG_READONLY_MCP_DSN을 통해 제공됩니다 — 환경 변수는 비밀번호가 명령줄이나 실수로 커밋될 수 있는 클라이언트 구성 파일에 나타나지 않도록 존재합니다:

{
  "mcpServers": {
    "pg-readonly-mcp": {
      "command": "pg-readonly-mcp",
      "env": { "PG_READONLY_MCP_DSN": "postgresql://readonly_role:...@host:5432/db" }
    }
  }
}

해당 연결 문자열의 역할은 SELECT 외에는 아무것도 보유하지 않아야 합니다. 서버는 이를 자체적으로 확인하고 그렇지 않으면 시작을 거부합니다 — 위의 check_connection_is_readonly를 참조하세요.

도구

의도적으로 하나의 도구입니다. 전체 가치 제안이 "읽기를 제외한 모든 것을 거부합니다"인 서버는 이를 올바르게 처리하기 위해 두 번째 표면이 필요하지 않습니다.

도구

기능

query

하나의 SELECT 형태 명령문을 실행합니다. 연결에 닿기 전에 파싱되고 탐색됩니다. --max-rows(기본값 1000) 및 --timeout-ms(기본값 5000)으로 제한됩니다.

테스트

37개의 테스트. 25개는 데이터베이스가 필요 없으며 어디서나 실행됩니다 — 이들은 guard.py의 전체 테스트 스위트로, 순수 문자열 입력, 결정 출력입니다. 나머지 12개는 라이브 Postgres가 필요하며 설계상 통합 테스트입니다: check_connection_is_readonly의 요점은 실제 역할 속성과 실제 권한 부여에 대해 수행하는 작업이며, 모의 연결은 서버가 실제 연결에 대해 실제로 수행하는 작업과 관계없이 통과할 것입니다.

pytest -q --cov=pg_readonly_mcp

CI는 실제 postgres:16 서비스 컨테이너에 대해 실행되며, 데이터베이스 기반 테스트가 건너뛰기로 보고되면 빌드를 실패 처리합니다 — sweep-mcp가 심볼릭 링크 테스트에 적용하는 것과 동일한 규칙이며, 같은 이유입니다: 조용히 아무것도 하지 않는 테스트는 테스트가 없는 것보다 나쁩니다.

12개 중: guard.py를 통하지 않고 실제 MCP 도구 호출을 통해 구동되는 정확한 Datadog 페이로드의 라이브 재현 — 그리고 예외가 발생했다는 것뿐만 아니라 대상 스키마가 그 후에도 여전히 존재한다는 단언. 또한 포함: 동일한 호출 경로를 통해 CTE 내에 숨겨진 쓰기, 행 제한, 명령문 시간 제한, 취소된 쿼리가 다음 쿼리를 위해 연결을 사용 가능한 상태로 유지하는 것, 그리고 CREATEDB와 테이블 권한이 전혀 없는 역할이 역할 속성만으로 거부되는 것.

실제 Postgres에 대한 CI에서의 커버리지: 78%, guard.py는 100%. server.py의 격차는 일반적인 psycopg.Error 포괄 처리입니다 — 스위트에서 취소가 아닌 데이터베이스 오류를 의도적으로 유발하는 것은 없습니다 — 그리고 main()의 argparse 및 전송 배선으로, 스위트는 대신 build_server를 통해 직접 실행합니다; 전송은 모의할 가치가 가장 적고 실제 실수가 숨을 가능성이 가장 낮은 부분입니다.

처음 푸시된 버전은 통과하지 못했습니다. 실제 Postgres 서비스 컨테이너가 처음으로 스위트를 실행했을 때만 두 개의 버그가 표면화되었으며, 코드만으로는 둘 다 보이지 않았습니다: SET statement_timeout = %s가 SET statement_timeout = $1로 Postgres에 도달하여 파싱에 실패했습니다. SET은 유틸리티 명령문이고 SELECT처럼 바인드 매개변수를 받지 않기 때문입니다 — 도구에 대한 모든 실제 호출이 동일하게 실패했을 것입니다. 별도로, 테스트 픽스처는 여전히 라이브 권한을 보유한 역할을 DROP ROLE하려고 시도했으며, Postgres는 이를 거부합니다. DROP OWNED BY가 먼저 실행되어야 합니다. 둘 다 수정되었으며, 그 이후의 실행이 이 숫자가 설명하는 실행입니다. 성공만 보고하는 스위트는 아무도 실패를 지켜본 적이 없는 스위트이기 때문에 남겨두었습니다.

레이아웃

src/pg_readonly_mcp/
  guard.py    parses and walks the tree. Opens no connection. 135 lines.
  server.py   the MCP tool, the connection check, the timeout and row cap.
tests/
  test_guard.py    25 tests - no database, run anywhere
  test_server.py   12 tests - live Postgres required, CI-enforced

guard.py는 MCP나 psycopg에 대해 아무것도 모릅니다. 쿼리가 server.py에 있는 이유로 거부된다면, 그것은 버그입니다 — 판단은 문자열만으로 테스트할 수 있는 한 계층 아래에 속합니다.

라이선스

MIT.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Read-only PostgreSQL MCP server that enables running SELECT queries, listing tables and schemas, and describing columns, with built-in protection against writes and malicious SQL attacks.
    590 npm
    MIT
  • F
    license
    Not graded
    quality
    B
    maintenance
    Read-only MCP server for PostgreSQL enabling schema introspection and SELECT queries via MCP clients like Claude, with multi-layered write protection.
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Provides a read-only PostgreSQL MCP server with schema introspection. Enforces least-privilege database roles to prevent any writes, even from malicious SQL.
    MIT