Skip to content

fix: guard OAuth destinations against link-local and metadata addresses in every client - #2796

Merged
cliffhall merged 4 commits into
v2/mainfrom
v2/fix/2749-fetch-destination-guard
Oct 25, 2026
Merged

cliffhall merged 4 commits into
v2/mainfrom
v2/fix/2749-fetch-destination-guard

Conversation

@cliffhall

@cliffhall cliffhall commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Closes #2749
Closes #2802

What changed

Ports v1's /fetch destination 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 in core/mcp/node/fetchGuard.ts.

The guard

  • Scheme: only http: and https:, checked on every hop.
  • Destinations: refuses link-local and metadata addresses only: 169.254.0.0/16 (in dotted and hex IPv4-mapped IPv6 forms too), fe80::/10 and fd00:ec2::254. Loopback and RFC 1918 addresses stay reachable.
  • Redirects: the guard follows them itself (redirect: "manual", at most 5, and only 301/302/303/307/308) and re-checks each hop. It ports v1's nextRedirectRequest rewrite: 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.
  • DNS: the check runs inside undici's 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.
  • Behind HTTPS_PROXY / HTTP_PROXY: requests go through EnvHttpProxyAgent built with the same lookup. ProxyAgent ignores connect, so the hook pins exactly the NO_PROXY direct 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

  • Web, browser OAuth: POST /api/fetch calls guardedFetch and answers a refusal with 403, as v1 does.
  • Web, backend transport: 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.
  • CLI, TUI, mcpdo: a new optional InspectorClientEnvironment.oauthFetch, filled with createGuardedFetch(). InspectorClient builds its auth chain on it. Its transport chain sends non-MCP requests there too, via routeOAuthRequests. That function treats a request as MCP only when the existing exemptMcpEndpoint predicate 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 outside InspectorClient (CLI stored-auth refresh and revocation, mcpdo EMA) use the guarded fetch directly.
  • The MCP endpoint itself still goes through environment.fetch (createProxyFetch()), unchanged.

createGuardedFetch is 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 the typeof fetch adapter (Request input, URLSearchParams forms, 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 pinned NO_PROXY host, and a NO_PROXY name that rebinds to the IMDS after the preflight.
  • routeOAuthRequests.test.ts, transport.test.ts (createBackendBaseFetch) and inspectorClient-oauth-fetchFn.test.ts (auth chain and transport-chain routing).
  • Route tests in server-extra-coverage.test.ts, plus oauthFetch wiring tests in TUI App.test.tsx and mcpdo authorize.test.ts / ema.test.ts.

npm run local:gate passes.

🤖 Generated with Claude Code

…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>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Oct 25, 2026
@cliffhall
cliffhall requested a balanced review from Copilot October 25, 2026 03:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread core/mcp/remote/node/fetchGuard.ts Outdated
Comment thread core/mcp/remote/node/fetchGuard.ts Outdated
Comment thread core/mcp/remote/node/fetchGuard.ts Outdated
…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>
@cliffhall cliffhall changed the title fix(web): guard /api/fetch destinations against link-local and metadata addresses fix: guard OAuth destinations against link-local and metadata addresses in every client Oct 25, 2026
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 1: all three findings fixed in b4a8df5 (answered in each thread):

  • P1, pin NO_PROXY connections: the guard dispatches through EnvHttpProxyAgent built with the pinning lookup, so bypassed hosts are pinned. A rebinding test covers it.
  • P2, abort the proxy DNS preflight: the preflight rejects with the signal's reason on abort, and that path is kept separate from the tolerated resolver failure.
  • P2, Fetch-defined redirects only: only 301/302/303/307/308 are followed; any other 3xx is relayed.

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 InspectorClientEnvironment.oauthFetch. It also covers the web backend's own transport, whose token-refresh discovery bypassed /api/fetch. The MCP endpoint itself stays unguarded. The PR description has the details.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Legacy SSE same-origin OAuth discovery can bypass the guard and follow redirects to blocked metadata addresses.

1 open issue
3 resolved since last review

Review effort: Balanced

Comment thread core/auth/routeOAuthRequests.ts
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>
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 2: the one finding (same-origin discovery under legacy SSE could bypass the guard) is fixed in c08a719 by routing well-known URIs to the guard first; details are in the thread. The remaining same-origin registration case is filed as #2802.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Same-origin custom metadata URLs can still bypass destination checks during legacy SSE transport discovery.

1 open issue
1 resolved since last review

Review effort: Balanced

Comment thread core/auth/routeOAuthRequests.ts Outdated
…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 AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

DNS pinning, redirect handling, proxy behavior, and request classification establish security boundaries across every client and warrant final human security review.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@cliffhall

Copy link
Copy Markdown
Member Author

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.

@cliffhall
cliffhall merged commit fae1aa2 into v2/main Oct 25, 2026
14 of 15 checks passed
@cliffhall
cliffhall deleted the v2/fix/2749-fetch-destination-guard branch October 25, 2026 04:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

2 participants