Guard OAuth token and DCR endpoints against SSRF - #6351
Merged
Conversation
A token endpoint named by a remote MCP server, or by a metadata document pointing at an authority the operator never configured, was dialed by an unguarded HTTP client. That let a malicious server steer the exchange at an internal address and follow redirects off-host (CWE-918). Route every exchange and refresh through a client whose policy follows who supplied the URL: an operator-configured authority - or an issuer naming its own authority in its own metadata - may be private or loopback, anything else is refused at dial time and may not redirect cross-host. Trust is derived after the discovered-endpoint override, so the flag always describes the URL actually dialed, and discovery clears it when the document names a different authority. The unguarded default client is gone: the refresher and resource token source now require an explicit one. GHSA-3768-rwj3-38p2 Reported by kta1kri.
jhrozek
requested review from
ChrisJBurns,
JAORMX,
amirejaz,
aponcedeleonch,
blkt,
rdimitrov,
reyortiz3 and
tgrunnagle
as code owners
August 17, 2026 15:01
Dynamic client registration reached endpoints named by a remote server under the policy meant for operator-configured hosts, where being loopback is itself a permission and INSECURE_DISABLE_URL_VALIDATION widens the private-IP gate. A registration POST carries the initial access token, so a metadata-named host could both be probed internally and handed that credential (CWE-918). Keep the private-IP restriction for endpoints whose authority the operator did not name, including loopback ones, while an operator-configured upstream keeps working through the existing private-IP opt-in. The provenance decision lives in the resolver: a registration endpoint read out of a fetched metadata document is treated as server-supplied even when the operator chose the discovery URL, so no caller can forget to say so. GHSA-3768-rwj3-38p2 Reported by kta1kri.
jhrozek
force-pushed
the
fix/ghsa-3768-ssrf-guard
branch
from
August 17, 2026 15:45
cfda7ed to
0b7ba76
Compare
JAORMX
approved these changes
Aug 17, 2026
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An authorization-server metadata document served by a remote MCP server is untrusted input, but two outbound paths dialed endpoints out of it without an SSRF guard (
GHSA-3768-rwj3-38p2, CWE-918):http.DefaultClient.Flow.handleCallbackset nooauth2.HTTPClienton the context, sogolang.org/x/oauth2fell back to the default client — no dial guard, no redirect policy. Atoken_endpointpointing at a loopback address received a POST containing the authorization code and PKCE verifier, and a302from there was followed off-host. The same poisonedTokenURLpersisted into the run config and was reused on every later refresh.networking.NewHostScopedClientBuilderOR'dIsLocalhost(host)andINSECURE_DISABLE_URL_VALIDATIONinto the private-IP gate, so aregistration_endpointofhttp://127.0.0.1:PORT/xpassed validation and then got an unguarded DCR POST carrying the initial access token — server-side, with no user in the loop.The distinction that fixes both is who supplied the URL, not what it resolves to. An operator who configures dex at
127.0.0.1has decided to trust it; a host that arrived inside a document a remote server served us has not been trusted by anyone.TargetIsPrivate-style gating cannot tell those apart, which is why the loopback waiver was correct on the operator path and a defect on the DCR path.What changed:
oauth.NewTokenHTTPClientbuilds it once inNewFlowand it is injected at the exchange and all four refresh sinks. Untrusted endpoints get the private-IP dial guard; every token client getsSameHostRedirectPolicyandDisableKeepAlivesso the check re-runs per request and cannot be walked off-host by a redirect.networking.AuthorityMatchesAnycompares an endpoint's authority against the URLs the operator configured. An authorization server naming its own authority in its own metadata stays covered by the operator's decision to configure it; naming any other authority does not.discovery.gocan only ever clear the flag when it overwritesTokenURL, never set it, so a trust decision can't outlive the URL it was made about.networking.NewServerSuppliedHostClientBuilderapplies the same policy minus the loopback and env-var waivers on the private-IP gate. Inpkg/auth/dcrthe provenance decision lives in the resolver, so aregistration_endpointthat names an unconfigured authority is strict even when the operator chose theDiscoveryURL— no caller can forget to say so.NewNonCachingRefresherandNewResourceTokenSourcenow require an explicit*http.Clientinstead of falling back to an unguarded one.Reported by
kta1kri.Type of change
Test plan
task test)task test-e2e)task lint-fix)Every new assertion is about whether a listener received a request, not about the value of a trust flag — flag-value assertions are what let the first cut of this fix pass while the behavior was wrong.
pkg/auth/oauth— an untrusted loopback token endpoint is refused at the private-IP guard with zero hits on the listener; a trusted one completes the exchange (the dex / Keycloak-in-Docker non-regression case); a trusted endpoint's cross-host302leaves the redirect target at zero hits witherrors.Is(err, ErrRedirectRefused).pkg/auth/discovery— an operator-configured issuer whose metadata advertisestoken_endpointon a different authority loses the trust; one naming its own authority keeps it.pkg/auth/dcr— a metadata-namedregistration_endpointon an unconfigured authority is refused and never receives the initial access token; an upstream naming its own authority still registers. Verified the first test fails when the guard is neutered (foreignHits= 1).pkg/networking—AuthorityMatchesAnytable; two real dials proving the server-supplied builder refuses loopback by default and permits it underallowPrivateIPs; a case pinning thatINSECURE_DISABLE_URL_VALIDATIONno longer widens the private-IP gate.cmd/thv/app— the flow-config builder is now extracted and asserted directly, because thethv proxytrust wiring was previously unreachable from tests.Not verified: no kind/e2e run.
thv proxy --remote-auth-issuer http://localhost:PORT/... <public-mcp-url>against a real dex is the one shape that resists unit testing, and it is worth a manual check before merge.Changes
pkg/networking/utilities.goAuthorityMatchesAny— the operator-provenance testpkg/networking/http_client.goNewServerSuppliedHostClientBuilder; corrected the stale "single source of truth" claim on the host-scoped builderpkg/auth/oauth/flow.goNewTokenHTTPClient; guarded client built inNewFlowand injected at the exchange and refreshpkg/auth/oauth/non_caching_refresher.go*http.Clientis now required; unguarded fallback removedpkg/auth/oauth/resource_token_source.gopkg/auth/discovery/discovery.goTokenEndpointTrusted/IssuerTrusted; clears trust when a discovered endpoint changes authoritypkg/auth/remote/handler.gopkg/auth/remote/persisting_token_source.gopkg/auth/tokensource/tokensource.gopkg/auth/dcr/request.goServerSuppliedEndpoints, documenting what it does and does not coverpkg/auth/dcr/resolver.gocmd/thv/app/proxy.goDoes this introduce a user-facing change?
Yes, one narrowing:
INSECURE_DISABLE_URL_VALIDATIONno longer widens the private-IP gate for endpoints supplied by a remote server or its metadata. It still relaxes the HTTPS-scheme requirement as before. Operators who relied on it to reach a private IdP should configure that IdP's issuer or token URL explicitly, or set the existingallowPrivateIPsoption on the upstream.Operator-configured localhost and in-cluster IdPs (dex, Keycloak-in-Docker,
10.x/*.internal) keep working, including on the DCR path — there are non-regression tests for each.Special notes for reviewers
Scope exception. This is 12 non-test files against the 10-file guidance in
CLAUDE.md. The two defects were triaged as separable and the original plan was two stacked PRs, but the fix for the second one usesAuthorityMatchesAny, introduced by the first — splitting now would mean either duplicating that helper or landing a DCR fix that cannot express its own trust test. Happy to split if you would rather review them apart.Where to look hardest:
discovery.go— the "clear but never set" rule onTokenEndpointTrustedis what stops a trust decision outliving the URL it described. An earlier cut of this fix computed the flag before the discovered-endpoint override and reopened the vulnerability on the configured-issuer + DCR path.resolver.go— provenance is per-endpoint, not per-request: theDiscoveryURLcan be operator-supplied while theregistration_endpointit yields is not. An earlier cut treated every metadata-derived endpoint as strict, which broke operator-configured loopback IdPs.Follow-up, not in scope:
canonicalAuthorityretains a bare-authority (host:port) parsing branch that no current caller reaches;pkg/vmcp/auth/strategiesperformsclient_credentialsgrants onhttp.DefaultClientwith operator-supplied token URLs — not this vulnerability's class, but the non-interactive server-side path worth hardening next.🤖 Generated with Claude Code