fix(security): gate read_cache on the authorization that produced the entry - #1226
Merged
Conversation
… 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.
Deploying mcpproxy-docs with
|
| 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 |
Contributor
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 34219119365 --repo smart-mcp-proxy/mcpproxy-go
|
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_cachekey is a hash of the truncated response, not a credential.handleReadCacheresolved 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/callroutesread_cacheto the same handler) — could page the full payload a broader credential had produced. Concretely: an agent token scoped togithub,weatherrunsretrieve_tools, the response is truncated and parked behind a key; aweather-only token on the same session callsread_cachewith 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_cachereads throughManager.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/mcpback-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 aset_profileor 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
internal/cache/authorization.goAuthorizationsnapshot type, caller-kind constants,ErrUnauthorizedRead,CouldHaveProducedsuperset rule (kind → profile server set → server scope / permission tier / pin).internal/cache/models.goRecord.Producer *Authorization(json:"producer,omitempty"), persisted with the entry.internal/cache/manager.goStoreAs(stamped write),GetRecordsAs(gated read, gate runs after expiry and before the access-stats bump);Store/GetRecordskeep their old unstamped/ungated behaviour for internal callers.internal/server/cache_authz.gocacheAuthorization/cacheAuthorizationWithderive the snapshot from the auth context + resolved profile;producerCacheStore/cacheStoreAsadapt theCacheStoreseam so the truncation helpers write stamped entries without knowing about auth.internal/server/mcp.gohandleRetrieveToolsWithMode,handleCallToolVariant,handleCallToolcapture the producer stamp at authorization time and truncate throughcacheStoreAs(producer);handleReadCachereads throughGetRecordsAs(reader), mapsErrUnauthorizedReadto 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.gointernal/server/mcp_read_cache_authz_test.godocs/features/agent-tokens.md,docs/features/routing-modes.mdNo new dependencies. Tool-surface goldens under
internal/server/testdataare 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 norAccessCount; producer persists with the record.TestGetRecordsAs_SameAuthorizationIsByteIdentical: gated page == ungated page at every offset.TestReadCache_NarrowerTokenOnSameSessionCannotReadBroaderEntry: end-to-end throughhandleRetrieveTools→ truncation →handleReadCache; every page refused with the authorization message (not a key-not-found), nogithub:orrecordsin 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):
Verification
Live, isolated instance on
127.0.0.1:18231(scratch--data-dirand--config;~/.mcpproxyand the port-8080 core untouched). Two stdio serversaandb(a local echo fixture),tool_response_limit: 500, quarantine off. Same script run against anorigin/mainbuild (baseline) and this branch (fresh data dir each). Tokens minted over REST:wildcard(allowed_servers: ["*"], read+write) anda-only(allowed_servers: ["a"], read). MCP calls viaPOST /mcpwithAuthorization: Bearer <token>(X-API-Keyfor admin),initialize→notifications/initialized→tools/call,Mcp-Session-Idcarried from the initialize response.The request.
wildcardcallsb:echowith 40 records (1690 chars > 500) → truncated and parked behind a key, identical on both builds:Control on both builds:
a-onlycallingb:echodirectly →Server 'b' is not in scope for this agent token(isError).Baseline leak (
origin/main).a-onlyredeems the wildcard's key and receives thebpayload through all three doors:Branch refusal. Same three doors, every page, and the recursive child key:
Positive controls (branch).
wildcardreads its key on a new session; admin reads it over MCP and over REST;wildcardreads the recursive child;a-onlycallsa:echo(40 records), gets its own key and pages it on the same and on a new session;wildcard(superset) readsa-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);wildcardanda-onlyare 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-lintv2 with.github/.golangci.ymlon 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/tokenswith a misspelled or missingallowed_serverssilently mints a["*"]token — pre-existing REST behaviour, worth a separate fail-closed look.Cross-model review
Reviewer:
opencodewithgithub-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 -raceon./internal/cache/...and the skip-filtered./internal/server/...green, lint 0 issues, goldens untouched.mainevery entry was readable by every caller; the diff strictly narrows that. Admin reading anything is the spec's own FR-001 rule; the anonymous/mcpcaller is out of scope per the spec's Scope Boundary. Universal refusal + durable invalidation is FR-002 work.handleCallToolVariantalso 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 viaset_profile.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 onmain.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 needsProducerplumbed throughReadCacheResponse(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:
internal/runtime/runtime.goregistry-servers) and guesser (internal/experiments/guesser.go) writers still use the unstampedStore, which the gate treats as redeemable by unrestricted callers viaread_cache; SC-005's freshly-generated internal-entry fixture is absent.read_cachechild carries the redeemer's snapshot (possibly broader than the parent's) instead of the parent's snapshot or the narrower of the two./api/v1/tools/callread_cachebranch, an unauthorized live key and a nonexistent key return distinguishable bodies.{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
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 editsinternal/server/mcp.go(handleCallToolVariantprofile-resolution block orhandleReadCache), expect a small hunk-adjacent conflict there.origin/mainwas merged into the branch (merge, not rebase) with no conflicts.