Repository navigation
fix(oauth): reject userinfo and backslash in implicit redirect URIs - #377
Conversation
validateClient approved a redirect target by the host url.Parse reported, which is not always the host a browser dials. Two shapes exploit that gap: https://evil.com@claude.ai/cb parses as claude.ai, passes the allowlist http://evil.com@localhost/cb parses as localhost, passes as loopback Anything that splits on the first '@', or simply shows the URL to a user, reads evil.com. A backslash is worse — it is not a valid URI character and parsers disagree on whether it terminates the authority — so it is rejected before parsing rather than after. This is reachable in production: OAUTH_ALLOW_IMPLICIT_CLIENT defaults to true and is unset on the deployed pod, so the implicit-client branch is live and its host allowlist is the only thing standing between an unregistered caller and an arbitrary redirect target. Also compares the loopback host case-insensitively. Host names are case-insensitive per RFC 3986 §3.2.2 but url.Parse preserves the case it was given, so a client emitting LOCALHOST was turned away. Registered clients are unaffected — they match their own exact URIs.
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @mashkovd's task in 2m 43s —— View job Review complete — changes requested
Note: Finding (1 P1)The hardening (backslash rejection + userinfo rejection) is added only to Because
So the exact host-spoofing shape this PR sets out to close is still reachable one hop later, via self-registration. Suggest factoring the backslash/userinfo checks into a shared helper used by both functions, and adding register-path test coverage for the same nine rejection shapes. Full details and a fix link are in the inline comment. Everything else in the diff looks solid: the Verdict: REQUEST_CHANGES (1 P1, 0 P2, 0 P3). |
Antigravity (agy) review — informationalModel: gemini-3.1-pro-high. Severity: P1 Severity: P2 Severity: P2 |
There was a problem hiding this comment.
Has P1/P2 findings, changes requested: 1 P1. The new backslash/userinfo checks are added only to validateClient's implicit-client fallback branch, but the sibling function validateImplicitRedirectURI — used to gate POST /oauth/register and documented as applying "the same policy" — was not updated. Since validateClient exact-matches already-registered redirect_uris without re-running the host/userinfo/backslash checks, an attacker can self-register the same userinfo-spoofed URI (e.g. https://evil.com@claude.ai/cb) that this PR set out to block, and it will be accepted verbatim at /oauth/register, then sail through /oauth/authorize via the exact-match path. See inline comment for details and a suggested fix (shared helper + test coverage for the register path).
The first version of this hardening only tightened the implicit branch of validateClient, which left the door it was meant to close still open: POST /oauth/register goes through validateImplicitRedirectURI, a near-identical copy that kept accepting the spoofed shapes. A caller could register https://evil.com@claude.ai/cb there, and validateClient's exact-match path for registered clients would then wave it through without re-running any host check at all. Rather than copy the checks into the second function, validateClient's implicit branch now delegates to validateImplicitRedirectURI. The duplication is what allowed the two to drift in the first place, so this removes it: one policy, one implementation, both doors. Found by claude[bot] and agy independently on #377.
|
Claude finished @mashkovd's task in 1m 23s —— View job Review complete — approved
Note: As with the previous review pass, What changed since the last review passThe latest commit ( Concretely, this closes the exact bypass previously demonstrated: Test coverage matches: On the P2 case-sensitivity note from the Antigravity review
VerdictNo P1/P2 findings. Approved. |
There was a problem hiding this comment.
No P1/P2 findings (0 P3). The P1 flagged in earlier reviews (validateImplicitRedirectURI, which gates POST /oauth/register, missing the backslash/userinfo checks and therefore bypassable via self-registration) is resolved: validateClient's implicit-client fallback now delegates directly to validateImplicitRedirectURI instead of carrying a duplicate copy of the checks, so both entry points enforce identical policy. New test TestValidateImplicitRedirectURIRejectsTheSameShapes exercises the registration-path function directly with the same nine rejection shapes. Good to merge.
Antigravity (agy) review — informationalModel: gemini-3.1-pro-high. Severity: P1 Concrete failure scenario: An attacker can make a POST request to Proposed fix: |
|
Skipping — this commit (41a7ef5) already has a Claude review verdict. Push a new commit, or check the run log if you expected a fresh pass and the previous run actually failed. |
Antigravity (agy) review — informationalModel: gemini-3.1-pro-high. Here is a review of the provided diff. Finding 1: Validation bypassed because it is never called during registration
Finding 2: Missing integration test coverage hides the registration bypass
|
What
validateClientdecides whether a redirect target is acceptable from the hosturl.Parsereports. That is not always the host a browser will dial, and two shapes exploit the gap:Anything that splits on the first
@— or simply renders the URL to a user — readsevil.com. A backslash is worse still: not a valid URI character, and parsers disagree on whether it terminates the authority, so it is rejected before parsing rather than after.Also makes the loopback host comparison case-insensitive (RFC 3986 §3.2.2);
url.Parsepreserves case, so a client emittingLOCALHOSTwas turned away.This is live, not theoretical
OAUTH_ALLOW_IMPLICIT_CLIENTdefaults to true (internal/config/config.go:205) and is unset on the deployed pod — I checkedlabs-mctl-telegram-base-servicedirectly. So the implicit-client branch is active in production, and its host allowlist is the only thing between an unregistered caller and an arbitrary redirect target.The code binds the authorization code to
redirect_uriat/oauth/tokenand requires PKCE, so a hijacked code is not directly redeemable by a passive attacker — but an attacker who starts the flow picks the challenge themselves, and the comment abovevalidateClientalready recognises that briefly shipping a code to an attacker-controlled host is the thing worth preventing.How this came up
Auditing this server against three defects just fixed in mctl-api (#156/#157). None of them are present here — this OAuth implementation is markedly more careful:
writeAuthorizeErrorwrites JSON 400 and never setsLocation, so the open redirect that mctl-api had cannot arisevalidateClienthas always takenclientIDand scoped registered URIs to their own client — the cross-client hole mctl-api hadOnly the parser-disagreement hardening was missing, which is what this PR adds.
Testing
go vetandgo test ./...clean. New tests pin nine rejection shapes (userinfo in both allowlist and loopback form, raw and encoded backslash, suffix tricks, http on a non-loopback host) plus the legitimate targets that must keep working, and assert registered-client scoping is untouched. Verified they fail without the change:Left alone deliberately
The
AllowedImplicitHostscomparison is still case-sensitive, sohttps://CLAUDE.AI/cbis refused. Same RFC 3986 point as the loopback fix, but changing it widens what the allowlist accepts, so it wants its own decision rather than riding along in a hardening PR.