Skip to content

Server hardening: summarize, resource limits, HTTP defaults, pool session-state (DB-22138/22159/22139/22176/22133/22202)#11

Open
Sfurti-yb wants to merge 7 commits into
mainfrom
fix/server-hardening-bundle
Open

Server hardening: summarize, resource limits, HTTP defaults, pool session-state (DB-22138/22159/22139/22176/22133/22202)#11
Sfurti-yb wants to merge 7 commits into
mainfrom
fix/server-hardening-bundle

Conversation

@Sfurti-yb

@Sfurti-yb Sfurti-yb commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ships PR #3 (final) of the MCP Server v2 audit primary release. Self-contained hardening fixes bundled into one PR because each is small; separate commits so the review surface stays scannable one concern at a time.

Six tickets closed.

Ticket Priority Fix summary
DB-22138 Medium summarize_database uses per-table SAVEPOINT so one erroring view/table skips instead of aborting the whole loop.
DB-22159 Medium Statement timeout via SET LOCAL statement_timeout; result-row cap via fetchmany + 1; configurable pool sizes; query-length pre-check. Five new env vars, all parsed with a _positive_int argparse helper.
DB-22139 High HTTP mode defaults to MCP_HOST=127.0.0.1. Refuses to start when public host + no auth; escape hatch MCP_ALLOW_UNAUTHENTICATED=true runs with a prominent WARNING.
DB-22176 Low → folded in Origin allowlist is now case-insensitive (RFC 6454). Touches the same middleware as DB-22139.
DB-22133 Medium Pool reset= callback runs DISCARD ALL on every connection return. Supersedes the existing RESET ROLE in _conn_as_role's finally (which stays as belt-and-braces). Closes the "SET ROLE bleeds if the finally is skipped" gap.
DB-22202 Medium Same DISCARD ALL also clears advisory locks (the cross-user DoS repro Vishal verified live) and prepared-statement plans (the pool-slot poisoning where DEALLOCATE ALL desynced psycopg's cache). One code change, two tickets.

Commits

  1. summarize_database: per-table SAVEPOINT so one bad object skips, not aborts — DB-22138. tools.py:summarize_database, 4 integration tests.
  2. Add resource limits: statement timeout, result cap, configurable pool (DB-22159) — DB-22159. server.py + tools.py + new tests/test_config.py (22 unit tests) + 5 integration tests + new mcp_session_capped fixture.
  3. HTTP secure-by-default + Origin allowlist case-insensitivity (DB-22139/22176)server.py + Dockerfile + README.md + new tests/test_http_startup.py (18 unit tests) + 7 Origin case-insensitivity tests.
  4. Fix DB-22138 integration test: COUNT(*) skips projection, use WHERE — one-line test-only fix so the CI-caught bug in commit 1's test is repaired (unblocks the integration job).
  5. Pool session-state isolation via DISCARD ALL (DB-22133 + DB-22202)server.py new _pool_reset callback + tests/test_pool_reset.py (3 unit tests) + tests/test_integration_pool_reset.py (4 integration tests direct from Vishal's ticket repros) + .github/workflows/test.yml glob-based test picker so new unit / integration files are picked up automatically.

Config additions

Seven new env vars, all with sensible defaults so v1 deployments keep working:

Env var Default Purpose
MCP_HOST 127.0.0.1 Bind host. Explicit 0.0.0.0 requires auth.
MCP_ALLOW_UNAUTHENTICATED false Dev-only escape hatch — public bind without auth. Prominent WARNING at startup.
YB_MCP_POOL_MIN_SIZE 1 Min pool connections.
YB_MCP_POOL_MAX_SIZE 5 Max pool connections.
YB_MCP_STATEMENT_TIMEOUT_MS 30000 Per-tool-call statement timeout.
YB_MCP_MAX_RESULT_ROWS 10000 Read-tool result row cap. Truncated responses carry a truncated: true marker.
YB_MCP_MAX_QUERY_LEN 100000 Bytes. Queries larger than this rejected pre-parse.

Backward compatibility

  • Every new env var has a v1-equivalent default. Deployments not touching the new envs behave the same as before — except:
    • MCP_HOST defaults to 127.0.0.1 (was 0.0.0.0). A deployment that was implicitly relying on the old 0.0.0.0 default must now set MCP_HOST=0.0.0.0 explicitly. This is intentional — the pre-fix default was a security footgun.
    • Truncation on the read tool changes response shape from list[dict] to {"rows": [...], "truncated": true, ...} when the row cap is hit. Under the default 10k cap this is unlikely to hit v1 workflows. Non-truncated responses keep the v1 shape.
  • Pool reset=DISCARD ALL is transparent to callers — it fires only after a with pool.connection() block ends, and the existing RESET ROLE in _conn_as_role's finally keeps working the same way inside a call.

CI workflow change (bundled with commit 5)

Historically the CI's unit job ran pytest tests/test_guardrails.py tests/test_auth.py and the integration job ran an explicit three-file list — new test files added over the course of this PR (test_config.py, test_http_startup.py, test_pool_reset.py, test_integration_pool_reset.py) weren't being picked up. Switched both jobs to glob-based selection:

  • Unit: pytest tests/ --ignore-glob='tests/test_integration_*.py'
  • Integration: pytest tests/test_integration_*.py

Also fixes a pre-existing gap: test_integration_write_flag.py was silently outside CI before this PR.

Sequencing

Off origin/main (045bdd0). Independent of PR #9 (read-tool safety) and PR #10 (OIDC v2 mapping) in terms of file overlaps:

Both rebases should be clean since the changes are in different functions or different lines within the same function.

Test plan

  • uv run pytest tests/ --ignore-glob='tests/test_integration_*.py' -q — 172 unit tests pass
  • CI unit checks pass on py3.10/3.11/3.12
  • CI integration check passes on py3.11 (after commit 4's fix to the DB-22138 test's b_second_bad view)
  • Manual verify DB-22138: schema with s.a_bad view over 1/0 + s.z_good table → response contains both entries
  • Manual verify DB-22139: MCP_HOST=0.0.0.0 without MCP_AUTH_PROVIDER → server exits with clear CRITICAL log
  • Manual verify DB-22159: pg_sleep(60) with default 30s timeout → fails within ~30s (not 60)
  • Manual verify DB-22176: MCP_ALLOWED_ORIGINS=https://MyApp.Example.com + browser sends https://myapp.example.com → 200 (was 403)
  • Manual verify DB-22133: SET ROLE via the read tool, verify next _conn_as_role checkout reports the pool user's role, not the previous role
  • Manual verify DB-22202: pg_advisory_lock(k) via the read tool, verify from an independent psql session that the lock is released after the tool call returns

…aborts

Closes DB-22138 — a single problematic table/view (view over a
since-dropped object, permission-denied table, division-by-zero in a
view definition, foreign-table connection error) previously aborted
the whole loop and discarded every other table's row count. The
tool's response collapsed to `[{"error": "..."}]` — a schema summary
with N-1 tables silently missing.

Vishal's ticket repro: schema with a normal `z_good` table (3 rows)
plus a view `a_bad AS SELECT 1 AS x WHERE (1/0) = 1`. The view sorts
alphabetically first, so v1 iterated it before z_good, hit the
division-by-zero, and returned only the error — z_good's count was
never computed. Under a least-privilege pool user this hits regularly:
a role that can't read one table loses the entire summary.

Three structural changes to `summarize_database`:

1. **Initial tables lookup** has its own try/except. If the
   `information_schema.tables` query itself fails, record the error
   and skip the (empty) loop.

2. **Per-table SAVEPOINT.** Each iteration runs
   `SAVEPOINT tbl_<i>` → column lookup + `COUNT(*)` →
   `RELEASE SAVEPOINT`. On failure: `ROLLBACK TO SAVEPOINT` +
   `RELEASE SAVEPOINT` (pg keeps the savepoint alive after rollback,
   so the release is required to keep the stack clean for the next
   iteration). Failed table gets `{"table": name, "error": str(e)}`
   in the summary and iteration continues.

3. **Outer try/finally retained** — the outermost `ROLLBACK` still
   closes the read-only transaction even if a savepoint cleanup
   itself fails.

Design notes:
- Numeric savepoint names (`tbl_0`, `tbl_1`, ...) to avoid pg's
  63-char identifier limit for weird table names. Named via
  `sql.Identifier` to be safe regardless.
- Not fixing DB-22158 (schema identifier interpolation at line 163)
  here — it's scoped to the follow-up v2.1 release and shares this
  function, so bundling would confuse review. Same for DB-22188
  (empty vs non-existent schema).

Tests: 4 new integration cases in `tests/test_integration_reads.py`
(require `YUGABYTEDB_URL`):

- test_summarize_continues_past_error_view — direct Vishal repro.
  Both `z_good` (row_count=3) and `a_bad` (error) appear.
- test_summarize_continues_past_permission_denied — restricted +
  accessible tables; both appear.
- test_summarize_multiple_errors_one_good — two failing views + one
  good table; all three appear, no truncation.
- test_summarize_empty_schema_returns_empty_list — regression: empty
  schema still returns [].

111 non-integration tests still pass.

---
_automated · Claude Code (Opus 4.7)_
… (DB-22159)

Closes DB-22159 — a single unprivileged caller could take the whole
MCP server offline for all users, or crash the process, using trivial
queries. Vishal's live repros:

  # 5 trivial queries down the service for everyone
  5x  run_read_only_query("SELECT pg_sleep(8)")     # occupy 5-conn pool
  then run_read_only_query("SELECT 1")              # blocks 7.4s

  # one query balloons the process toward OOM
  run_read_only_query("SELECT repeat('x', 1024) FROM generate_series(1, 100000)")
    -> server RSS 93 MB -> 712 MB

Root causes fixed:

1. **Hardcoded 5-connection pool** (`min_size=1, max_size=5` in
   app_lifespan). No knob for operators. Now configurable via
   `YB_MCP_POOL_MIN_SIZE` / `YB_MCP_POOL_MAX_SIZE`.

2. **No statement_timeout.** A slow query (pg_sleep, cartesian join,
   heavy scan) holds a pool connection indefinitely. `SET LOCAL
   statement_timeout = '{ms}ms'` at transaction start bounds it.
   `SET LOCAL` scopes to the transaction and dies with the ROLLBACK /
   COMMIT, so the timeout doesn't leak across pool checkouts.
   Configurable via `YB_MCP_STATEMENT_TIMEOUT_MS` (default 30000).

3. **No result-row cap.** `cur.fetchall()` buffers the whole result
   into memory. `SELECT repeat('x', N) FROM generate_series(1, N)`
   drives RSS growth linearly → OOM crash. Replaced with
   `cur.fetchmany(max_result_rows + 1)` — detects overrun by fetching
   one extra row, then truncates and returns a `truncated: true`
   marker so the caller sees the elision. Non-truncated responses
   keep the v1 list-of-dicts shape (unchanged for the common case).
   Configurable via `YB_MCP_MAX_RESULT_ROWS` (default 10000).

4. **No query-size cap.** Vishal measured ~6.4s of CPU in the
   guardrail parser on a ~1MB write query. Pre-parse rejection is the
   cheap first-line defense. Configurable via `YB_MCP_MAX_QUERY_LEN`
   (default 100000 bytes).

New module-level `_positive_int(s)` argparse helper parses all five
env vars — a typo (`YB_MCP_POOL_MAX_SIZE=abc` or `=0`) fails startup
with a clean argparse error instead of a `ValueError` traceback. Same
pattern the DB-22162 follow-up fix uses for `max_insert_rows`; not
retrofitting `max_insert_rows` here to keep scope tight.

Test coverage:

- `tests/test_config.py` (new, 22 unit tests):
  - `_positive_int` — positive / zero / negative / non-int / empty
    / float / whitespace.
  - Defaults for all five limits.
  - Env-var → ServerConfig plumbing.
  - Bad env values → SystemExit (argparse), not a traceback.

- `tests/test_integration_reads.py` (5 new integration tests):
  - `test_statement_timeout_kills_pg_sleep` — pg_sleep(60) with 1s
    timeout fails within ~5s (elapsed-time assertion).
  - `test_result_row_cap_truncates` — 10-row query with cap=3 →
    truncation shape.
  - `test_result_row_cap_not_triggered` — regression: below cap
    keeps the v1 list shape.
  - `test_max_query_len_rejects_oversized_query` — 300-byte-padded
    query with 200-byte cap → pre-execute rejection.
  - `test_max_query_len_allows_normal_query` — regression.

- `tests/conftest.py` — new `mcp_session_capped` fixture with small
  caps (STATEMENT_TIMEOUT_MS=1000, MAX_RESULT_ROWS=3, MAX_QUERY_LEN=200,
  write enabled) so integration tests exercise the caps quickly.

133 non-integration tests pass.

---
_automated · Claude Code (Opus 4.7)_
…9/22176)

Closes DB-22139 (Medium) — Vishal's ticket: with the out-of-box
defaults the HTTP transport was an unauthenticated MCP→DB proxy on
all interfaces, with no DNS-rebinding protection. Verified:

  POST /mcp  (no token, Origin: <https://evil.example.com>)  -> HTTP 200
  startup log: "No auth provider configured, auth disabled"
               "Uvicorn running on http://0.0.0.0:8000"
  Dockerfile:  ENTRYPOINT ["yugabytedb-mcp"]  CMD ["--transport","http"]

Also folds in DB-22176 (Low) — Origin allowlist case-sensitivity —
because it touches the same middleware and the same code path.

Fix has three parts:

**1. Default bind host is now loopback (127.0.0.1), not 0.0.0.0.**

New `MCP_HOST` env / `--host` arg controls the bind host. Operators
who want a public bind must set `MCP_HOST=0.0.0.0` explicitly. The
`YugabyteDBMCPServer.run()` signature changed from
`run(self, host="0.0.0.0", port=8000)` to `run(self, port=8000)`;
host comes from `CONFIG.host` now. The old default was a security
footgun — a distracted operator running `yugabytedb-mcp --transport
http` on any AWS instance immediately exposed the DB.

**2. `_check_http_startup(host)` fail-closed guard.**

Runs before uvicorn opens the socket. When HTTP mode is combined
with a non-loopback host AND no auth provider AND no escape hatch,
the server logs a CRITICAL error explaining the four ways to fix it
and calls `sys.exit(1)`. The escape hatch — `MCP_ALLOW_UNAUTHENTICATED=
true` — lets the server run without auth on a public host, but with
a very prominent WARNING banner in the logs so the choice is visible
in prod.

Independent of auth: the guard also logs a WARNING when the Origin
allowlist is empty in HTTP mode, since `OriginValidationMiddleware`
no-ops on an empty allowlist. Not fatal — some deployments front
non-browser clients only — but the log gives operators a signal.

**3. Origin allowlist is now case-insensitive (DB-22176).**

RFC 6454 declares scheme + host to be case-insensitive; every real
browser lowercases both before sending the Origin header. Pre-fix
the allowlist compared case-sensitively, so an admin typing
`MCP_ALLOWED_ORIGINS=https://MyApp.Example.com` silently rejected
every real browser request (which sends `https://myapp.example.com`).
`_parse_allowed_origins` lowercases entries at load time;
`OriginValidationMiddleware.__call__` lowercases the incoming Origin
at compare time. Belt-and-braces.

Docs updated:

- README config table gains `MCP_HOST` and `MCP_ALLOW_UNAUTHENTICATED`
  rows. "Self-hosted remote mode" section rewritten to open with
  the secure-by-default behavior + the required `MCP_HOST=0.0.0.0`
  + `MCP_AUTH_PROVIDER` combination.
- Dockerfile CMD gains a comment block explaining the same. The
  base image + user-hardening from DB-22161 is follow-up scope
  (v2.1 release), so this commit only adds documentation, not
  actual container hardening.

Test coverage (28 new unit tests):

- `tests/test_http_startup.py` (new, 18):
  - `TestIsLoopback` (5) — parametrized over loopback / non-loopback.
  - `TestEnvBool` (4) — matches parse_config's `.lower() == "true"`;
    documents that `1` / `yes` / `on` are False (DB-22186 follow-up).
  - `TestCheckHttpStartup` (9) — stdio skips check; loopback+no-auth
    OK; loopback+auth OK; public+auth OK; public+no-auth+no-escape
    → SystemExit(1) with CRITICAL log mentioning MCP_AUTH_PROVIDER
    and 0.0.0.0; escape hatch works + logs prominent WARNING; escape
    hatch is a no-op when auth is configured; empty allowlist logs
    WARNING; allowlist configured suppresses that WARNING.

- `tests/test_mcp_spec_middleware.py` (+7):
  - `TestOriginCaseInsensitivity` (4) — uppercase scheme/host,
    mixed case, uppercase scheme only, regression (different origin
    still 403).
  - `TestParseAllowedOriginsCasing` (3) — env value lowercased,
    multi-entry lowercased, MCP_BASE_URL fallback lowercased.

169 non-integration tests pass.

---
_automated · Claude Code (Opus 4.7)_
@Sfurti-yb
Sfurti-yb marked this pull request as ready for review July 9, 2026 22:02
@Sfurti-yb
Sfurti-yb marked this pull request as draft July 10, 2026 05:21
`test_summarize_multiple_errors_one_good` had `b_second_bad` fail via
its projection (`SELECT (5/0)::int AS y`), but Postgres's COUNT(*)
doesn't evaluate the projection — it just counts rows. So the view
existed with 1 row, and the summarize tool's `SELECT COUNT(*)` returned
1 without error. Test asserted a per-object error entry that never
materialized.

Move the failure into WHERE, same shape as a_first_bad. The DB has to
evaluate the WHERE expression to know which rows count, so the
division-by-zero fires and the summarize tool records the per-object
error the test asserts on.

Test-only change; production code untouched.

---
_automated · Claude Code (Opus 4.7)_
Closes DB-22133 (SET ROLE bleed across pool checkouts) and DB-22202
(advisory-lock cross-user DoS + prepared-statement pool-slot poisoning).

Both tickets are the same class of bug: session-level state left behind
by one tool call surviving to the next user's checkout. `RESET ROLE`
in `_conn_as_role`'s finally covers DB-22133 on the happy path but not
if the finally is skipped (KeyboardInterrupt, mid-yield exit, etc.), and
it does nothing for advisory locks or prepared-statement plans — which
was the DB-22202 discovery.

Fix: pass a `reset=` callback to the psycopg ConnectionPool that issues
`DISCARD ALL` on every connection return. `DISCARD ALL` is Postgres's
canonical "scrub session" statement — it clears:

  - the current role (RESET ROLE — closes DB-22133)
  - advisory locks (closes the cross-user DoS in DB-22202)
  - prepared statements + cached plans (closes the pool-slot poisoning
    in DB-22202, where DEALLOCATE ALL through the read tool would
    desync psycopg's client-side cache)
  - session GUCs (bonus — a caller can no longer leave
    `default_transaction_read_only = off` behind)
  - temp tables, sequences, cached plans

Runs after every `with pool.connection() as conn:` block, so any state
the tool accidentally leaves behind is scrubbed before the next checkout.

Design notes:

- The existing `RESET ROLE` in `_conn_as_role`'s finally stays in place
  as belt-and-braces. DISCARD ALL covers the same ground, but keeping
  the finally-block reset means role isolation still works between
  statements within a single tool call (not just between pool
  checkouts). Cheap, safe, defensive.
- `_pool_reset` swallows failures with a WARNING rather than raising.
  If DISCARD itself fails on a dead connection, the pool's existing
  `check_connection` health check replaces the connection on next
  checkout. Raising from the reset callback would leak exceptions into
  psycopg-pool's internal machinery.

Test coverage:

- tests/test_pool_reset.py (new, 3 unit tests):
  - Callback issues DISCARD ALL exactly once.
  - Callback swallows execute() failures + logs WARNING.
  - No side effects beyond the single execute.
- tests/test_integration_pool_reset.py (new, 4 integration tests, need
  YUGABYTEDB_URL):
  - DB-22133 direct repro — SET ROLE → return → next checkout back to
    baseline role.
  - DB-22202 advisory lock repro — pg_advisory_lock(42421) → return,
    verify via an independent psycopg connection that pg_locks no
    longer has the row.
  - DB-22202 prepared-statement repro — prep + DEALLOCATE ALL through
    the pool, next checkout still returns correct results (would have
    failed pre-fix with "prepared statement _pg3_0 does not exist").
  - Bonus — session GUC leak: SET default_transaction_read_only=on
    doesn't survive to the next checkout.

CI workflow fix (bundled here because it directly gates whether these
tests actually run):

- .github/workflows/test.yml — unit job was `pytest test_guardrails.py
  test_auth.py`, missing test_config.py, test_http_startup.py, and now
  test_pool_reset.py. Switched to `pytest tests/
  --ignore-glob='tests/test_integration_*.py'` so new unit test files
  are picked up automatically.
- Integration job was explicit list of three files, missing
  test_integration_write_flag.py (silent gap since before this PR) and
  now test_integration_pool_reset.py. Switched to glob:
  `pytest tests/test_integration_*.py -v`.

172 non-integration tests pass locally (was 169 before this commit).

---
_automated · Claude Code (Opus 4.7)_
@Sfurti-yb Sfurti-yb changed the title Server hardening: summarize per-table, resource limits, HTTP secure-by-default (DB-22138/59/39/76) Server hardening: summarize, resource limits, HTTP defaults, pool session-state (DB-22138/22159/22139/22176/22133/22202) Jul 10, 2026
Two bugs surfaced by CI on the previous commit:

1. `DISCARD ALL cannot run inside a transaction block` — psycopg's
   default is autocommit=False, so `conn.execute("DISCARD ALL")` opens
   an implicit transaction and immediately fails. Fix `_pool_reset`:
   flip to autocommit for the DISCARD, restore in a `finally` so the
   connection state is what the next pool checkout expects.

2. `syntax error at or near "NOT"` — Postgres has no
   `CREATE ROLE IF NOT EXISTS` syntax. Swallow
   `psycopg.errors.DuplicateObject` around the CREATE instead.

Unit tests updated:

- `test_flips_autocommit_around_discard` — asserts the exact call order
  `set_autocommit(True) → execute("DISCARD ALL") → set_autocommit(False)`.
- `test_restores_autocommit_even_when_discard_fails` — the finally
  block still restores autocommit=False when DISCARD raises.
- `test_swallows_set_autocommit_failure` — if the initial
  `set_autocommit(True)` raises (dead connection), the callback still
  log-and-continues rather than leaking into psycopg-pool.
- Dropped `test_no_side_effects_beyond_execute` — asserted a single
  method call, no longer true after adding the autocommit flip.

174 unit tests pass locally (was 172, +2 pool_reset cases).

---
_automated · Claude Code (Opus 4.7)_
@Sfurti-yb
Sfurti-yb requested a review from ashetkar July 10, 2026 13:54
@Sfurti-yb
Sfurti-yb marked this pull request as ready for review July 10, 2026 13:54
Comment thread README.md

The endpoint uses Cognito's `USER_PASSWORD_AUTH` flow under the hood — the Cognito **app client must have `ALLOW_USER_PASSWORD_AUTH` enabled** (Cognito Console → User pools → App integration → App client settings). MFA, password-reset, and other challenge flows are **not** handled by this endpoint — they require the browser flow.

OIDC (`MCP_AUTH_PROVIDER=oidc`) wiring exists but is untested; please share findings if you exercise it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have now tested this OIDC path as well using Keycloak. If so, please remove this line.

Empirically verified against local YugabyteDB (2025.2.0): a lock taken
via `pg_try_advisory_lock(N)` on one connection survives `DISCARD ALL`
and remains visible to other sessions via `pg_locks`. Only
`pg_advisory_unlock_all()` actually releases it. This is a YB deviation
from vanilla Postgres 15, where DISCARD ALL is documented to include
advisory-lock release.

Impact before this fix: the DB-22202 cross-user advisory-lock DoS repro
Vishal filed remained open despite the pool `reset=` callback. Any tool
call that grabbed an advisory lock (accidental or otherwise) would
leave the lock in place across pool checkouts, blocking subsequent
sessions that tried to acquire the same key.

Fix: after `DISCARD ALL`, also run `SELECT pg_advisory_unlock_all()`.
Runs in the same autocommit block; both statements cannot run inside a
transaction, so the existing autocommit-flip logic covers both.

Unit tests updated to assert the two-execute call order (was one).
Integration test `test_discard_all_clears_advisory_lock` was previously
FAILING against a real YB — now passes. Full pool-reset integration
suite: 4/4 pass. Unit suite: 5/5 pass.

---
_automated · Claude Code (Opus 4.7)_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants