Skip to content

fix(mcp): follow redirects with per-hop SSRF re-validation - #344

Open
terpstra-209 wants to merge 1 commit into
open-legal-products:mainfrom
terpstra-209:fix/mcp-follow-redirects
Open

fix(mcp): follow redirects with per-hop SSRF re-validation#344
terpstra-209 wants to merge 1 commit into
open-legal-products:mainfrom
terpstra-209:fix/mcp-follow-redirects

Conversation

@terpstra-209

Copy link
Copy Markdown

Summary

guardedFetch refused to follow HTTP redirects, which made any MCP server whose OAuth
discovery endpoints redirect impossible to connect. This follows redirects while
re-validating every hop against the same SSRF guard, so the protection is preserved
(and is stricter than plain redirect: "follow").

Found while connecting a public MCP server that serves its RFC 8414 metadata via a 302.

Why

guardedFetch set redirect: "manual" and returned the 3xx response as-is. That helper
is handed to the MCP SDK as its fetch implementation (servers.ts:63), so the SDK saw the
raw redirect during OAuth discovery.

The SDK's discoverAuthorizationServerMetadata tries several well-known URLs in order,
but continues only on 4xx:

if (response.status >= 400 && response.status < 500) continue; // try next URL
throw new Error(`HTTP ${response.status} trying to load OAuth metadata from ${endpointUrl}`);

A 302 is not 4xx, so the first redirecting candidate aborted the whole search — even when
a later candidate would have returned valid metadata. Concretely, for a server whose
protected-resource metadata advertises https://host/mcp as its authorization server:

URL tried Result
https://host/.well-known/oauth-authorization-server/mcp 302 → fatal, search aborts
https://host/mcp/.well-known/oauth-authorization-server 200, valid metadata — never reached

The connector could be created but never authorized, so it sat at 0 tools with an opaque
HTTP 302 trying to load OAuth metadata error. The same server connects normally in other
MCP clients, which follow the redirect.

Changes

backend/src/lib/mcp/client.tsguardedFetch now follows redirects itself rather than
delegating to the runtime, so each hop is re-checked by validateRemoteMcpUrl (HTTPS,
private/reserved IP ranges, DNS re-resolution). redirect: "manual" is retained on the
underlying fetch so the runtime never follows anything unvalidated.

Deliberately bounded:

  • GET/HEAD only. A redirected POST would have to replay its body; no MCP flow needs
    that, and skipping it avoids reasoning about 307/308 body semantics.
  • 5-hop cap, so a redirect loop terminates.
  • Authorization is dropped when the origin changes, so credentials can't leak to a
    third-party host via redirect.
  • Relative Location values are resolved against the current URL before validation.

This is stricter than the redirect: "follow" a normal client would use: with follow,
the runtime resolves the redirect chain internally and the guard only ever sees the
original URL, so a public hostname could bounce to a private address unchecked. Here every
hop is validated before it is fetched.

Testing

backend/src/lib/mcp/__tests__/client.ssrf.test.ts — 7 new cases:

  • follows a redirect and returns the final response
  • resolves a relative Location against the current URL
  • refuses a redirect pointing at 169.254.169.254 (the guard still fires mid-chain)
  • drops Authorization on a cross-origin redirect
  • keeps Authorization on a same-origin redirect
  • does not follow redirects for non-GET requests
  • stops at the hop cap instead of looping

Results:

vitest run src/lib/mcp/__tests__/client.ssrf.test.ts   16 passed
npm test --prefix backend                              557 passed | 23 skipped
tsc --noEmit                                           clean

Manually verified end to end: the connector that previously failed with
HTTP 302 trying to load OAuth metadata now completes OAuth and discovers its full tool
list.


The investigation and code in this PR were done by Claude (Opus 5) via Claude Code; I
reviewed and am submitting it.

guardedFetch set redirect: "manual" and returned the 3xx as-is, so any MCP
server whose OAuth discovery endpoints redirect was unusable. The MCP SDK
continues its well-known URL search only on 4xx and treats anything else as
fatal, so a single 302 aborted discovery even when a later candidate URL
would have returned valid metadata.

Follow redirects here rather than delegating to the runtime, so every hop is
re-checked by validateRemoteMcpUrl (HTTPS, private-IP, DNS re-resolution).
This is stricter than redirect: "follow", which would let a public URL bounce
to an address the guard never saw.

Bounded: GET/HEAD only (no POST body replay), 5 hops max, and the
Authorization header is dropped when the origin changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants