fix(mcp): follow redirects with per-hop SSRF re-validation
From the PR description
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.ts - guardedFetch 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.
Authorizationis dropped when the origin changes, so credentials can't leak to a third-party host via redirect.- Relative
Locationvalues 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
Locationagainst the current URL - refuses a redirect pointing at
169.254.169.254(the guard still fires mid-chain) - drops
Authorizationon a cross-origin redirect - keeps
Authorizationon 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.
Our analysis
Follow OAuth redirects with SSRF checks — read the full analysis →
Think the analysis missed something the PR description covers?
Capture this PR into my fork
Download a Markdown prompt that tells Claude how to port every
commit in this PR into your working tree. Run it via
claude -p < capture-pull-344.md from
inside the repo you want the changes in.