Skip to content

security: enforce scopes on remote MCP + log real op names#45

Closed
garagon wants to merge 1 commit into
garrytan:masterfrom
garagon:security/f001-enforce-mcp-scopes
Closed

security: enforce scopes on remote MCP + log real op names#45
garagon wants to merge 1 commit into
garrytan:masterfrom
garagon:security/f001-enforce-mcp-scopes

Conversation

@garagon

@garagon garagon commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Summary

The Supabase Edge Function at supabase/functions/gbrain-mcp/index.ts exposes every non-excluded operation to any holder of a valid bearer token, including destructive ones (put_page, delete_page, put_raw_data, revert_version, log_ingest). The access_tokens.scopes column exists in schema.sql but is never read at runtime, so a token created as read-only can still call delete_page. On top of that, logRequest(...) is called with the literal string 'mcp_request' in both branches of the /mcp handler, so mcp_request_log only records that a call happened, not which tool ran — post-incident forensics is impossible.

This PR fixes both issues at the handler boundary.

Changes

supabase/functions/gbrain-mcp/index.ts

  • Added a DESTRUCTIVE_OPS set covering put_page, delete_page, put_raw_data, revert_version, log_ingest.
  • createMcpServer now takes { name, scopes } from the authenticated token. Read-only tokens (no 'write' scope) get a filtered tools/list that hides destructive ops. Calls to destructive ops from a read-only token are rejected in the CallToolRequestSchema handler with a structured forbidden response pointing at docs/mcp/DEPLOY.md#token-scopes.
  • authenticateToken now SELECTs the scopes column and returns it. NULL (legacy tokens pre-scopes) is coerced to [], so legacy tokens keep working as read-only — no existing integrations break.
  • logRequest is now called with the real operation name in the success / error / forbidden / unknown_tool branches. The two hardcoded logRequest(..., 'mcp_request', ...) sites around the /mcp route are removed — the per-op entries inside the handler are authoritative.

Why not just exclude destructive ops from the remote transport

Moving them into REMOTE_EXCLUDED would block every remote caller, including the owner's own write integrations (sync pipelines, ingest workers). The scopes column was already part of the schema and CLI for this exact reason, it just wasn't wired into the edge path. This PR closes the wiring gap instead of shrinking the API.

Validation

A deterministic PoC in report/evidence/poc-f001-remote-destructive-ops.ts (not part of this diff, from the upstream audit) runs 4 bug checks + 1 context check against index.ts, schema.sql, and operations.ts:

Before the fix (file stashed, same PoC):

[CONFIRMED] C1 destructive ops ungated (no exclude, no scope check)
    REMOTE_EXCLUDED=[sync_brain, file_upload] (destructive excluded: 0/5).
    DESTRUCTIVE_OPS set=[none] (destructive gated: 0/5).
    Scope check present: false. Effective defense: NONE
[CONFIRMED] C2 scopes column exists but unused
    access_tokens.scopes column DEFINED (schema.sql). Runtime usages found: 0
[CONFIRMED] C3 audit log hardcodes operation name
    logRequest(..., 'mcp_request', ...) hardcoded call sites: 2
[INFO]      C4 (context) CASCADE chain pages -> children
    content_chunks, links, tags, raw_data, timeline_entries, page_versions
    all ON DELETE CASCADE — blast radius of a single delete_page
[CONFIRMED] C5 attack chain (read-only token -> destructive op)
    simulateAttack(read-only token) ->
      delete_page:ROUTED, put_page:ROUTED, revert_version:ROUTED,
      put_raw_data:ROUTED, log_ingest:ROUTED
Overall: VULNERABILITY CONFIRMED

After the fix (this PR):

[NOT CONFIRMED] C1 destructive ops ungated (no exclude, no scope check)
    DESTRUCTIVE_OPS set=[put_page, delete_page, put_raw_data, revert_version, log_ingest]
    (destructive gated: 5/5). Scope check present: true. Effective defense: scope-gate
[NOT CONFIRMED] C2 scopes column exists but unused
    Runtime usages found: 7 — index.ts:84, index.ts:118, index.ts:119, ...
[NOT CONFIRMED] C3 audit log hardcodes operation name
    logRequest(..., 'mcp_request', ...) hardcoded call sites: 0
[INFO]          C4 (context) CASCADE chain pages -> children
    (schema unchanged — this is the blast radius, not the bug)
[NOT CONFIRMED] C5 attack chain (read-only token -> destructive op)
    simulateAttack(read-only token) ->
      delete_page:FORBIDDEN, put_page:FORBIDDEN, revert_version:FORBIDDEN,
      put_raw_data:FORBIDDEN, log_ingest:FORBIDDEN
Overall: NOT CONFIRMED

Backwards compatibility

  • Tokens created before the scopes column was populated (rawScopes === null) are treated as empty-scope, i.e. read-only. To regain destructive access, reissue the token with --scopes=write via bun run src/commands/auth.ts create <name> --scopes=write.
  • Read-only usage (search, get_page, list_pages, etc.) is unchanged.
  • The /mcp transport no longer emits the noisy 'mcp_request' log rows, which means pre-existing log queries that counted those rows will need to be updated to count per-op entries instead. There's no data loss — the new rows carry strictly more information.

Test plan

  • bun test unit tests pass
  • bun run test:e2e E2E tests pass against real Postgres + pgvector
  • Deploy edge function (supabase functions deploy gbrain-mcp --no-verify-jwt), issue two tokens — one with --scopes=write, one without. Verify:
    • Read-only token: tools/list omits destructive ops
    • Read-only token: calling delete_page returns the forbidden payload and inserts a forbidden row into mcp_request_log
    • Write token: tools/list shows all ops, delete_page succeeds, mcp_request_log records operation = 'delete_page'
  • Check that legacy tokens (pre-scopes column populated) behave as read-only and do not 500

Not in this PR

  • The CASCADE chain on pages is intentional — this PR doesn't touch it. It's only referenced to show why remote write access matters.
  • The sync_brain and file_upload entries in REMOTE_EXCLUDED are untouched; they're excluded for timeout reasons unrelated to scope enforcement.

The Supabase Edge Function at supabase/functions/gbrain-mcp/index.ts
exposed every non-excluded operation to any valid bearer token,
including destructive ones (put_page, delete_page, put_raw_data,
revert_version, log_ingest). The access_tokens.scopes column existed
in schema.sql but was never read, so a token created "read-only"
could still call delete_page.

The audit trail compounded the problem: logRequest was called with
the literal string 'mcp_request' in both success and error branches,
so mcp_request_log rows recorded only that a call happened, never
which tool ran. Forensic analysis after an incident was impossible.

This PR:

- Adds a DESTRUCTIVE_OPS set and rejects calls to those ops when the
  authenticated token lacks the 'write' scope. Rejection happens at
  the CallToolRequestSchema handler boundary and returns a structured
  'forbidden' payload with the token name, the requested op, and a
  docs pointer.
- Hides destructive ops from tools/list for read-only tokens so the
  MCP client surface matches what the token can actually call.
- Threads `{ name, scopes }` from authenticateToken into createMcpServer
  instead of closing over a module-scoped variable.
- Reads access_tokens.scopes (handling the legacy NULL case) so the
  column stops being dead code.
- Calls logRequest with the actual operation name in the success,
  error, forbidden, and unknown_tool branches. Removes the two
  hardcoded 'mcp_request' log sites from the /mcp route — the
  per-op entries inside the handler are authoritative.

Backwards compatibility: tokens created before the scopes column was
populated (rawScopes === null) are treated as empty-scope, i.e.
read-only. Existing read-only usage is unaffected. To regain
destructive access, reissue the token with --scopes=write.

Refs: docs/mcp/DEPLOY.md#token-scopes
@garrytan

Copy link
Copy Markdown
Owner

Closing this PR — the Edge Function at supabase/functions/gbrain-mcp/index.ts was removed in v0.8.0, so this attack surface no longer exists in the current codebase. The vulnerability analysis was spot-on though. If you'd like to help harden the future gbrain serve --http when it ships, we'd welcome that. Thanks for the thorough security audit, Gus! 🙏

@garrytan garrytan closed this Apr 12, 2026
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