Skip to content

fix(oauth): keep the redirect guards when the caller supplies a client - #366

Open
Jaro-c wants to merge 1 commit into
developfrom
fix/force-redirect-policy
Open

Jaro-c wants to merge 1 commit into
developfrom
fix/force-redirect-policy

Conversation

@Jaro-c

@Jaro-c Jaro-c commented Sep 8, 2026

Copy link
Copy Markdown
Member

applyDefaults installed the safe client only when cfg.HTTPClient was nil, so a caller who supplied one kept their transport and lost CheckRedirect entirely.

Measured on develop at 6396536: applyDefaults(Config{HTTPClient: &http.Client{}}) returns CheckRedirect == nil, applyDefaults(Config{}) returns it set.

safeRedirect is four controls, not one:

  • a five hop cap
  • https on every redirect target
  • refusal of a loopback, private, link-local or unspecified address, which is the SSRF guard
  • refusal of a cross-origin redirect

All four went together, on every fetch the package makes: token exchange, JWKS, userinfo, discovery. The token exchange carries the client secret and receives the ID token, so those are the fetches that least tolerate an unguarded redirect.

A caller supplies a client for its transport, its timeout or its proxy. None of those intentions include turning off SSRF protection, and nothing told them it had happened. standards/security is explicit that the most restrictive configuration is the active one and that users opt in to looseness, never to safety.

The copy is the second half of the fix

applyDefaults now shallow copies a supplied client and forces the policy on the copy. Forcing it onto the caller own *http.Client would change how that client behaves everywhere else in their program, which is a different surprise rather than a fix. They keep Transport, Timeout and Jar, which is what they supplied it for.

Both halves are checked in the failing direction, separately: removing the forcing fails the guard test, and mutating the caller client instead of copying fails the no-mutation test.

isLoopbackHost, riding along

It matched three literals, so a development server bound to 127.0.0.2 to dodge a port collision was refused the documented plaintext exception. That fails closed, so it was a usability gap rather than a hole, but the package already had the right definition a few lines away: isPrivateHost uses net.IP.IsLoopback() and the two disagreed about what loopback means.

It uses net.IP.IsLoopback() now. The tests pin that the exception does not widen past loopback: 10.0.0.1, 192.168.1.1 and localhost.evil.com stay refused.

Provenance

Both found by a MiniMax-M3 pass over the package. It rated the client one a hardening suggestion rather than a defect, on the grounds that the behaviour is documented in a doc comment. I disagree and filed it as a defect: a one-line comment in a long file is not how a caller learns that four security controls just turned off, and the standard treats a disabled security default as a defect regardless of whether it is written down.

Closes #364
Closes #365

applyDefaults installed the safe client only when cfg.HTTPClient was
nil, so a caller who supplied one kept their transport and lost
CheckRedirect entirely. Measured before: applyDefaults with a supplied
client returned CheckRedirect nil, the default path returned it set.

safeRedirect is four controls, not one: a five hop cap, https on every
redirect target, refusal of a loopback, private, link-local or
unspecified address, and refusal of a cross-origin redirect. All four
went together, on the token exchange, JWKS, userinfo and discovery
fetches. The token exchange carries the client secret and receives the
ID token, so those are the fetches that least tolerate an unguarded
redirect.

A caller supplies a client for its transport, its timeout or its proxy.
None of those intentions include turning off SSRF protection, and
nothing told them it had happened. The security standard is explicit
that a caller opts in to looseness and never to safety.

applyDefaults now shallow copies a supplied client and forces the policy
on the copy. The copy is the second half of the fix: forcing it onto the
caller own *http.Client would change how that client behaves everywhere
else in their program, which is a different surprise rather than a fix.
They keep Transport, Timeout and Jar.

isLoopbackHost goes with it. It matched three literals, so a development
server on 127.0.0.2 was refused the documented plaintext exception. That
failed closed and was a usability gap, but the package already had the
right definition a few lines away in isPrivateHost, and the two
disagreed about what loopback means. It uses net.IP.IsLoopback now, and
the tests pin that the exception does not widen past loopback:
10.0.0.1, 192.168.1.1 and localhost.evil.com stay refused.

Both halves checked in the failing direction. Removing the forcing fails
the guard test; mutating the caller client instead of copying fails the
no-mutation test.

Closes #364
Closes #365

Signed-off-by: Jose <75870284+Jaro-c@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant