Allow MCPRemoteProxy to work without upstream or client auth#4168
Allow MCPRemoteProxy to work without upstream or client auth#4168aron-muon wants to merge 7 commits intostacklok:mainfrom
Conversation
e7fec58 to
1c8c7cd
Compare
There was a problem hiding this comment.
Large PR Detected
This PR exceeds 1000 lines of changes and requires justification before it can be reviewed.
How to unblock this PR:
Add a section to your PR description with the following format:
## Large PR Justification
[Explain why this PR must be large, such as:]
- Generated code that cannot be split
- Large refactoring that must be atomic
- Multiple related changes that would break if separated
- Migration or data transformation
Alternative:
Consider splitting this PR into smaller, focused changes (< 1000 lines each) for easier review and reduced risk.
See our Contributing Guidelines for more details.
This review will be automatically dismissed once you add the justification section.
Codecov Report❌ Patch coverage is
Additional details and impacted files☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
0aa7f12 to
68e79e9
Compare
Large PR justification has been provided. Thank you!
|
✅ Large PR justification has been provided. The size review has been dismissed and this PR can now proceed with normal review. |
68e79e9 to
1037865
Compare
1037865 to
5b110c0
Compare
5b110c0 to
010f7fc
Compare
010f7fc to
49e9690
Compare
|
@aron-muon hey bud, just got back from leave, for reference this RFC has been raised that aims to provide a new CRD that will eventually lead to the deprecation and removal of |
Very exciting! |
|
@claude review the changes and check if this overlaps with the MCPOIDCConfig CRD changes that have been added recently. |
|
I'll analyze this and get back to you. |
There was a problem hiding this comment.
Regarding the MCPOIDCConfig overlap question: I don't find a MCPOIDCConfig CRD type in the current main branch, so I can't verify overlap — this likely refers to a pending or in-progress change that should be compared manually. A security issue was also identified in the new redirect-following logic (see inline comment) that should be addressed before merge.
Extended reasoning...
Overview
This PR adds three features to MCPRemoteProxy: optional OIDC (anonymous access), a DisableUpstreamTokenInjection flag with strip-auth middleware, and a new forwardFollowingRedirects function in the transparent proxy. It touches 27 files including CRD types, controller logic, middleware, and the core transparent proxy transport.
Security risks
The bug hunting system identified a real security regression in forwardFollowingRedirects: the Authorization header (containing the client JWT or upstream IdP access token) is cloned unconditionally into redirect requests, including cross-host redirects. Go's standard http.Client has protected against this since Go 1.12 by stripping Authorization/Cookie headers on cross-host redirects — this implementation omits that check. This is a credential exfiltration risk when the upstream issues 3xx redirects to attacker-controlled or unintended external hosts.
Level of scrutiny
High. The PR modifies security-sensitive proxy transport code, introduces new auth middleware, and changes a required CRD field to optional (breaking schema change). The transparent proxy redirect logic in particular is new and complex with insufficient test coverage (41% patch coverage reported by Codecov).
Other factors
A human reviewer (ChrisJBurns) noted in March that an RFC is being drafted to deprecate/replace MCPRemoteProxy. A reviewer (lujunsan) also specifically requested a check against MCPOIDCConfig CRD changes; no MCPOIDCConfig type exists in main, so that overlap cannot be verified automatically. The combination of an unresolved design direction, a security bug in new proxy code, and the explicit overlap question warrants human review before merge.
| newReq, err := http.NewRequestWithContext(req.Context(), req.Method, redirectURL.String(), nil) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to build redirect request: %w", err) | ||
| } | ||
| newReq.Header = req.Header.Clone() |
There was a problem hiding this comment.
🔴 The Authorization header (containing either the client JWT or the upstream IdP access token) is unconditionally forwarded to cross-host redirect targets in forwardFollowingRedirects. A compromised or misbehaving upstream server that returns a 3xx redirect to a different host will receive the bearer token. Go's own http.Client has protected against this since Go 1.12 via shouldCopyHeaderOnRedirect(), which strips Authorization, Cookie, and Cookie2 on cross-host redirects; this implementation omits that check. The strip-auth middleware added in this PR only covers the DisableUpstreamTokenInjection=true case — the default configuration (embedded auth server with upstream-swap) remains vulnerable.
Extended reasoning...
What the bug is and how it manifests
forwardFollowingRedirects (transparent_proxy.go, lines 434–438) builds a new request for each 3xx redirect and does newReq.Header = req.Header.Clone() unconditionally — no matter whether the redirect URL is on the same host or a completely different one. The Authorization header therefore travels to every redirect destination, including cross-host ones.
The specific code path that triggers it
The middleware chain runs before httputil.ReverseProxy calls tracingTransport.RoundTrip:
- If
DisableUpstreamTokenInjection=falseand an embedded auth server is configured, the upstream-swap middleware replaces the client JWT with an upstream IdP access token inAuthorization. RoundTripreads this augmented request and callsforwardFollowingRedirects.- If the upstream returns a 3xx redirect to a different host,
forwardFollowingRedirectsclones all headers — including theAuthorizationheader containing the IdP token — into the redirect request and forwards it.
Why existing code doesn't prevent it
The strip-auth middleware added by this PR removes Authorization before RoundTrip is called, but only when DisableUpstreamTokenInjection=true. In that specific case the header is already gone by the time forwardFollowingRedirects runs, so no leakage occurs. In the two other configurations — (1) OIDC only (no embedded auth server) where the client JWT stays in Authorization, and (2) embedded auth server with upstream-swap active — the Authorization header is present and is forwarded verbatim to cross-host redirect targets.
Go's own http.Client has protected against this since Go 1.12 via shouldCopyHeaderOnRedirect(), which explicitly strips Authorization, Cookie, and Cookie2 when req.URL.Host != via[0].Host. The forwardFollowingRedirects implementation replicates http.Client redirect-following but omits that host-change check.
Impact
Any trusted upstream that issues a cross-host redirect (e.g., an API gateway, CDN, or a compromised server) will receive the bearer token. In production the token at risk is the upstream IdP access token (e.g., an Okta or GitHub token), not just the short-lived ToolHive JWT. This is a regression introduced by this PR; previously the proxy did not follow redirects at all, so the issue did not exist.
Step-by-step proof
- Client authenticates via embedded auth server (Okta upstream configured,
DisableUpstreamTokenInjection=false). - Upstream-swap middleware replaces the ToolHive JWT with an Okta access token:
Authorization: Bearer <okta-token>. RoundTripcallsforwardFollowingRedirects(req, body)with the Okta token in headers.- Upstream MCP server at
mcp.example.comreturns302 Location: https://attacker.example.com/collect. forwardFollowingRedirectscomputesredirectURL = attacker.example.com/collect(different host).newReq.Header = req.Header.Clone()copiesAuthorization: Bearer <okta-token>into the redirect request.- The proxy sends
GET https://attacker.example.com/collectwith the Okta token — credential exfiltrated.
How to fix it
Before cloning headers, compare redirectURL.Host against req.URL.Host. If they differ, delete Authorization (and Cookie/Cookie2) from the cloned header map — mirroring Go's shouldCopyHeaderOnRedirect behaviour:
newReq.Header = req.Header.Clone()
if redirectURL.Host != req.URL.Host {
newReq.Header.Del("Authorization")
newReq.Header.Del("Cookie")
newReq.Header.Del("Cookie2")
}
The embedded auth server always injected upstream IdP tokens into requests forwarded to backend MCP servers. This made it impossible to use the embedded auth server for client-facing OAuth flows when the upstream MCP server is public and doesn't require authentication — the injected token caused 401 rejections from the upstream. Add a `disableUpstreamTokenInjection` field to EmbeddedAuthServerConfig that skips the upstream swap middleware while keeping the embedded auth server running for client authentication. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
MCPRemoteProxy previously required oidcConfig, making it impossible to proxy public MCP servers without configuring authentication. Make the oidcConfig field optional — when omitted, the proxy allows anonymous access and forwards requests to the upstream without any authentication on either side. This enables "case 1" where both the upstream MCP and the proxy are fully public. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three issues prevented MCPRemoteProxy from connecting to third-party upstream MCP servers that use HTTP redirects: 1. X-Forwarded-Host leaked the proxy's hostname to the upstream. The upstream used it to construct 307 redirect URLs pointing back to the proxy, creating a redirect loop. Fix: skip SetXForwarded() for remote upstreams (isRemote == true). 2. Go's http.Transport.RoundTrip does not follow redirects, but httputil.ReverseProxy uses Transport directly. Upstream 307/308 redirects (e.g. HTTPS→HTTP scheme changes, path canonicalization) were returned to the MCP client which cannot follow them through the proxy. Fix: add forwardFollowingRedirects that transparently follows up to 10 redirects, preserving method and body for 307/308 (RFC 7538). 3. When disableUpstreamTokenInjection is true, the client's ToolHive JWT was still forwarded to the upstream in the Authorization header. Fix: add strip-auth middleware that removes the Authorization header before forwarding. Also adds debug logging for outbound request headers and upstream response status codes to aid diagnosis of remote proxy issues. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
67d3ec4 to
3b7c6e1
Compare
|
Hey @aron-muon, thanks for keeping this rebased! I did a deep dive into the current state of Goal 2 (optional Goals 1 and 3 are still unresolved:
It's also worth noting that for users who don't need the proxy pod at all — they just want vMCP to call a remote server with optional auth — Would you be open to splitting this into a focused PR covering just goals 1 and 3? That would make it smaller and easier to review. Happy to help coordinate. |

Summary
MCPRemoteProxy required OIDC authentication and always injected upstream tokens when the
embedded auth server was configured. This made two common deployment patterns impossible
and the transparent proxy leaked X-Forwarded headers to remote upstreams.
Why: Operators sometimes need to proxy third-party public MCP servers (e.g., documentation APIs)
with client authentication (Okta, GitHub) but without injecting tokens upstream — the upstream
is public and does not expect auth headers. Additionally,
X-Forwarded-Hostheaders leaked toremote upstreams could cause redirect loops when the upstream used them to construct redirect URLs.
What changed (3 logical changes across 5 commits):
Add
disableUpstreamTokenInjection— whentrue, the embedded auth server stillhandles OAuth flows for clients but does not inject upstream IdP tokens into outgoing
requests. A
strip-authmiddleware removes the client ToolHive JWT from theAuthorization header to prevent it leaking to the upstream.
Make
oidcConfigoptional — when omitted, the proxy allows anonymous access with noauthentication on either side. Added nil-check at the top of
resolveAndAddOIDCConfig(which now also supports the new
MCPOIDCConfigRefpath added by upstream).Skip
SetXForwarded()for remote upstreams — preventsX-Forwarded-Hostfromleaking the proxy hostname to third-party servers, which can cause 307 redirect loops.
Also adds debug logging for outbound requests and upstream responses.
Upstream convergence notes (changes that landed in main during this branch lifetime):
followRedirectsimplementation with cross-hostand HTTPS-downgrade guards. This PR original
forwardFollowingRedirectswas droppedin favor of upstream more secure version.
OIDCConfigpointer type: Main independently madeOIDCConfiga*OIDCConfigRefpointer and added the new
OIDCConfigRef *MCPOIDCConfigReferencefield for shared OIDCconfig. This PR optional-auth changes integrate with that — the nil check in
resolveAndAddOIDCConfighandles bothOIDCConfigandOIDCConfigRefbeing nil.PorttoProxyPortrename: Main renamed the field; tests updated accordingly.buildOIDCClientSecretEnvVarsextraction: Main extracted inline OIDC secret handlinginto a helper method, matching this PR pattern. Kept upstream version.
buildExternalAuthEnvVarsextraction: This PR refactoring of inline token exchangecode into a helper was kept since upstream still had it inline.
Type of change
Test plan
go test ./cmd/thv-operator/controllers/... ./pkg/runner/... ./pkg/transport/proxy/transparent/... ./cmd/thv-operator/pkg/controllerutil/...)go build ./...)Changes
api/v1alpha1/mcpexternalauthconfig_types.goDisableUpstreamTokenInjectiontoEmbeddedAuthServerConfigCRDpkg/authserver/config.goDisableUpstreamTokenInjectionto runtimeRunConfigcontrollerutil/authserver.gocontrollerutil/authserver_test.gopkg/runner/middleware.gostrip-authmiddleware; skip upstream swap when injection disabledpkg/runner/middleware_test.gocontrollers/mcpremoteproxy_runconfig.goresolveAndAddOIDCConfigcontrollers/mcpremoteproxy_deployment.gobuildExternalAuthEnvVarshelpercontrollers/mcpremoteproxy_controller_test.gocontrollers/mcpremoteproxy_reconciler_test.goproxy/transparent/transparent_proxy.goSetXForwarded()for remote upstreams; add debug loggingcrd-api.mdDisableUpstreamTokenInjectionDoes this introduce a user-facing change?
Yes. Two new capabilities for MCPRemoteProxy:
oidcConfig(andoidcConfigRef) to allow unauthenticated access to a proxied MCP server.disableUpstreamTokenInjection: trueon anembeddedAuthServerMCPExternalAuthConfig to authenticate clients without injecting tokens upstream.Generated with Claude Code