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.
Supersedes #344, which was closed and can no longer be reopened (GitHub blocks reopening a PR once its head branch has been force-pushed). Same commit content, rebased onto current main - the SSRF/DNS-pinning/dispatcher-reuse hardening landed there since #344 was opened (f28d9d1, 65cbf0e, 4caf2f3) touched the same file, which is what caused the conflict. guardedFetch still merges cleanly with that work: the redirect-following loop reuses the same pinned guardedAgent dispatcher on every hop.
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.
- Authorization is 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 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, re-run after rebasing onto current main:
vitest run src/lib/mcp/__tests__/client.ssrf.test.ts 18 passed
npm test --prefix backend 847 passed | 25 skipped
tsc --noEmit clean
npm run build --prefix backend clean
Manually verified end to end (prior to rebase): 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-386.md from
inside the repo you want the changes in.