Repository navigation
fix: guard OAuth destinations against link-local and metadata addresses in every client - #2796
Conversation
…ta addresses (#2749) Port v1's /fetch guard to v2's POST /api/fetch route, in its own module (core/mcp/remote/node/fetchGuard.ts): - http/https only, on every hop - refuse 169.254.0.0/16 (and its IPv4-mapped IPv6 forms), fe80::/10 and fd00:ec2::254; loopback and RFC 1918 stay reachable - follow redirects manually, re-checking each hop, with v1's per-hop credential/method/body rewrite - on the direct path, validate DNS inside undici's connect.lookup so the socket connects to exactly the addresses that were checked - behind HTTPS_PROXY/HTTP_PROXY the proxy resolves names, so a hostname gets a best-effort local check only; documented in the module and README Refusals answer 403, as on v1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
🟡 Changes recommended
Proxy-bypassed requests lack DNS pinning, DNS preflight ignores cancellation, and redirect handling follows non-redirect 3xx responses.
3 open issues
What changed in this PR
Ports v1’s link-local and metadata destination guard to the web backend’s /api/fetch route for #2749, while keeping loopback and LAN targets reachable.
Changes:
- Adds destination validation, manual redirect handling, and connection-time DNS validation for direct requests.
- Integrates guarded fetching with 403 responses and adds unit, integration, and route coverage.
- Documents proxy limitations and the web-only scope.
| File | Description |
|---|---|
core/mcp/remote/node/server.ts |
Uses guarded fetching and returns 403 for refused targets. |
core/mcp/remote/node/fetchGuard.ts |
Implements destination checks, DNS pinning, and redirect handling. |
core/mcp/node/proxyFetch.ts |
Exports adapters for reuse by guarded direct fetching. |
clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts |
Adds route-level refusal and redirect tests. |
clients/web/src/test/integration/mcp/remote/fetchGuard.test.ts |
Exercises direct connections with an injected DNS resolver. |
clients/web/src/test/core/mcp/remote/node/fetchGuard.test.ts |
Tests guard helpers and proxy-mode behavior. |
clients/web/README.md |
Documents restrictions, proxy limitations, and client scope. |
.claude/skills/project-structure/SKILL.md |
Adds the guard to the shared-runtime map. |
Review effort: Balanced
…iew (#2749) Extend the /api/fetch destination guard to every Node client's OAuth traffic, never the configured MCP endpoint: - move the guard to core/mcp/node/fetchGuard.ts and add createGuardedFetch(), a typeof-fetch adapter over it - new InspectorClientEnvironment.oauthFetch: the base of InspectorClient's auth chain, and (via routeOAuthRequests + exemptMcpEndpoint) of the transport chain's non-MCP requests, i.e. the SDK's own discovery on a 401 or a refresh - CLI, TUI and mcpdo fill it, and their standalone OAuth calls (stored-auth refresh, revocation, EMA) use the guarded fetch directly - the web backend's transport routes its OAuth through the guard as well, closing the refresh/discovery path that bypassed /api/fetch Review (Copilot, round 1): - behind a proxy, route through EnvHttpProxyAgent built with the pinning lookup, so a NO_PROXY host's direct connection is pinned too - the proxy-mode DNS preflight now rejects promptly on abort - follow only 301/302/303/307/308; any other 3xx is relayed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
|
Copilot round 1: all three findings fixed in b4a8df5 (answered in each thread):
Scope change in the same commit, at the maintainer's request: the guard now also covers OAuth in the CLI, TUI and mcpdo through a new |
Legacy SSE widens exemptMcpEndpoint's MCP exemption to the server's whole origin, so a hostile SSE server's own /.well-known/oauth-protected-resource went out on the unguarded fetch and could redirect discovery to the metadata service (Copilot, round 2). An RFC 8615 well-known URI is never MCP traffic, so routeOAuthRequests now sends any path containing /.well-known/ to the guard ahead of the classifier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
…2802) Under legacy SSE, exemptMcpEndpoint widens its MCP exemption to the server's whole origin, so any same-origin OAuth request that was neither form-encoded nor at a well-known path - protected-resource metadata at a custom resource_metadata path (Copilot, round 3), or dynamic client registration (#2802) - still went out unguarded and could be redirected to the metadata service. routeOAuthRequests now requires a request to have an MCP wire shape as well as an MCP destination: a JSON-RPC 2.0 POST (message or batch), an event-stream GET, or a session DELETE. OAuth sends none of those, and anything else goes to the guard, which only refuses link-local and metadata addresses. This supersedes the well-known-URI override from round 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
|
Copilot review loop closed: round 4 posted no findings. Across four rounds, the five findings were all in scope and are all fixed: NO_PROXY pinning, abort-aware preflight, Fetch-defined redirect statuses, and same-origin OAuth under legacy SSE (fixed by request-shape classification, which also closes #2802). Copilot flags the PR for a final human security review, since it changes DNS pinning, redirect handling, proxy routing and request classification in every client. |


Closes #2749
Closes #2802
What changed
Ports v1's
/fetchdestination guard (#1717 / #1732) to v2 and applies it to OAuth traffic in every client. It never applies to the MCP endpoint a user configured. The guard lives incore/mcp/node/fetchGuard.ts.The guard
http:andhttps:, checked on every hop.169.254.0.0/16(in dotted and hex IPv4-mapped IPv6 forms too),fe80::/10andfd00:ec2::254. Loopback and RFC 1918 addresses stay reachable.redirect: "manual", at most 5, and only 301/302/303/307/308) and re-checks each hop. It ports v1'snextRedirectRequestrewrite: a cross-origin hop loses its credential headers, 301/302/303 rewrite the method the Fetch-standard way, and a cross-origin hop that would still carry a body is refused.connect.lookup. The addresses the hook validates are the ones the socket connects to, so a name can't resolve differently between check and connect. An IP literal skips lookup, so it's checked before the request instead.HTTPS_PROXY/HTTP_PROXY: requests go throughEnvHttpProxyAgentbuilt with the same lookup.ProxyAgentignoresconnect, so the hook pins exactly theNO_PROXYdirect connections. A proxied host is resolved by the proxy, so it gets a best-effort local check: refused if it resolves locally to a blocked address, allowed if it doesn't resolve locally at all. The DNS lookup stops as soon as the request is aborted. For proxied traffic the proxy is the network boundary.Where it applies
POST /api/fetchcallsguardedFetchand answers a refusal with 403, as v1 does.createTransportNode's fallback base (createBackendBaseFetch) sends non-MCP requests through the guard. This closes a path that bypassed/api/fetch: the SDK refreshing tokens and running discovery inside the backend's transport, using the tokens the browser handed it.InspectorClientEnvironment.oauthFetch, filled withcreateGuardedFetch().InspectorClientbuilds its auth chain on it. Its transport chain sends non-MCP requests there too, viarouteOAuthRequests. That function treats a request as MCP only when the existingexemptMcpEndpointpredicate says so and the request has an MCP wire shape (a JSON-RPC 2.0 POST, an event-stream GET, or a session DELETE), so under legacy SSE same-origin discovery and registration still reach the guard, which covers the SDK's own discovery on a 401 or a refresh. The OAuth calls these clients make outsideInspectorClient(CLI stored-auth refresh and revocation, mcpdo EMA) use the guarded fetch directly.environment.fetch(createProxyFetch()), unchanged.createGuardedFetchis proxy-aware itself, so OAuth stays proxied. One side effect: OAuth requests now load undici even with no proxy configured. The web and CLI READMEs say so.Tests
src/test/core/mcp/node/fetchGuard.test.ts: block-list, redirect rewrite, pinning lookup, the hop loop (redirect statuses, schemes, IP literals, redirect limit), proxy-mode checks including abort mid-lookup, and thetypeof fetchadapter (Request input,URLSearchParamsforms, unsupported bodies, error unwrapping).src/test/integration/mcp/node/fetchGuard.test.ts: real sockets with an injected resolver. Covers the direct path, a proxied host through a proxy, a pinnedNO_PROXYhost, and aNO_PROXYname that rebinds to the IMDS after the preflight.routeOAuthRequests.test.ts,transport.test.ts(createBackendBaseFetch) andinspectorClient-oauth-fetchFn.test.ts(auth chain and transport-chain routing).server-extra-coverage.test.ts, plusoauthFetchwiring tests in TUIApp.test.tsxand mcpdoauthorize.test.ts/ema.test.ts.npm run local:gatepasses.🤖 Generated with Claude Code