fix(security): scope upstream_servers tail_log to the caller's authorized servers - #1224
Merged
Conversation
…ized servers `upstream_servers` `tail_log` is a per-server read that agent tokens may invoke (it is deliberately absent from auth.agentDeniedServerOps), but the handler never consulted the caller's scope: it went straight to the storage lookup and, on success, returned the server's log lines, its enabled / quarantined flags and its live connection status. A token restricted to server `a` (by AllowedServers, by a profile pin, or by an explicit /mcp/p/<slug> URL profile) could therefore tail hidden server `b`'s log, and could use "not found" vs a served response as an existence oracle for any server name it cared to probe. The fix authorizes the named server against the caller's effective scope BEFORE the lookup, using the same predicate `list` filters by and the call_tool_* variants enforce (`serverInScope` over the AuthContext plus `resolveActiveProfile`, so pin > URL profile > session selection are all honoured, and a stale pin resolves to deny-all rather than widening). The refusal is rendered by one shared helper, `tailLogNotFound`, which is also now used for the genuine storage.ErrUpstreamNotFound path, so an out-of-scope server and a nonexistent server produce byte-identical responses (modulo the echoed name): no existence, no status, no logs. Admin / OS-socket callers carry no restriction and are unaffected. Tests (internal/server/mcp_tail_log_scope_test.go) drive the real `upstream_servers` dispatcher with a two-server fixture whose log files carry a canary line and whose clients are registered so served responses include connection_status (otherwise the non-disclosure assertions would pass vacuously). They cover: a server-restricted token, a profile-pinned token, a stale pin (deny-all), an admin (unchanged, AllowedServers never consulted), and a URL profile scope that bounds every caller including admin. Each hidden case is compared against the unrestricted response for a server that truly does not exist. This is FR-007 (authorize-before-lookup, non-disclosing refusal) of Spec 105, the agent-token scope hardening acceptance contract on branch 105-agent-scope-hardening, not yet merged. The lineage the fix follows is Spec 104 FR-016h. The remaining FR-007 points (canonical log-file ownership for colliding names such as a/b vs a_b, subject-bound logging for shared-service producers, container-ownership housekeeping) are separate follow-ups.
Deploying mcpproxy-docs with
|
| Latest commit: |
202ee19
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://2b8d49ce.mcpproxy-docs.pages.dev |
| Branch Preview URL: | https://claude-agitated-brattain-c21.mcpproxy-docs.pages.dev |
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… cleanup The fixture listed github/secret as ENABLED in the main server's config so profile "gh" would expand (EffectiveServers keeps only configured names). An enabled entry also makes the runtime connect in a background goroutine that keeps writing server-<name>.log after Shutdown returns; on Linux CI that write landed after t.TempDir's RemoveAll and 3 of the 5 new tests failed with "directory not empty" (Build Binaries, linux/amd64). The entries are now disabled: the profile still expands, the runtime never spawns a connection for them, and the enabled flag a served response reports still comes from proxy.storage, which is unchanged.
Contributor
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 34229958295 --repo smart-mcp-proxy/mcpproxy-go
|
…e-gate comment The comment introduced with the tail_log scope gate claimed administrators were 'unaffected'. That is only true for unscoped administrators: like `list` and call_tool_* on the same surface, an explicit URL profile or a session set_profile bounds every caller (Spec 057 FR-004), and the pinned test TestTailLog_URLProfileScope_AppliesToAllCallers documents exactly that. Say so next to the predicate so the next reader does not have to derive it. Runtime behaviour is unchanged.
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-007, authorize-before-lookup with a non-disclosing refusal. Spec 105 is the acceptance contract on branch
105-agent-scope-hardening(not yet merged); the fix follows the Spec 104 FR-016h lineage.Summary
The leak.
upstream_serverstail_logis a per-server read that agent tokens may invoke (it is deliberately absent fromauth.agentDeniedServerOps), buthandleTailLognever consulted the caller's scope. It went straight tostorage.GetUpstreamServer(name)and, on success, returned the server's log lines, itsenabled/quarantinedflags and its liveconnection_status. A token restricted to servera— byAllowedServers, by a profile pin, or by an explicit/mcp/p/<slug>URL profile — could tail hidden serverb's log, and could use "not found" vs. a served response as an existence oracle for any server name it cared to probe.The fix. The named server is authorized against the caller's effective scope before the lookup, using the same predicate
listfilters by and thecall_tool_*variants enforce:serverInScope(authCtx, profileScope, name)withprofileScopefromresolveActiveProfile(ctx), so pin > URL profile > session selection are all honoured and a stale pin resolves to deny-all rather than widening to the token's own server list. The refusal is rendered by one shared helper,tailLogNotFound, which is now also used for the genuinestorage.ErrUpstreamNotFoundpath, so an out-of-scope server and a nonexistent server produce byte-identical responses (modulo the echoed name): no existence, no status, no logs. Admin / OS-socket callers skip theAllowedServerscheck, so unscoped administrators are unaffected; an explicit/mcp/p/<slug>URL profile or session-selected profile bounds administrators too, exactly aslistandcall_tool_*already do on that surface (Spec 057 FR-004) — see review finding R1-1 below.What changed
internal/server/mcp.gohandleTailLognow takesctx; adds the scope check ahead of the storage lookup; addstailLogNotFound(name)as the single refusal shape and routes theErrUpstreamNotFoundbranch through it. Other storage errors keep their previous wording. The scope-gate comment states the actual administrator policy (review R1-1).internal/server/mcp_tail_log_scope_test.goupstream_serversdispatcher withoperation=tail_log.Net: +229 / −1 over three commits (
2d8728cd1fix + tests,1b6bbb68ftest-fixture cleanup,202ee1957review comment correction), no new dependencies, no golden regeneration.Tests
Fixture: two upstreams (
github,secret) with per-server log files carrying a canary line, clients registered (never connected) so a served response includesconnection_status— otherwise the "status not disclosed" assertions would pass vacuously — and a profileghcontaining onlygithub. Every hidden case is compared byte-for-byte (modulo name) against the unrestricted response for a server that truly does not exist (ghost).TestTailLog_ServerRestrictedToken_HidesOutOfScopeServer—AllowedServers=[github]token:secrethidden,githubserved.TestTailLog_ProfilePinnedToken_HidesServerOutsideProfile—AllowedServers=[*]+ProfilePin=gh:secrethidden,githubserved.TestTailLog_StaleProfilePin_DeniesAll— pin to a removed profile hides both servers (deny-all, no widening).TestTailLog_AdminUnchanged— admin context, no context, and an admin with a populatedAllowedServersare all served both servers.TestTailLog_URLProfileScope_AppliesToAllCallers—/mcp/p/ghscope bounds admin and anonymous callers alike (Spec 057 FR-004).Gates run locally on
./internal/server/...:go build ./cmd/mcpproxyandgo build -tags server -o /dev/null ./cmd/mcpproxy— OKgo test -race -skip 'E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint' ./internal/server/...—ok(327s)/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml ./internal/server/...— 0 issuesinternal/server/testdatapass unregenerated (tool definitions are untouched).Verification
Live, against two locally built binaries (baseline =
origin/mainc93f79423; branch =2d8728cd1), each on a fresh--data-dir+--config, port 18221, personal edition. Two stdio upstreamsaandb(./cmd/mcpfixture -transport stdio,quarantine_enabled:false), bothconnected:truewithserver-a.log/server-b.logpresent. Token minted viaPOST /api/v1/tokens:{"name":"a-only","allowed_servers":["a"],"permissions":["read"]}. On both binaries this token'supstream_servers listreturns only["a"], i.e.bis hidden from it.Request (after
initialize/notifications/initialized,Authorization: Bearer <a-only>):{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"upstream_servers","arguments":{"operation":"tail_log","name":"b","lines":5}}}Before (baseline,
origin/main) — the leak.tail_log name=bwith thea-only token is served:Five real log lines of
b, plusserver_status.enabled/quarantinedand liveconnection_status, for a server the same token'slisthides.tail_log name=nonexistentwith the same token returnsServer 'nonexistent' not found: upstream not found(isError:true) —cmpof the two bodies: different, so served-vs-not-found is an existence oracle.After (this branch) — refusal is byte-identical to not-found.
cmpafter normalising the echoed name: identical. Neither body containslog_lines,server_statusorconnection_status. The hidden-brefusal is also identical (name-normalised) to the baseline's genuine not-found body, so the not-found wording did not change across the fix.Positive controls (branch).
a-only token,tail_log name=a→ 5 lines ofa,connection_status.state: Ready. AdminX-API-Key,tail_log name=b→ 5 lines ofb,server_status {enabled:true, quarantined:false},state: Ready(unchanged from baseline). Wildcard token (allowed_servers:["*"], read),tail_log name=b lines=3→lines_returned:3, status and state present.CI round 1 (
2d8728cd1) — red, fixed in1b6bbb68f. Build Binaries linux/amd64 failed 3 of the 5 new tests (ServerRestrictedToken,AdminUnchanged,URLProfileScope) withTempDir RemoveAll cleanup: directory not emptyand a lumberjackwrite error: can't open new logfile: .../server-secret.log— the fixture listedgithub/secretas enabled in the main server's config (so profileghwould expand), which makes the runtime connect them in a background goroutine that keeps writingserver-<name>.logafterShutdownreturns. The other matrix jobs (darwin amd64/arm64, windows amd64, linux arm64) were fail-fast cancelled, not failed — darwin/arm64's test step had already passed. Fix (test-only): the two entries are nowEnabled: false;EffectiveServersonly needs the names, so the profile still expands, the runtime spawns no connection, and theenabledflag a served response reports still comes fromproxy.storage. Focused rungo test -race -count=10 -run 'TestTailLog_' ./internal/server/→ok(28s); lint → 0 issues.Gating in the worktree at
1b6bbb68f:go test -race -count=1 -skip 'E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint' ./internal/server/...→ok(225s;internal/server/tokens5.7s), all five new tests plus the pre-existingTestTailLog_ScrubsLogLinespass; goldens underinternal/server/testdatauntouched;golangci-lint(v2,.github/.golangci.yml) → 0 issues. Live verification above was run at2d8728cd1; the follow-up commit touches only the test fixture. Both instances killed by pattern and verified gone; port 18221 free; the tray core on :8080 was never touched.Caveats: only the
AllowedServersdimension was exercised live — profile pin, stale pin and/mcp/p/<slug>URL scope are covered by the unit tests, not by the running instance.ld: warning: ... malformed LC_DYSYMTABduring the-racetest build is a macOS toolchain warning, unrelated./api/v1/statusreportsversion: nullfor both unstamped local builds (cosmetic).Cross-model review
Reviewer:
opencodeCLI (1.18.29), modelgithub-copilot/gpt-6-astra, run in the worktree with the diff, a brief and a copy of Spec 105. Two rounds, one fix round between them.Round 1 (
1b6bbb68f) — FINDINGS, 2.internal/server/mcp.go) — applied, comment-only (202ee1957). The reviewer pointed out that profile-scoped administrators lose a previously available capability:handleTailLogapplies the profile half ofserverInScopeto admin callers, so an API-key admin on/mcp/p/gh(or with a session-selected profile) getstailLogNotFoundfor an existing server outside the profile, whereorigin/mainserved it;TestTailLog_URLProfileScope_AppliesToAllCallerspins this. The finding is genuine as a description defect: the diff's comment claimed "Admin / OS-socket callers carry no restriction and are unaffected", which is only true for unscoped administrators. The comment now states the actual policy — admins skip theAllowedServerscheck but not the profile check, exactly aslist(handleListUpstreams) andcall_tool_*(handleCallToolVariantprofile gate) already bound every caller on the same surface (Spec 057 FR-004) — and names it as an administrator-visible change on profile-scoped connections. Runtime behaviour deliberately unchanged: exempting admins would maketail_logthe only named-server operation on/mcp/p/<slug>that ignores the URL profile, re-splitting the three predicates this PR unifies. Spec 105 FR-007 (line 40) defines effective authorization as including the resolved profile scope for every caller kind and line 113 applies it "to profile-scoped administrators too"; the tension is with SC-005's parity clause, which is a spec-text reconciliation (add profile-scopedtail_logto the SC-005 exception list) — tracked below, not implemented here.internal/server/mcp.go) — declined: pre-existing, outside the diff. Name authorization does not prevent cross-server disclosure through shared log files:sanitizeServerLogNamemapsa/banda_bto the sameserver-a_b.logandReadUpstreamServerLogTailreturns the unfiltered last N lines, so ana_b-only token still reads hiddena/brecords. Reproduced with a throwaway test ininternal/logs(sanitizeServerLogName("a/b") == sanitizeServerLogName("a_b") == "a_b"; deleted, not committed). Both functions are untouched by the PR, which only adds a name-scope gate before the lookup and neither introduces nor widens the shared-log disclosure. The fix needs per-record canonical attribution at write time plus filter-before-limit and an authorized-taillines_returned(Spec 105 FR-007) — the reviewer itself noted it is the first follow-up below; the no-widening rule keeps it there.Gates after the fix round (
202ee1957): both edition builds OK;go test -race -count=1 -skip '…' ./internal/server/...→ok(241s;tokens5.5s);golangci-lintv2 → 0 issues; goldens underinternal/server/testdataunregenerated and passing; pre-commit hooks passed. Pushed fast-forward from1b6bbb68f.Round 2 (
202ee1957) — CLEAN, 0 findings. The reviewer re-read the diff against Spec 105 and returned no defects in the change; it listed the remaining FR-007 / SC-005 contract points as spec gaps, which are merged into the checklist below.Bite check (own verification). With the
serverInScopegate deleted fromhandleTailLog(_, _ = authCtx, profileScope), 4 of the 5 new tests fail (ServerRestrictedToken,ProfilePinnedToken,StaleProfilePin,URLProfileScope);TestTailLog_AdminUnchangedstill passes, as a regression guard should. The name-normalised byte comparison is not vacuous: the reference response requiresisErrorandnot foundfrom the real storage path, andconnection_statusis asserted present on served responses.Own observations not applied (with reasons):
set_profilescope (tier 3 ofresolveActiveProfile) is honoured by construction because the gate calls the same resolver, but no test drives a session-selected profile. Left as a follow-up; adding it would widen the test fixture beyond this PR.linesparsing and the BBolt read, so it is marginally faster than the genuine not-found path. Both are in-memory fast-fail paths with no file I/O — same timing class under the spec's definition; no timing assertion exists.mainyet, so the reference stays; re-point it when 105 merges.Follow-ups / Spec 105 gaps
This PR covers only the authorize-before-lookup + non-disclosing-refusal half of FR-007. The remaining contract points — from the initial audit and both reviewer rounds — are listed, not implemented, here:
sanitizeServerLogNamemapsa/banda_b(any non[A-Za-z0-9._-]rune) to the sameserver-a_b.log, so ana_b-only token still reads hiddena/b's lines; no per-record canonical-writer attribution exists (internal/logs/logger.go~297-308, ~468-498). (Reviewer R1-2, P1 — pre-existing.)mcp_tail_log_scope_test.go~63-64, ~114), which will have to change when attribution lands.ReadUpstreamServerLogTailtails all records first,logger.go~495-511, so a foreign interleaved line can displace an authorized one from the returned window) andlines_returnedmust count the authorized tail — today it islen(logLines)of the raw tail (mcp.go~5745).a/bvsa_bvs case variants), plus interleaved-line fixtures with and without a hidden co-owner (attributable / foreign / legacy records) and the differential hidden-co-owner cases.internal/oauth/config.go~1692-1732); interleaveda/bOAuth fixture.mcpproxy-a-also matchesmcpproxy-a-b-…,internal/upstream/core/docker.go~349-415) and matched containers are logged under the requesting client; foreign container ids must never be written toa's log, legacy foreign records withheld;avsa-bcleanup fixture.set_profile) test fortail_log.upstream_serversswitch (mutations are denied byAuthorizeServerOp, quarantine ops reject non-admins), so there is no second leak today — but there is no generalized pre-lookup scope gate, and operation/config refusals (read-only mode,DisableManagement,AuthorizeServerOp,mcp.go~3370-3406, ~3482-3486) still precede name-scope resolution rather than FR-010's scope-first ordering. FR-010 precedence is not demonstrated across them./mcp/p/<slug>or sessionset_profile) are now refusedtail_logfor servers outside the profile, which is not on Spec 105's SC-005 exception list (reviewer R1-1). Reconcile the spec text — add profile-scopedtail_logto the exception list, consistent with FR-007 line 40 / line 113 — rather than exempting admins inhandleTailLog; also add the SC-005 administrator outcomes for the collision / interleaving / container fixtures above.AllowedServersagent-token case;assertTailLogServedchecks only the presence ofserver_status/connection_status, not that the flag values match storage or full pre-feature response parity; no test drives the real HTTP anonymous-auth middleware / transport path (the scope tests call the dispatcher with a constructed context).Notes
origin/main(c93f79423) before commit; no conflicts.internal/server/mcp.go(thehandleTailLogregion) and a new test file. It does not editinternal/server/profile_tool.go, so no merge conflict is expected with the two profile-related branchesclaude/eager-kirch-10f6c2andclaude/xenodochial-lumiere-5abe75; those two conflict with each other, not with this one.