Repository navigation
fix(mcp-server): a mesh tool served over HTTP reaches only the public internet unless an operator calls it - #457
brianonbased-dev wants to merge 4 commits into
Conversation
… internet unless an operator calls it Task cixd (P1, filed from the pre-review of #410/#420). holomesh_invoke_tool POSTed to whatever URL a manifest named, with the manifest's own headers, followed redirects (axios default), and returned the answer whole. holomesh_publish_tool needs only tools:write and checked no URL, and invoke also runs manifests from the shared knowledge store, whose "attestation" is a hash of their own content. So any tools:write key (open registration issues them) could make the hosted server call an internal address and read the reply. - invokePublishedMeshTool: an mcp-http call from a caller who is not an operator (and not the local stdio process) goes through #420's outbound guard: the address is checked, the connection is pinned to it, no redirect is followed, the answer is capped at 5 MB, and a non-2xx answer fails as axios's did. Operators keep the unguarded path. - holomesh_publish_tool refuses a non-public mcp-http address from such a caller up front. The invoke check stays the one that matters: a name can change what it resolves to, and remote manifests never pass through publish. - The rule for who may reach a private network moves beside the guard (callerMayReachPrivateNetwork, exported), and handlers.ts's isTrustedCaller now calls it, so the video fetch and the mesh invoke cannot drift apart. mesh-tool-registry.test.ts gains 5 tests with stand-in servers on 127.0.0.1 that count arrivals: a tools:write invoke of an internal address is refused with 0 arrivals; an operator reaches it and reads the answer; a 307 from an allowed first hop is not followed (target 0 arrivals); a 5 MB + 1 answer is refused; publishing an internal address is refused for tools:write and allowed for an operator. Watched failing, one break at a time: unguarded invoke -> 3 red; every caller trusted -> 4 red; redirects followed -> 1 red; no cap -> 1 red; publish unchecked -> 1 red. Restored 15/15; outbound-url-guard 43/43 and mesh-invoke-tool-signing-ctx pass. twin-earth has the same 2 failures with this change set aside (main's, fixed by #407). tsc 0 errors. Stacked on #420 (the guard); merge #420 first. Not in this change: sweepMeshToolRegistry's periodic GET of each published tool's /health is still unguarded. It returns only healthy/unhealthy, and after this change a non-operator can only publish public addresses, so what is left is a name that later resolves inward. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and a stranger's mesh manifest is always guarded Answers the #457 pre-review (same seat as the author, so it is a pre-review; the distinct-seat review is still owed). P1: handleTool fills a missing signing context with {signer:'stdio-local', scopes:['admin:*']} whenever HOLOSCRIPT_API_KEY is set, on the hosted server too. An execute_workflow step whose batch child lost its caller's context therefore reached callerMayReachPrivateNetwork looking like an operator: mesh publish skipped the address check, and invoke took the unguarded path to an internal address and returned the answer. The rule now reads the stdio-local signer as no context: trusted on the stdio server, not over HTTP. (#407 removes that context-less re-entry at its source; this makes the rule safe on its own.) P3: an operator's invoke could pick a manifest from the shared knowledge store, written by anyone, and POST to its address with its headers, unguarded. The operator exemption now covers only a manifest published on this server (same id and content hash), and a knowledge-store entry can no longer take over the id of a tool published here. The operator path follows no redirects either (a 307 keeps a POST's body). P3: the health sweep followed redirects and read any size. It now follows none (a redirect still counts as an answer) and reads at most 64 KB. Watched red, each break alone against the mesh registry suite (21 tests): stdio-local admin trusted again -> 1 red; a manifest not published here exempted -> 2 red; a store entry taking a local id -> 1 red; operator redirects followed -> 1 red; sweep redirects followed -> 1 red; sweep reading any size -> 1 red. Real code 21/21. Neighbouring suites (outbound guard, signing-ctx invoke, premium exits, mesh store, workspace vantage, daemon lifecycle, AI generation) 149/149. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
triage 2026-10-05: open-cap ≤5; recreate from main if needed |
|
reopened 2026-10-05: Joseph GO — security/hosted-server triage restore |
|
Before this merges (claude6, from the #420 review): #420 is merged (d034294) and this PR now targets main. The guard |
One conflict, packages/mcp-server/src/handlers.ts imports: main (#407) added assertReentrantToolAuthorized/callerPrincipal from ./security/tool-scopes, this PR added callerMayReachPrivateNetwork from ./security/outbound-url-guard. Kept both; isTrustedCaller still delegates to the PR's shared rule. Auto-merged cleanly and verified by diff against main: #407 re-entry checks and signingCtx threading, #410 tenant scopes, #483 allowlist/localCustody are untouched; the PR's public-only mcp-http invoke, publish-time refusal and probe changes remain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…origin or replays a POST body fetchPublicHttp sent the same headers on every hop and replayed method and body, so a probe with Authorization and Cookie that followed a 302 to another origin delivered both there (found in the #420 review; holomesh_invoke_tool sends caller-chosen headers, so it was exposed). Now, when a hop's origin differs from the previous request's, authorization, cookie and proxy-authorization are dropped (any header init form, any case); 301/302 on a POST and 303 on any non-GET/HEAD become a body-less GET without content-type/length; 307/308 keep method and body but still lose credentials cross-origin; same-origin redirects keep their headers. Tests (loopback servers on two ports): Authorization/Cookie absent after a cross-origin 302 and present after a same-origin one; 303 POST arrives as GET with no body; 307/308 keep POST and body without Authorization. Watched red against the pre-fix guard: 8 new tests fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Ready for Release: head What changed since
Proof (sealed
For Release's reviewer: both new commits are claude6's own, so the review must come from a seat other than claude6. The original PR commits are claude1's. |
HoloCI verdict: NOT JUDGEDHoloCI queued the head commit of this pull request with the
That is a problem with the CI fleet, not a verdict on the code: nothing here says the change is right or wrong, and it needs another run before anyone relies on it. |
Board:
task_1790639545383_cixd(P1). It was filed from the pre-review of #410 and #420. Stacked on #420 (the outbound guard): the base isclaude1/video-fetch-public-only, so merge #420 first.What was wrong
holomesh_invoke_toolsent a POST to whatever URL a manifest named, using the manifest's own headers. It followed redirects (axios's default) and returned the answer whole.holomesh_publish_toolneeds onlytools:write, and it checked no URL.So any
tools:writekey could make the hosted server call an internal address and read the reply. Open registration issues those keys. Internal addresses include*.railway.internal, the container's own ports, and link-local metadata. With a redirect, a GET of any of them was also possible.What changes
Invoke (the fix that matters). In
invokePublishedMeshTool, an mcp-http call from a caller who is not an operator (and not the local stdio process) goes through fix(mcp-server): the video reconstruction fetch reaches only the public internet unless the caller is an operator or local #420'sfetchPublicHttp:Operators, and the local stdio process, keep the unguarded path. Since round 2 (below) that holds only for a tool published on this server, and that path follows no redirect either.
Publish. A non-public mcp-http address from such a caller is refused up front. This is defence in depth: a name can change what it resolves to, and remote manifests never pass through publish.
One rule for who may reach a private network. It is
callerMayReachPrivateNetwork, which now sits beside the guard and is exported.handlers.ts'sisTrustedCaller(fix(mcp-server): the video reconstruction fetch reaches only the public internet unless the caller is an operator or local #420) calls it, so the video fetch and the mesh invoke cannot drift apart.Verified (round 1)
mesh-tool-registry.test.tsgains 5 tests. Stand-in servers on 127.0.0.1 count what reaches them:tools:writeinvokes a tool at an internal addresstools:write, allowed for an operatorWatched failing, one break at a time:
Other suites.
outbound-url-guardpasses 43/43 andmesh-invoke-tool-signing-ctxpasses.twin-earth-federationhas the same 2 failures with this change set aside. They are main's failures, which fix(mcp-server): a tool that runs other tools runs them as the real caller, re-checked, on every path #407 fixes.Round 2: the pre-review (head dde980c)
The same seat's pre-review (not the distinct-seat review) found one P1 and two P3s. All three are fixed.
P1: a call that lost its caller counted as an operator.
handleToolfills a missing signing context with{signer:'stdio-local', scopes:['admin:*']}wheneverHOLOSCRIPT_API_KEYis set. The hosted server sets it too.execute_workflowstep whosebatch_tool_callchild lost the caller's context reachedcallerMayReachPrivateNetworklooking like an operator. Publish then skipped the address check. Invoke took the unguarded path to an internal address and returned the answer.stdio-localsigner as no context: trusted on stdio, not over HTTP. fix(mcp-server): a tool that runs other tools runs them as the real caller, re-checked, on every path #407 removes that context-less re-entry at its source, and this makes the rule safe on its own.P3: an operator could run a stranger's manifest unguarded. Manifests from the shared knowledge store carry their author's address and headers.
P3: the health sweep followed redirects and read any size. It now follows none (a redirect still counts as an answer) and reads at most 64 KB.
New tests: 6 more in the same file, 21 in all. They use 127.0.0.1 stand-ins, and one stand-in answers for the knowledge store, so nothing leaves the machine:
handleTool('holomesh_invoke_tool'): no context, key set, over HTTPWatched failing, one break at a time. Each break goes red only in its own test:
Results.
Not in this change
The sweep's address check.
sweepMeshToolRegistrystill sends its periodic GET of each tool's/healthwithout an address check, because tools an operator publishes may live at private addresses./healthper sweep.The made-up admin identity. It still exists on the hosted server for other gates, for example the fork sandbox gate's granted scopes. #407 removes the known ways in. Task x5ku makes the bridge stdio-only.
Review
A distinct seat is needed. The author is claude1 / claudecode-claude-x402. claude2 owns mcp-server and the Railway lane; cursor-claude-x402 was asked for #420.
🤖 Generated with Claude Code