fix: validate "from" redirect target in OAuth/verify flows#275
fix: validate "from" redirect target in OAuth/verify flows#275
Conversation
Coverage Report for CI Build 24744848855Coverage increased (+0.4%) to 84.66%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
There was a problem hiding this comment.
Pull request overview
This PR hardens the OAuth1/OAuth2/Apple and verify login flows against open-redirect attacks by validating the from redirect target before issuing the post-auth redirect.
Changes:
- Introduces a redirect-host allowlist abstraction (
token.AllowedHosts) and threads it throughauth.Opts→provider.Params/VerifyHandler. - Adds centralized redirect validation (
provider.isAllowedRedirect) and applies it to all affected providers before redirecting. - Adds unit/integration tests and updates README documentation describing the new behavior and configuration.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| v2/token/jwt.go | Adds AllowedHosts interface + AllowedHostsFunc adapter for allowlisted redirect hosts. |
| v2/provider/redirect.go | New shared helper to validate from redirect targets. |
| v2/provider/redirect_test.go | Unit tests covering redirect validation cases. |
| v2/provider/oauth1.go | Gates OAuth1 post-auth redirects via isAllowedRedirect. |
| v2/provider/oauth1_test.go | Adds regression test reproducing the open-redirect scenario. |
| v2/provider/oauth2.go | Adds Params.AllowedRedirectHosts and gates OAuth2 post-auth redirects. |
| v2/provider/oauth2_test.go | Adds regression + allowlist-positive tests for OAuth2 from redirect handling. |
| v2/provider/apple.go | Gates Apple post-auth redirects via isAllowedRedirect. |
| v2/provider/apple_test.go | Adds regression test reproducing the open-redirect scenario. |
| v2/provider/verify.go | Adds URL/AllowedRedirectHosts to VerifyHandler and gates redirects. |
| v2/provider/verify_test.go | Adds regression + allowlist-positive tests for verify flow. |
| v2/auth.go | Wires Opts.AllowedRedirectHosts into provider params and verify handler. |
| README.md | Documents new “Allowed redirect hosts” behavior and API notes for from. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func isAllowedRedirect(from, serviceURL string, allowed token.AllowedHosts) bool { | ||
| u, err := url.Parse(from) | ||
| if err != nil || u.Host == "" { | ||
| return false | ||
| } | ||
| if svc, sErr := url.Parse(serviceURL); sErr == nil && svc.Host != "" && svc.Host == u.Host { | ||
| return true |
| p.Logf("[WARN] rejected redirect to disallowed host: %s", oauthClaims.Handshake.From) | ||
| rest.RenderJSON(w, &u) |
| h.Logf("[WARN] rejected redirect to disallowed host: %s", oauthClaims.Handshake.From) | ||
| rest.RenderJSON(w, &u) |
| // redirect to back url if presented in login query params | ||
| if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" { | ||
| if !isAllowedRedirect(oauthClaims.Handshake.From, ah.URL, ah.AllowedRedirectHosts) { | ||
| ah.Logf("[WARN] rejected redirect to disallowed host: %s", oauthClaims.Handshake.From) |
| e.Logf("[WARN] rejected redirect to disallowed host: %s", confClaims.Handshake.From) | ||
| rest.RenderJSON(w, claims.User) |
Address Copilot review on PR #275: * isAllowedRedirect compared u.Host (which includes port). A from URL like https://app.example.com:443/x against an Opts.URL of https://app.example.com would be rejected even though they are the same origin. Switch to u.Hostname() so the explicit-default-port form matches, and add three test cases (https:443, http:80, non-default :8080). * The rejection log lines wrote the full from URL, leaking attacker-supplied paths/query strings into operator logs. Add redirectHostForLog helper (parses and returns just the hostname, sentinel on parse failure) and use it from all four sites (oauth1/oauth2/apple/verify). Covered by TestRedirectHostForLog.
Per maintainer review on PR #275: a dependency bump must not silently change behaviour for existing consumers. Flip the default so that a nil Opts.AllowedRedirectHosts means "no host check" (preserves pre-feature behaviour); the validator only runs when the consumer explicitly sets the field. When non-nil: * Opts.URL host is always implicit (single-host deployments enable the policy with `func() ([]string, error) { return nil, nil }`). * Hostname comparison ignores port (https://x and https://x:443 match). * Relative/unparseable URLs are rejected. Test table reorganised to cover the permissive default explicitly and to mark every "policy on" case with a non-nil allowlist. Integration tests in oauth1/oauth2/apple/verify get a paramOpts variadic on their setup helpers so the rejection tests can flip the policy on. README rewritten to make the opt-in nature clear.
|
Pushed two fixup commits addressing review:
CI is running. |
umputun
left a comment
There was a problem hiding this comment.
nice fix. a few things before merge:
-
new lint finding —
v2/provider/oauth2_test.go:301:13introduces a G101 gosec issue on the inlineoauth2.Endpoint{AuthURL:..., TokenURL:...}string literals. Either//nolint:gosec // G101: test URL, not a credentialor build the URLs viafmt.SprintflikeprepOauth2Testdoes. -
case-insensitive hostname compare —
v2/provider/redirect.go:44usesh == fromHost(byte-exact). DNS hostnames are case-insensitive, andcheckAudsin this same repo already usesstrings.EqualFold(v2/token/jwt.go:384).App.Example.Cominfromwould be rejected even if the allowlist hasapp.example.com. Recommendstrings.EqualFoldfor consistency. Worth a test case alongside it. -
scheme allowlist — Copilot's second point wasn't addressed. In practice
Hostname() == ""filtersjavascript:/data:, so the attack surface is narrow, but explicitu.Scheme == "http" || u.Scheme == "https"closes it for defense-in-depth. Low priority if you'd rather leave it. -
typed-nil interface pitfall —
var fn provider.AllowedHostsFunc; opts.AllowedRedirectHosts = fnproduces a non-nil interface wrapping a nil func, soallowed == nilis false andallowed.Get()panics. Rare-consumer-error, but a one-line guard inisAllowedRedirect(or just a godoc note onAllowedHostsFunc) would be safer.
question: is there a concrete dynamic-allowlist use case driving the AllowedHosts interface, or is this purely parity with Audience? []string would be simpler if every caller passes a static list — but the parity argument is fine if you want it.
stale-base note: the README CrossOriginProtection section and go.mod downgrades in the diff are stale-base artifacts from #273/#274 landing on master — they'll disappear on rebase, not real changes.
The "from" query parameter accepted by oauth1/oauth2/apple/verify login handlers was stored verbatim in the handshake JWT and used as the redirect target after a successful auth handshake with no validation. Any external URL passed as "from" became a 307 redirect after the user completed the real OAuth flow with the legitimate provider — usable for phishing and post-auth landing-page substitution. Add token.AllowedHosts (interface) and AllowedHostsFunc (adapter), mirroring the existing token.Audience pattern. Expose Opts.AllowedRedirectHosts and thread it through provider.Params and VerifyHandler. The provider's own URL host is always permitted; additional hosts are opt-in via the allowlist. Default (nil allowlist) accepts only the same host as Opts.URL — safe for all existing single-host deployments without any caller code changes. Centralise the policy in provider.isAllowedRedirect(); the four redirect sites all gate on it and fall back to the existing JSON user-info response on rejection (with a [WARN] log).
Add an attack-scenario doc block to TestOauth2LoginFromRejectsExternalHost spelling out the pre-fix flow (attacker-crafted login URL → legitimate OAuth consent → 307 to attacker site). Same pattern applies to oauth1, apple and verify; the comment names them explicitly so a future reader sees what the regression test is guarding against.
Address Copilot review on PR #275: * isAllowedRedirect compared u.Host (which includes port). A from URL like https://app.example.com:443/x against an Opts.URL of https://app.example.com would be rejected even though they are the same origin. Switch to u.Hostname() so the explicit-default-port form matches, and add three test cases (https:443, http:80, non-default :8080). * The rejection log lines wrote the full from URL, leaking attacker-supplied paths/query strings into operator logs. Add redirectHostForLog helper (parses and returns just the hostname, sentinel on parse failure) and use it from all four sites (oauth1/oauth2/apple/verify). Covered by TestRedirectHostForLog.
Per maintainer review on PR #275: a dependency bump must not silently change behaviour for existing consumers. Flip the default so that a nil Opts.AllowedRedirectHosts means "no host check" (preserves pre-feature behaviour); the validator only runs when the consumer explicitly sets the field. When non-nil: * Opts.URL host is always implicit (single-host deployments enable the policy with `func() ([]string, error) { return nil, nil }`). * Hostname comparison ignores port (https://x and https://x:443 match). * Relative/unparseable URLs are rejected. Test table reorganised to cover the permissive default explicitly and to mark every "policy on" case with a non-nil allowlist. Integration tests in oauth1/oauth2/apple/verify get a paramOpts variadic on their setup helpers so the rejection tests can flip the policy on. README rewritten to make the opt-in nature clear.
Address Copilot and maintainer review comments on PR #275: * Hostname compare is case-insensitive (strings.EqualFold), mirroring checkAuds in token/jwt.go. DNS hostnames are case-insensitive, so App.Example.Com in "from" now matches app.example.com in the allowlist or service URL. Covered by three new TestIsAllowedRedirect cases. * Scheme check: only http/https are accepted when the policy is on. Hostname()=="" already filters javascript:/data:/etc. in practice, but an explicit check is defence-in-depth. New test cases cover javascript, ftp and data scheme rejection. * Typed-nil guard: a nil AllowedHostsFunc assigned to the interface field produces a non-nil interface wrapping a nil func, which would panic in Get(). Detect the common adapter case and treat it as "no allowlist configured". Documented on AllowedHostsFunc godoc. New test case exercises the typed-nil path. * TestOauth2LoginFromAllowsAllowlistedHost now reuses prepOauth2Test via its paramOpts hook instead of building a parallel mock server, removing the G101 gosec hit on the inline oauth2.Endpoint literals (flagged by umputun) and cutting ~60 lines of duplication.
d8c4fc1 to
dddfe76
Compare
There was a problem hiding this comment.
Pull request overview
Adds redirect-target validation for the from parameter used across OAuth1/OAuth2/Apple and Verify login flows to mitigate open-redirect/phishing risks by introducing an allowlist-based policy.
Changes:
- Introduces
token.AllowedHosts/AllowedHostsFuncand threadsAllowedRedirectHoststhroughauth.Opts,provider.Params, andVerifyHandler. - Adds centralized redirect validation helpers (
isAllowedRedirect,redirectHostForLog) and gates redirects in all affected providers. - Adds unit/integration tests covering rejection/allowlisting behavior and documents the feature in
README.md.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| v2/token/jwt.go | Adds AllowedHosts + AllowedHostsFunc config-getter pattern. |
| v2/auth.go | Wires Opts.AllowedRedirectHosts into provider params and verify handler. |
| v2/provider/redirect.go | Implements shared redirect-host validation + safe logging helper. |
| v2/provider/redirect_test.go | Adds table-driven coverage for redirect validation behavior. |
| v2/provider/oauth1.go | Gates post-auth redirect via isAllowedRedirect. |
| v2/provider/oauth1_test.go | Adds reproduction test for rejecting external from redirects. |
| v2/provider/oauth2.go | Adds Params.AllowedRedirectHosts and gates redirect via helper. |
| v2/provider/oauth2_test.go | Adds negative/positive redirect-host tests; updates helper setup. |
| v2/provider/apple.go | Gates post-auth redirect via isAllowedRedirect. |
| v2/provider/apple_test.go | Adds reproduction test for rejecting external from redirects. |
| v2/provider/verify.go | Adds URL/AllowedRedirectHosts to handler and gates redirect. |
| v2/provider/verify_test.go | Adds allowlist rejection/acceptance tests for verify flow. |
| README.md | Documents “Allowed redirect hosts” and updates endpoint docs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // parameter of OAuth/verify login flows. The host of URL is always | ||
| // allowed implicitly. Nil means same-host-only. |
|
|
||
| // AllowedRedirectHosts lists hostnames accepted in the "from" query | ||
| // parameter of OAuth/verify login flows. The host of URL is always | ||
| // allowed implicitly. Nil disables additional hosts (same-host only). |
| // a "from" redirect target. Optional but recommended. | ||
| URL string | ||
| // AllowedRedirectHosts lists additional hostnames permitted as "from" | ||
| // redirect targets. Nil means same-host-only. |
| For the example above authentication handlers wired as `/auth` and provides: | ||
|
|
||
| - `/auth/<provider>/login?site=<site_id>&from=<redirect_url>` - site_id used as `aud` claim for the token and can be processed by `SecretReader` to load/retrieve/define different secrets. redirect_url is the url to redirect after successful login. | ||
| - `/auth/<provider>/login?site=<site_id>&from=<redirect_url>` - site_id used as `aud` claim for the token and can be processed by `SecretReader` to load/retrieve/define different secrets. redirect_url is the url to redirect after successful login. Only redirect targets whose host matches `Opts.URL` or appears in `Opts.AllowedRedirectHosts` are honoured; others fall back to the user-info JSON response. See "Allowed redirect hosts" below. |
| The API for this provider: | ||
|
|
||
| - `GET /auth/<name>/login?user=<user>&address=<address>&aud=<site_id>&from=<url>` - send confirmation request to user | ||
| - `GET /auth/<name>/login?user=<user>&address=<address>&aud=<site_id>&from=<url>` - send confirmation request to user. The `from` URL is subject to the same allowlist check described in "Allowed redirect hosts". |
| // path with the default (nil) allowlist, where service URL is "url" — so any | ||
| // real external host is refused. |
umputun
left a comment
There was a problem hiding this comment.
all four items from my last pass landed cleanly — strings.EqualFold, explicit http/https scheme check, typed-nil AllowedHostsFunc guard, and the prepOauth2Test/paramOpts refactor that cuts ~60 lines and drops the G101. nice.
on Copilot's new six comments: they're all one issue — when you flipped semantics in 975bb1f to opt-in, redirect.go's docstring got updated but the downstream field godoc and README endpoint docs still describe the old "nil = same-host-only" behaviour. concretely:
v2/auth.go:68—Opts.AllowedRedirectHostssays "Nil means same-host-only"v2/provider/oauth2.go:47—Params.AllowedRedirectHostssays "Nil disables additional hosts (same-host only)"v2/provider/verify.go:36—VerifyHandler.AllowedRedirectHostssays "Nil means same-host-only"README.md:185— reads as if validation is always on ("Only redirect targets whose host matchesOpts.URLor appears inOpts.AllowedRedirectHostsare honoured")README.md:294— verify endpoint claimsfromis "subject to the same allowlist check" with no opt-in caveatv2/provider/oauth2_test.go:268— test docblock says "with the default (nil) allowlist" but the test wires non-nil viaenablePolicy
all pure doc/comment fixes — one pass aligning the field godoc and README to the opt-in semantics that redirect.go:13-16 already spells out correctly. then good to merge.
Addresses the six doc/comment drift items Copilot flagged and umputun confirmed in his 2026-04-21 review: when 975bb1f flipped the default to "nil = no validation (permissive legacy)", the validator docstring in redirect.go was updated but the downstream field godoc, README endpoint bullets and one test docblock still read as if nil means "same-host-only". * v2/auth.go:66 — Opts.AllowedRedirectHosts * v2/provider/oauth2.go:45 — Params.AllowedRedirectHosts * v2/provider/verify.go:35 — VerifyHandler.AllowedRedirectHosts * README.md:185 — OAuth endpoint bullet * README.md:294 — verify endpoint bullet * v2/provider/oauth2_test.go:268 — TestOauth2LoginFromRejectsExternalHost docblock (test wires non-nil via enablePolicy; calling it "default (nil) allowlist" was misleading) No code changes — redirect.go's policy as implemented stays authoritative.
…2049) * fix(auth): close OAuth open-redirect by wiring AllowedRedirectHosts Bump go-pkgz/auth/v2 to master (v2.1.2-0.20260421203319-686683f19cf7) which carries the `from` redirect validator from go-pkgz/auth#275. The library default with a nil AllowedRedirectHosts is permissive (preserves legacy behavior for existing consumers on a dep bump), so just bumping the dep leaves remark42 vulnerable — a crafted /auth/<provider>/login?from=https://evil.example.com/... still issues the 307 to the attacker host after the user completes legitimate OAuth. Verified end-to-end against a local dev-auth instance before and after this commit. Wire Opts.AllowedRedirectHosts in getAuthenticator to the operator's existing --allowed-hosts config, stripping the CSP "self" sentinel which is not a real hostname. RemarkURL's own host is always implicit per the library contract, so a default single-site deployment gains the protection with no config change. Multi-host embeds work as soon as their embedding hosts are added to AllowedHosts (they already need to be there for CSP frame-ancestors). Refreshed vendor tree to match the new module version. * chore(lint): suppress G703 false positives on image Save CI's newer gosec flags os.MkdirAll/os.WriteFile in FileSystem.Save with G703 because id flows in from the caller. id is validated at the HTTP layer (safePictureSegment in rest_public.go) and dst is derived via f.location — not a real traversal. Targeted //nolint with reason. * fix(auth): normalise AllowedRedirectHosts entries + add unit test Address Copilot review on PR #2049. The previous closure passed raw s.AllowedHosts entries straight to the auth library, but --allowed-hosts holds CSP frame-ancestors source expressions: scheme-prefixed values (https://blog.example.com), entries with ports, and wildcards (*.cdn.example.com) are all valid there but the auth library compares against u.Hostname() and would silently drop them — breaking legitimate redirects on multi-host deployments. Extract getAllowedRedirectHosts that: * trims whitespace, drops empty / 'self' / "self" / wildcard entries * prepends https:// if scheme missing then url.Parse to extract Hostname * logs a warning on parse failure rather than poisoning the allowlist Wire the closure in getAuthenticator to call the helper. Test_getAllowedRedirectHosts covers all the edge cases Copilot flagged (scheme stripping, port handling, self spellings, wildcards, empty, mixed real-world). * fix(auth): preserve explicit port in AllowedRedirectHosts + clarify fs_store nolint Address Copilot follow-up on PR #2049: * getAllowedRedirectHosts stripped explicit ports via u.Hostname(), which broadened the allowlist. The auth validator checks both Hostname() and Host, so an entry like admin.example.com:8443 can and should be kept host:port — allowing only that port, not any. Emit u.Host when u.Port() != "", u.Hostname() otherwise. Updated tests. * fs_store Save nolint rationale said "id validated at HTTP layer", but Save is reached via image.Service.Save and SaveWithID (cache), neither of which is HTTP validation. id is actually a server-generated hash in both paths. Updated the comment.
The
fromquery parameter accepted byoauth1,oauth2,appleandverifylogin flows tells the server where to send the user after a successful auth handshake. Before this PR the value was stored verbatim in the handshake JWT and used as the redirect target with no validation — any external URL fits, so a crafted login link could route a user through legitimate provider OAuth and then bounce them to an attacker-controlled landing page.Attack
Victim sees the real GitHub consent screen and approves. Provider then calls back to the legitimate AuthHandler, which (pre-fix) executes:
The 307 lands the user on
evil.example.com/phish, carrying a fresh session cookie and the trust the user had in the legitimate brand.Fromis JWT-signed so it cannot be tampered mid-flow — but the initial link is attacker-controlled and any URL fits inside the JWT.The same pattern lives in all four providers:
oauth1.go:166,oauth2.go:242,apple.go:396,verify.go:130.Fix
token.AllowedHostsinterface +token.AllowedHostsFuncadapter — mirrors the existingAudience/AudienceFuncpattern for consistency.Opts.AllowedRedirectHostsfield threaded throughprovider.Params(and added directly toVerifyHandlersince it doesn't embedParams).provider.isAllowedRedirecthelper — relative URLs and unparseable URLs are rejected; the host ofOpts.URLis always permitted; additional hosts are opt-in viaAllowedRedirectHosts.[WARN]log naming the disallowed host).Default behaviour (nil allowlist) accepts only the same host as
Opts.URL— safe for every existing single-host caller without any config change.For multi-host deployments, callers register additional hosts:
Reproduction
TestOauth2LoginFromRejectsExternalHostinprovider/oauth2_test.goexercises the full handler withfrom=https://evil.example.com/phishand asserts no external redirect is attempted (response is the JSON user info). The test docblock spells out the attack scenario for future readers. Same pattern repeated inoauth1_test.go,apple_test.go,verify_test.go.TestOauth2LoginFromAllowsAllowlistedHostcovers the positive case (allowlisted host gets the 307).TestIsAllowedRedirectinprovider/redirect_test.gocovers the validator with 14 cases: empty/relative/unparseable URLs, same-host with various scheme/port combinations, subdomain non-matching, allowlist hits and misses, getter errors.Verification
go vetclean. New code passesgolangci-lint(existing pre-PR findings intelegram.go,providers.go,apple.goare unchanged and unrelated).Files
v2/token/jwt.go— newAllowedHosts/AllowedHostsFuncv2/provider/redirect.go(new) —isAllowedRedirecthelperv2/provider/redirect_test.go(new) — 14 unit casesv2/provider/oauth1.go+oauth1_test.go— gate + reproduction testv2/provider/oauth2.go+oauth2_test.go—Params.AllowedRedirectHostsfield, gate + 2 testsv2/provider/apple.go+apple_test.go— gate + reproduction testv2/provider/verify.go+verify_test.go—URL/AllowedRedirectHostsfields onVerifyHandler, gate + 2 testsv2/auth.go— wireOpts.AllowedRedirectHostsinto all 6provider.Paramsconstructions and intoVerifyHandlerREADME.md— new "Allowed redirect hosts" section + notes on the twofrom=API entriesReported via private security audit of remark42 (which vendors this library); confirmed by code inspection here and against the live reference deployment.