Follow HTTP redirects in transparent proxy#4454
Follow HTTP redirects in transparent proxy#4454gkatz2 wants to merge 2 commits intostacklok:mainfrom
Conversation
MCP clients expect JSON-RPC responses and cannot handle HTTP redirects. When a remote server returns a 3xx redirect, the proxy now follows it transparently instead of passing the redirect response to the client. Fixes stacklok#4453 Signed-off-by: Greg Katz <gkatz@indeed.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4454 +/- ##
==========================================
- Coverage 69.57% 69.38% -0.19%
==========================================
Files 489 501 +12
Lines 50193 51385 +1192
==========================================
+ Hits 34920 35654 +734
- Misses 12590 12980 +390
- Partials 2683 2751 +68 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
amirejaz
left a comment
There was a problem hiding this comment.
The redirect-following logic is well-structured and the test coverage is thorough. Two high-severity security issues need to be addressed before merging, plus a few smaller observations.
Security — must fix
1. SSRF via open redirect (High)
The forward loop follows any Location header without validating whether it points to the same host as the original target. In a Kubernetes deployment a compromised or malicious MCP server can return a redirect to http://kubernetes.default.svc/api/v1/secrets, http://169.254.169.254/ (IMDS), or any reachable pod/service. The proxy's pod SA typically has broad network access by design, so the blast radius is significant.
Mitigation: compare redirectURL.Host (normalized) against the original req.URL.Host and break (returning the 3xx as-is) if they differ. Operators who intentionally rename a server's URL should just update the configured target, not rely on redirect following.
2. Authorization header forwarded to a different host (High)
The comment explicitly acknowledges this (// request headers (including Authorization) are preserved across redirects). Combined with the SSRF issue, a bearer token is silently exfiltrated to whatever host the attacker redirects to. The Go stdlib's own http.Client strips Authorization on cross-host redirects precisely to prevent this. See net/http/client.go:shouldCopyHeaderOnRedirect.
Mitigation: either block cross-host redirects (see above) or, if cross-host is ever needed, strip Authorization, Cookie, and Proxy-Authorization when redirectURL.Host != originalHost.
Correctness — looks good
- Body replay via
bytes.NewReader(body)is correct;bodyis pre-read byreadRequestBodybefore the session guard, so the originalreq.Bodystream is intact on the first hop. - Connection pool hygiene (drain + close on each hop) is correct.
req.Host = redirectURL.Hostcorrectly mirrors the existing host-rewrite pattern inRoundTrip.- Interaction with post-
forwardlogic (401 handling, DELETE cleanup, session tracking) is correct — all of it only sees the final response. - 303 exclusion is correct and well-documented.
Minor
maxRedirects = 10matcheshttp.Clientbut for a proxy that is already warning operators on every hop, 3 would be more conservative and sufficient for the intended use case (HTTP→HTTPS redirects, single-hop canonical URL normalisation).- No test exercises a cross-host redirect. Adding one (even if it just asserts the current behavior) makes the security decision explicit and would catch a future regression if a host-check is added.
| isRedirectStatus(resp.StatusCode); redirectsFollowed++ { | ||
| location := resp.Header.Get("Location") | ||
| if location == "" { | ||
| break |
There was a problem hiding this comment.
SSRF via open redirect — req.URL.Parse(location) accepts any absolute URL, including http://kubernetes.default.svc/... or http://169.254.169.254/. A compromised MCP server can redirect the proxy to any internal cluster service, and the proxy (with its typically permissive network policy) will dutifully forward the request.
Consider rejecting cross-host redirects:
| break | |
| redirectURL, parseErr := req.URL.Parse(location) | |
| if parseErr != nil { | |
| slog.Warn("failed to parse redirect Location header", | |
| "location", location, "error", parseErr) | |
| break | |
| } | |
| // Only follow same-host redirects to prevent SSRF. A redirect to a | |
| // different host indicates the operator should update the target URL. | |
| if redirectURL.Host != req.URL.Host { | |
| slog.Warn("refusing cross-host redirect from remote MCP server; update the configured target URL", | |
| "from_host", req.URL.Host, "to_host", redirectURL.Host) | |
| break | |
| } |
Note: the if parseErr != nil block that follows this line in the original is being absorbed into the suggestion above — remove the duplicate below.
| _, _ = io.Copy(io.Discard, resp.Body) | ||
| _ = resp.Body.Close() | ||
|
|
||
| // Clone preserves Method and all headers. We intentionally do not |
There was a problem hiding this comment.
Authorization header forwarded to a different host — req.Clone() copies all headers verbatim, including Authorization. If a cross-host redirect is ever permitted, the bearer token is delivered to the new host. The Go stdlib explicitly strips Authorization on cross-host redirects for this reason (net/http/client.go:shouldCopyHeaderOnRedirect).
If the same-host check suggested above is added, this becomes a defense-in-depth note; if cross-host redirects are intentionally supported in the future, add:
if redirectURL.Host != originalHost {
req.Header.Del("Authorization")
req.Header.Del("Cookie")
req.Header.Del("Proxy-Authorization")
}| targetURL, err := url.Parse(redirector.URL) | ||
| require.NoError(t, err) | ||
| proxy := createBasicProxy(p, targetURL) | ||
|
|
There was a problem hiding this comment.
No test covers a cross-host redirect scenario. It would be valuable to have a test that (a) sets up two separate servers, (b) has server A redirect to server B at a different host, and (c) asserts the expected behavior — either that the 3xx is returned as-is (if the same-host guard is added) or that the Authorization header is stripped (if cross-host is permitted). This makes the security decision explicit and prevents future regressions.
A malicious MCP server could redirect the proxy to internal cluster services (Kubernetes API, cloud IMDS). Restrict redirect following to same-host only and return the 3xx response as-is for cross-host redirects. Signed-off-by: Greg Katz <gkatz@indeed.com>
3411ec6 to
3e034e4
Compare
|
Thanks for the detailed review! Both security issues have been addressed in the follow-up commit 3e034e44. SSRF / cross-host redirect (both issues addressed together): The E2E failure: The failing test ( |
Summary
forwardmethod before returning the response to the client.isRedirectStatushelper that handles 301, 302, 307, and 308. The HTTP method and body are preserved across redirects viareq.Clone(). AmaxRedirectslimit of 10 prevents infinite loops.Fixes #4453
Type of change
Test plan
task test)task test-e2e)task lint-fix)Tested with a local HTTP server returning 308 redirects to a running MCP server (design-system-mcp). Verified:
initializecall succeeds through the redirectDoes this introduce a user-facing change?
Remote MCP servers behind HTTP redirects now work. Previously, the proxy passed redirect responses to the client, causing silent failures. No configuration needed — redirect following is automatic.
Special notes for reviewers
forwardrather thanRoundTripso that session tracking, 401 handling, and DELETE cleanup inRoundTriponly see the final response after all redirects are resolved.isRedirectStatusbecause it explicitly requires changing the method to GET, which would break JSON-RPC.Generated with Claude Code