fix(oauth): refuse redirects on credential POSTs (#729)#741
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
🚧 Files skipped from review as they are similar to previous changes (5)
WalkthroughOAuth credential-bearing requests now reject redirects through a shared client wrapper. Token exchange, refresh, device authorization, and device polling return ChangesOAuth redirect safety
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant OAuthClient
participant OAuthEndpoint
participant RedirectTarget
OAuthClient->>OAuthEndpoint: Send credential-bearing POST
OAuthEndpoint-->>OAuthClient: Return 307 or 308 redirect
OAuthClient-->>RedirectTarget: Do not replay request
OAuthClient-->>OAuthClient: Return ErrUnsafeRedirect
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Zero automated PR reviewVerdict: No blockers found Blockers
Validation
ScopeHead: This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality. |
Token exchange/refresh and the device authorization/poll POSTs validated only the initial endpoint, then let the http.Client follow a 307/308 that replays the form body (code, PKCE verifier, refresh_token, client_secret) to an unvalidated origin. Add withoutRedirects(): a shallow client copy whose CheckRedirect refuses every redirect (ErrUnsafeRedirect). Applied at PostToken (covers ExchangeCode, Refresh, and MCP which delegate to it), RequestDeviceCode, and pollDeviceOnce. The caller's client is never mutated; legitimate token endpoints that return 200 are unaffected.
a5c2e28 to
0919a84
Compare
anandh8x
left a comment
There was a problem hiding this comment.
Reviewed the OAuth redirect hardening at 0919a84. The credential-bearing token exchange, refresh, device authorization, and device polling paths reject redirects without mutating the caller's HTTP client. Focused race tests for internal/oauth and internal/mcp pass, focused vet is clean, and CI is green. No blocking findings.
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Approving.
withoutRedirects shallow-copies the client and sets CheckRedirect to return ErrUnsafeRedirect, applied at all three credential POSTs: PostToken (which backs ExchangeCode/Refresh and the MCP delegations), RequestDeviceCode, and pollDeviceOnce. Go treats a CheckRedirect error as fatal and returns before issuing the follow-up request, so a 307/308 never replays the form body (codes, PKCE verifiers, refresh tokens, secrets), and it also blocks an HTTPS to HTTP downgrade. The caller's client is left unmutated, so a caller-supplied client can't smuggle the redirect back in. The regression tests are convincing: each asserts errors.Is(err, ErrUnsafeRedirect) AND that the attacker server got zero hits, which is exactly the replay threat.
Two non-blocking notes:
registerClientininternal/mcp/oauth.go:303POSTs without the same guard. It's outside #729's scope (the request body carries no pre-existing credential, just client metadata), but the response carries aclient_secret, so applyingwithoutRedirectsthere too would be good defense in depth. Fine as a follow-up.- This overlaps #738, which patches only the token endpoint. #741 is the broader fix (adds device-code + poll and 5 tests), so I'd land this one and close #738 in its favor. Merge order is your / kevin's call.
CI green, oauth and mcp packages green locally.
Summary
Closes #729.
OAuth token requests validated only the initial endpoint, then let the supplied or default
http.Clientfollow a307/308redirect that replays the POST body — authorization codes, PKCE verifiers, refresh tokens, client secrets, client IDs — to another origin (or an HTTPS→HTTP downgrade) that never passed endpoint validation.Affected paths
internal/oauth/flow.goPostToken— token exchange + refresh (and MCP OAuth login/refresh, which delegate tooauth.ExchangeCode/oauth.Refresh).internal/oauth/device.goRequestDeviceCode— the device authorization POST (client_id+client_secret).internal/oauth/device.gopollDeviceOnce— the device-token poll (device_code+client_secret).Fix
New
withoutRedirects(client)returns a shallow copy of the client whoseCheckRedirectrefuses every redirect (ErrUnsafeRedirect). Applied at all three credential POSTs beforeclient.Do. Because the copy is per-request, the caller's client is never mutated, and a legitimate token endpoint that returns200is unaffected (real token endpoints don't redirect).Scope
internal/oauthonly. No config or API change. No behavior change for non-redirecting endpoints. No performance change expected.Testing
PostToken,ExchangeCode,Refresh,RequestDeviceCode,pollDeviceOnce) using a redirect trap: a credential endpoint that307/308-redirects to an attacker server which counts hits. Each asserts the call returnsErrUnsafeRedirectand the attacker receives zero requests (the body was never replayed). All five were confirmed to fail without the fix (reverted the source, kept the tests).go test -race ./internal/oauth/... ./internal/mcp/...green.go build ./...,go vet ./...,gofmt,govulncheck,git diff HEAD --checkall clean.307-redirects to an attacker,zero auth login <provider> --devicenow fails withrefusing to follow a redirect from a credential endpoint(exit 1) and the attacker server receives 0 requests — whereas unpatchedmainreplays theclient_secret-bearing POST (attacker hit) and proceeds.Related
Pairs with #511 (validate discovered endpoints) — the other half of the OAuth endpoint-safety hardening. Independent branch; no stacking.
Summary by CodeRabbit
ErrUnsafeRedirecterror surfaced when redirects are blocked for token exchange, refresh, device authorization, and device polling.