Skip to content

fix(security): gate read_cache on the authorization that produced the entry - #1226

Merged
Dumbris merged 2 commits into
mainfrom
claude/elastic-kilby-47370e
Sep 8, 2026
Merged

fix(security): gate read_cache on the authorization that produced the entry#1226
Dumbris merged 2 commits into
mainfrom
claude/elastic-kilby-47370e

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 8, 2026

Copy link
Copy Markdown
Member

Part of Spec 105 (agent-token scope hardening) — FR-001 / FR-002. Spec 105 is the acceptance contract on branch 105-agent-scope-hardening (not yet merged); this fix was built against the Spec 104 FR-016a lineage that requirement descends from.

Summary

The leak. A read_cache key is a hash of the truncated response, not a credential. handleReadCache resolved the key without looking at who was asking, so any credential that could present a key on the shared MCP session — or on another session, or through the REST direct-call path (/api/v1/tools/call routes read_cache to the same handler) — could page the full payload a broader credential had produced. Concretely: an agent token scoped to github,weather runs retrieve_tools, the response is truncated and parked behind a key; a weather-only token on the same session calls read_cache with that key and receives the github tools it can never discover itself, on every page.

The fix. Every cache entry now carries the authorization it was produced under (cache.Authorization: caller kind, principal, agent server scope, permission tier, profile pin, and the effective profile as a server set). read_cache reads through Manager.GetRecordsAs, which refuses — on every page — any reader that could not have produced the entry. The order is caller kind first: an admin (API key / OAuth admin) reads anything; an agent token never reads an admin's entry; between agents the server set, permission set and profile scope of the reader must each cover the snapshot's; an OAuth user reads only its own entries; the unauthenticated /mcp back-compat caller ranks below an authenticated admin. Profile scope is compared as a server set, so deleting or narrowing a profile after the entry was written revokes cached access (a stale pin resolves to deny-all, and a deny-all reader matches nothing). The stamp is captured when the handler authorizes the call — before the upstream round-trip — so a set_profile or profile deletion in flight cannot re-stamp the response under a scope that never passed the gate. A refused read touches neither the access stats nor the record; a same-authorization read is byte-identical to the previous ungated page.

What changed

File Change
internal/cache/authorization.go New: Authorization snapshot type, caller-kind constants, ErrUnauthorizedRead, CouldHaveProduced superset rule (kind → profile server set → server scope / permission tier / pin).
internal/cache/models.go Record.Producer *Authorization (json:"producer,omitempty"), persisted with the entry.
internal/cache/manager.go StoreAs (stamped write), GetRecordsAs (gated read, gate runs after expiry and before the access-stats bump); Store/GetRecords keep their old unstamped/ungated behaviour for internal callers.
internal/server/cache_authz.go New: cacheAuthorization / cacheAuthorizationWith derive the snapshot from the auth context + resolved profile; producerCacheStore / cacheStoreAs adapt the CacheStore seam so the truncation helpers write stamped entries without knowing about auth.
internal/server/mcp.go handleRetrieveToolsWithMode, handleCallToolVariant, handleCallTool capture the producer stamp at authorization time and truncate through cacheStoreAs(producer); handleReadCache reads through GetRecordsAs(reader), maps ErrUnauthorizedRead to a tool error, re-caches an oversize page under the reader's stamp, and now tags the Spec 028 auth identity onto its activity records.
internal/cache/authorization_test.go Superset table + legacy-entry, untouched-stats and byte-identical-page tests.
internal/server/mcp_read_cache_authz_test.go Handler-level tests: narrower token on the same session refused on every page (and still reads its own entries), deleted pinned profile revokes access, admin reads an agent entry.
docs/features/agent-tokens.md, docs/features/routing-modes.md Document the cache stamp as the fourth enforcement level.

No new dependencies. Tool-surface goldens under internal/server/testdata are untouched and pass unregenerated.

Tests

  • TestAuthorization_CouldHaveProduced (31 cases): same/narrower/broader agent scope, wildcard vs explicit lists, permission tiers, admin vs agent vs anonymous vs user kinds, profile pin match/mismatch, profile server-set coverage, stale (deleted) pin, deny-all readers.
  • TestGetRecordsAs_LegacyEntryWithoutProducer: unstamped entry is refused for agent and user readers, readable by admin/anonymous.
  • TestGetRecordsAs_RefusedReadLeavesStatsUntouched: refusal bumps neither stats nor AccessCount; producer persists with the record.
  • TestGetRecordsAs_SameAuthorizationIsByteIdentical: gated page == ungated page at every offset.
  • TestReadCache_NarrowerTokenOnSameSessionCannotReadBroaderEntry: end-to-end through handleRetrieveTools → truncation → handleReadCache; every page refused with the authorization message (not a key-not-found), no github: or records in the refusal; the producing token still reads every page; the narrow token reads its own entry.
  • TestReadCache_DeletedPinnedProfileRevokesCachedAccess: pinned token reads its entry, operator deletes the profile, the same token is refused.
  • TestReadCache_BroaderReaderMayReadNarrowerEntry: admin reads an agent-produced entry.

Gates (run on the merged branch):

gofmt -l <touched files>                                   -> clean
go build -o ./mcpproxy ./cmd/mcpproxy                      -> ok
go build -tags server -o /dev/null ./cmd/mcpproxy          -> ok
go test -race -skip 'E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint' ./internal/server/... ./internal/cache/...
    ok  internal/server         263.7s
    ok  internal/server/tokens    6.1s
    ok  internal/cache            3.8s
/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml ./internal/server/... ./internal/cache/...
    0 issues

Verification

Live, isolated instance on 127.0.0.1:18231 (scratch --data-dir and --config; ~/.mcpproxy and the port-8080 core untouched). Two stdio servers a and b (a local echo fixture), tool_response_limit: 500, quarantine off. Same script run against an origin/main build (baseline) and this branch (fresh data dir each). Tokens minted over REST: wildcard (allowed_servers: ["*"], read+write) and a-only (allowed_servers: ["a"], read). MCP calls via POST /mcp with Authorization: Bearer <token> (X-API-Key for admin), initializenotifications/initializedtools/call, Mcp-Session-Id carried from the initialize response.

The request. wildcard calls b:echo with 40 records (1690 chars > 500) → truncated and parked behind a key, identical on both builds:

tools/call call_tool_read {"name":"b:echo","args":{"items":["SECRET-B-RECORD-0-…", … 40 items]}}
→ "… [truncated by mcpproxy] Response truncated (limit: 500 chars, actual: 1690 chars, records: 40)
   Use read_cache tool: key=\"<KEY>\", offset=0, limit=50 …"

Control on both builds: a-only calling b:echo directly → Server 'b' is not in scope for this agent token (isError).

Baseline leak (origin/main). a-only redeems the wildcard's key and receives the b payload through all three doors:

# same MCP session as the producer
read_cache {"key":"<KEY>","offset":0,"limit":3}
→ {"records":["SECRET-B-RECORD-0-…","SECRET-B-RECORD-1-…","SECRET-B-RECORD-2-…"],"meta":{"total_records":40,…,"record_path":"echo.items"}}
# new MCP session (a-only initialize)           → identical records payload
# REST: POST /api/v1/tools/call {"tool_name":"read_cache",…} with Bearer <a-only>
→ {"success":true,"data":[{"type":"text","text":"{\"records\":[\"SECRET-B-RECORD-0-…"}]}

Branch refusal. Same three doors, every page, and the recursive child key:

# same session
→ {"content":[{"type":"text","text":"Cache entry is not readable with this credential: it was produced under a broader authorization (server scope, permission tier or profile) than this request holds. Re-run the original tool call with this credential to obtain your own cache key."}],"isError":true}
# new session                                → byte-identical refusal
# page 2 (offset 10)                         → byte-identical refusal
# REST /api/v1/tools/call read_cache branch  → {"success":false,"error":"Failed to call tool: tool call failed: Cache entry is not readable with this credential: …"}
# recursive child (wildcard paged limit=50 → oversize page re-cached under a child key): a-only read_cache → byte-identical refusal
grep -l SECRET-B-RECORD <all branch refusal captures>  → no matches

Positive controls (branch). wildcard reads its key on a new session; admin reads it over MCP and over REST; wildcard reads the recursive child; a-only calls a:echo (40 records), gets its own key and pages it on the same and on a new session; wildcard (superset) reads a-only's key. All return the records payload.

Upgrade fixture. Branch binary started on the baseline's data dir (legacy unstamped entry written by origin/main): admin reads it (twice — not invalidated); wildcard and a-only are refused over MCP and REST.

Gates on the PR head, -count=1: go test -race -skip 'E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint' ./internal/server/... ./internal/cache/...ok internal/server 230.2s · ok internal/server/tokens 5.7s · ok internal/cache 3.5s; golangci-lint v2 with .github/.golangci.yml on the same packages → 0 issues; git diff origin/main --name-only -- internal/server/testdata → empty (goldens unregenerated, green).

Not exercised live (unit-tested only): profile-pin scenarios (pinned token vs unpinned entry, deleted pin) and the in-flight profile-narrowing snapshot. Observed outside the diff: POST /api/v1/tokens with a misspelled or missing allowed_servers silently mints a ["*"] token — pre-existing REST behaviour, worth a separate fail-closed look.

Cross-model review

Reviewer: opencode with github-copilot/gpt-6-astra, one round, verdict FINDINGS (4). Each was checked against the code; none is a defect introduced by the diff — all four are Spec 105 acceptance points already listed under Follow-ups, so no code changed and nothing was pushed. Gates re-run on the PR head after the round: both editions build, go test -race on ./internal/cache/... and the skip-filtered ./internal/server/... green, lint 0 issues, goldens untouched.

# Sev Finding Disposition
R1-F1 P1 Unstamped entries (legacy, registry, guesser) stay readable by admin/anonymous and are never invalidated on refusal (FR-002/SC-004). Declined — follow-up. On main every entry was readable by every caller; the diff strictly narrows that. Admin reading anything is the spec's own FR-001 rule; the anonymous /mcp caller is out of scope per the spec's Scope Boundary. Universal refusal + durable invalidation is FR-002 work.
R1-F2 P2 Profile server-set coverage is checked before caller kind, so an admin bound to a session/URL profile is refused entries it could read before (FR-001 kind-first, SC-005 parity). Declined — follow-up. Deliberate: the profile filter in handleCallToolVariant also blocks admin sessions, so a profiled admin cannot obtain that payload by calling the tool either; the ordering enforces exactly "never a payload you could not have obtained". Over-refusal in the safe direction, self-imposed and liftable via set_profile.
R1-F3 P2 Authorization refusal is distinguishable from key-not-found on MCP and REST (existence oracle). Declined — follow-up. Keys are sha256(tool:args:UnixNano) and cannot be guessed, so a caller can only probe a key it already holds; the message carries no server names, content or producer identity. Not-found was already distinguishable from expired/parse errors on main.
R1-F4 P2 Recursive child pages are stamped with the reader's snapshot rather than the parent's (FR-001 monotonicity). Declined — follow-up. Re-cache happens only after GetRecordsAs(reader) passed, so reader ⊇ parent and any child reader must be ⊇ reader ⊇ parent: nobody who could not read the parent can read the child. Only effect is stricter redemption of the child; carrying the parent's stamp needs Producer plumbed through ReadCacheResponse (Spec 105).

Follow-ups / Spec 105 gaps

This PR ships the read gate and the provenance stamp. Spec 105 contract points it does not cover (reviewer-confirmed list), to be picked up separately:

  • FR-002 universal refusal + durable invalidation: entries with absent/legacy/unrecognised provenance are readable by admin and anonymous readers (nil producer treated as anonymous-produced) and are never deleted on first refused redemption; no upgrade fixture asserts content-free refusal, absence after restart and empty store (SC-004).
  • FR-002 internal-entry isolation: registry (internal/runtime/runtime.go registry-servers) and guesser (internal/experiments/guesser.go) writers still use the unstamped Store, which the gate treats as redeemable by unrestricted callers via read_cache; SC-005's freshly-generated internal-entry fixture is absent.
  • FR-001 caller-kind-first ordering / administrator compatibility: profile coverage is evaluated before caller kind, so a profile-bound (URL/session) administrator is refused entries it could previously read, contrary to "an administrator qualifies for any snapshot" and SC-005 admin parity.
  • FR-001 recursive provenance monotonicity: a re-cached read_cache child carries the redeemer's snapshot (possibly broader than the parent's) instead of the parent's snapshot or the narrower of the two.
  • FR-001 non-disclosing refusal: on MCP and on the REST /api/v1/tools/call read_cache branch, an unauthorized live key and a nonexistent key return distinguishable bodies.
  • FR-001 required fixtures: held-upstream-call tests where the session selection and, separately, the profile definition are narrowed to {a} before the response completes (the deleted-profile test only mutates config after production); cross-session redemption test; upstream-content-block producer test; recursive-child authorization test (admin with session profile {a} → child requested by wildcard agent pinned to {a}, on MCP and REST); pinned-token REST fixture comparing direct dispatch with cache redemption on /api/v1/tools/call.

Notes

  • This branch does not touch internal/server/profile_tool.go; the two profile branches (claude/eager-kirch-10f6c2, claude/xenodochial-lumiere-5abe75) both edit that file and are expected to conflict with each other, not with this one. If either of them also edits internal/server/mcp.go (handleCallToolVariant profile-resolution block or handleReadCache), expect a small hunk-adjacent conflict there.
  • origin/main was merged into the branch (merge, not rebase) with no conflicts.

… entry

A read_cache key is a hash, not a credential. handleReadCache resolved it
without looking at who was asking, so a broad agent token could truncate a
response (minting a key) and a narrower token on the same MCP session, on
another session, or through the REST direct-call path could then page the
broader payload, every page included.

Every cache entry now carries the authorization it was produced under
(caller kind, principal, agent server scope, permission tier, profile pin,
and the effective profile as a server set). read_cache reads through
GetRecordsAs, which refuses on every page any reader that could not have
produced the entry: the reader must be at least as broad in every
dimension, ordered by caller kind first (an admin reads anything; an
agent never reads an admin's entry; between agents the server set,
permission set and effective profile scope must each cover the
snapshot's). Deleting or narrowing a profile revokes cached access (a
stale pin resolves to deny-all and a deny-all reader matches nothing); an
unauthenticated /mcp caller ranks below an authenticated admin. The stamp
is captured when the handler authorizes the call, before the upstream
round-trip, so a profile change in flight cannot re-stamp the response.
Same-authorization reads are byte-identical to the ungated page; a
refused read touches neither access stats nor the record. The REST
direct-call path (/api/v1/tools/call) routes read_cache through the same
handler, so its cache branch is gated by the same rule.

Legacy entries with no producer stamp are treated as produced by an
unrestricted caller with no identity: readable by admin kinds, refused
for every agent token and OAuth user. Refusing them for administrators
too, and invalidating them on first redemption, is left to the spec's
follow-up work.

Tests: internal/cache/authorization_test.go pins the superset table
(kinds, server scope, permission tier, profile pin, profile server sets,
stale pins, legacy entries, untouched stats, byte-identical pages);
internal/server/mcp_read_cache_authz_test.go drives handleRetrieveTools
and handleReadCache with a broad and a narrow token on one MCP session
(refused on every page, own entries still readable), a deleted pinned
profile (revoked), and an admin reading an agent entry (allowed).

Acceptance contract: Spec 105 FR-001 / FR-002 (agent-token scope
hardening, branch 105-agent-scope-hardening, not yet merged); the fix was
built against the Spec 104 FR-016a lineage this requirement descends from.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: b2a662f
Status: ✅  Deploy successful!
Preview URL: https://a10a9329.mcpproxy-docs.pages.dev
Branch Preview URL: https://claude-elastic-kilby-47370e.mcpproxy-docs.pages.dev

View logs

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: claude/elastic-kilby-47370e

Available Artifacts

  • archive-darwin-amd64 (29 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (16 MB)
  • archive-windows-amd64 (29 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (23 MB)
  • installer-dmg-darwin-arm64 (21 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 34219119365 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 87.09677% with 16 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/server/cache_authz.go 77.14% 7 Missing and 1 partial ⚠️
internal/server/mcp.go 73.68% 5 Missing ⚠️
internal/cache/authorization.go 93.75% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@Dumbris
Dumbris merged commit 1f8c1cf into main Sep 8, 2026
41 checks passed
Dumbris added a commit that referenced this pull request Sep 9, 2026
Specs 104 (auto routing mode) and 105 (agent-token scope hardening) were
written and cross-reviewed on 2026-09-07 but never committed; five merged
PRs already cite 105 by name as their acceptance contract. This lands both
and records them in roadmap.yaml so the DAG reflects what shipped and what
is left.

Spec 105 — agent-token scope hardening. The Spec 104 review verified the
scoped-token promise against the code one surface at a time and found eight
places where a legitimately narrow token could learn about or act on servers
outside its grant. Five fix sessions ran from that review and merged on
2026-09-08: #1223 target tier, #1224 tail_log, #1225 set_profile, #1226
read_cache provenance, #1227 prompt owner and deleted-pin enumeration. Each
was live-verified against a baseline binary and reviewed to a clean verdict.
The epic records those five as done tasks and carries eight todo tasks, one
per remaining FR group, taken from the "Follow-ups / Spec 105 gaps"
checklists in those PR bodies: FR-005 retrieve_tools scoping, FR-008 direct
publication identity, FR-010 refusal shapes, the FR-003/FR-004 selectable-
profile predicate for unpinned tokens, FR-002 legacy cache invalidation,
FR-007 log-record attribution, FR-009 producer-side identity, and the
FR-011/013/014 regression suite.

Spec 104 — auto routing mode. Measures the three candidate surfaces per
session on the catalog that session will actually see and serves the richest
rung under a token budget. It depends on 105: its FR-016 states the
invariant those corrections make true. P1 is US1-4, P2 is US5-6.

PR evidence sits on the child tasks rather than the epic, because
gen-roadmap.py --check-github reads an all-merged epic pr: list as "this
epic is done" and the scope-hardening epic is not.

Also flips activity-storage-bounds to done: #1174 wired the 64KB write-path
cap and #1214 bounded the per-server tool_calls buckets, stopped omitempty
erasing configured zeros and added db compact, closing #1175 and #1176.

The specs/README index rows are appended for both, though that table has
been stale since 043 and does not list 044-103.

Validation: gen-roadmap.py --check-github reports 0 errors. The one warning
(mcp-2026-upgrade has no pr: link or child tasks) is pre-existing.
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