fix(users): thread next through OIDC login + open-redirect hardening (bookshelf-bxtat) #1392
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-bxtat"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
SSO login previously ignored the
?next=return-to destination — clicking adeep link while logged out, then signing in via SSO, always landed on
/instead of the originally-requested page. Local login already worked
correctly.
Root cause
loginPageHandler(OIDC-only auto-redirect) andloginHandler(credential-POST guard) redirected to
/auth/oidc/loginwithoutforwarding
next.login.htmllinked to/auth/oidc/loginwith no
next.oidcLoginHandlernever capturednext— only state/nonce/PKCE cookies.oidcCallbackHandlerhardcoded the post-login redirect to/.Fix
safeNextPath()helper, used by BOTH local and OIDCpaths. This also closes a latent open-redirect gap in local login: the old
inline check missed the
/\evil.combackslash variant that some browsersnormalize to
//before following a redirect.nextis now forwarded through all 3 handoff points into/auth/oidc/login?next=<validated>.oidcLoginHandlerstores the validatednextin a new short-livedbookshelf_oidc_nextcookie (samePath=/auth/oidc,HttpOnly,Secure,SameSite=Lax, TTL as the existing state/nonce/verifier cookies).oidcCallbackHandlerre-validates the cookie value (defense in depth) andredirects there instead of
/, then clears the cookie. Falls back to/when the cookie is absent, empty, or hostile.
Test plan
safeNextPathtable-driven unit tests (same-origin path preserved;//evil.com,/\evil.com,https://evil.com, empty →/).oidcLoginHandler/oidcCallbackHandlerunit tests: cookie is set from avalidated
nextquery param, hostile values fall back to/, callbackredirects to the stored (re-validated) next and clears the cookie.
nexthandler tests remain green.e2e/api/journey_9_oidc_login_test.gowith a full round-tripstep:
GET /auth/oidc/login?next=/books/123→ fake IdP → callback lands on/books/123.docs/content/docs/accounts-access/signing-in.mdupdated — SSO section nowcorrectly states you're returned to the page you were trying to view.
Closes bead bookshelf-bxtat on merge.
Code Review — PR #1392 (bookshelf-bxtat)
Reviewed the diff (
internal/users/handler.go,oidc_handler.go, template, tests, e2e journey, docs) for correctness/security/convention per.claude/rules/review-standard.md. No re-run of tests — CI is green.Findings
[MINOR] internal/users/handler.go:86-98 (safeNextPath) — no length cap on
nextsafeNextPathaccepts any string starting with a single/(not//or/\\) with no upper bound on length.oidcLoginHandler(oidc_handler.go:135-138) stores the raw value verbatim in thebookshelf_oidc_nextcookie. An attacker (or a legitimately very long deep-link path) can push a several-KB value into a Set-Cookie header; browsers will just silently drop/truncate an oversized cookie (no crash), so impact is cosmetic (deep-link falls back to/on truncation) rather than a security hole. Consider capping accepted length (e.g. 512 bytes) insafeNextPathfor defense-in-depth and a cleaner failure mode.[MINOR] internal/users/handler.go:86-98 (safeNextPath) — control characters / CRLF not explicitly rejected
safeNextPathdoesn't strip control chars (e.g. a decoded\r/\nfrom%0d%0a) before accepting the value as a redirect target / cookie value. In this codebase's Go stdlib this is currently neutralized (net/http's cookie writer sanitizes invalid bytes out of cookie values, and the header writer replaces\r/\nin header values before writingLocation), so there's no live CRLF-injection path today — but the safety is implicit/incidental rather than asserted by this function. Consider an explicit reject of control characters insafeNextPathso the guarantee doesn't silently depend onnet/httpinternals staying that way.What was verified clean
safeNextPath(handler.go:79-98) correctly rejects//evil.com,/\\evil.com(backslash-normalize bypass),https://evil.com, bareevil.com(no leading/), and empty string — all fall back to/. Traced every consumption point:loginPageHandler(handler.go:132),loginHandler(handler.go:220),oidcLoginHandler(oidc_handler.go:293), and — critically —oidcCallbackHandlerre-validates the cookie-sourced value again (oidc_handler.go:318,next := safeNextPath(cookieNext)) before redirecting, so a tampered/pre-existing hostileoidc_nextcookie can't cause an off-site redirect even if it somehow bypassed the write-time check. Both local-login inline checks (previously at handler.go:108/:208 pre-diff) are now fully replaced by the shared helper — no lingering divergent inline check remains.oidc_nextcookie is built from the samecookieAttrsbase as state/nonce/verifier (oidc_handler.go:118-124, applied at :302-305) —Path=/auth/oidc,HttpOnly,Secure=d.SecureCookies,SameSite=Lax,MaxAge=oidcStateCookieTTL(10 min). It is explicitly cleared inclearOIDCCookies(oidc_handler.go:249-251) with matchingPath=/auth/oidcandMaxAge=-1, andoidcCallbackHandlercallsclearOIDCCookiesunconditionally right after reading it (oidc_handler.go:319) — confirmed by the newIt("clears the oidc_next cookie on success"...)test.templates/pages/login.html:476—<a href="/auth/oidc/login?next={{.Next}}">—.Nextis server-validated (already passed throughsafeNextPathbefore being placed on the page-data struct) ANDhtml/template's contextual autoescaper applies proper URL-query escaping in this position regardless, so this is not raw interpolation / not an injection vector./whennextis absent/empty/invalid — verified vianext_path_test.gotable entries and the OIDC-only redirect tests (oidcLoginURL("") → "/auth/oidc/login"with no query string, exercised by the pre-existing "redirects to /auth/oidc/login (bypass closed)" case in handler_test.go).loginPageHandler/loginHandler, all new/changed test files are black-box (package users_test),safeNextPathis exposed to tests only via the existingexport_test.goshim pattern (consistent withExportOIDCLoginHandleretc. already in that file) — not a white-box violation. NewIts are one-assertion-per-behavior except the pre-existingfindCookie-nil-guard-then-field-assert idiom, which already existed in this file before the diff (e.g. oidc_handler_test.go original nonce/pkce cookie tests) — not a new violation introduced here. No.golangci.ymlor coverage-exclusion changes in the diff.docs/content/docs/accounts-access/signing-in.mdupdate is end-user framed ("you are returned to the page you were trying to view"), no source-code/internal references.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Security Review — PR #1392 (bookshelf-bxtat)
Adversarial review of
next/open-redirect threading through local + OIDC login. Confirmed a real, PoC-verified bypass ofsafeNextPath— details below.[BLOCKER] internal/users/handler.go:86-96 (
safeNextPath) and internal/users/handler.go:272 — open-redirect via embedded ASCII TAB (control-char) bypasses the////\\prefix checkssafeNextPathonly rejects values that literally start with"//"or"/\\". It does not reject embedded ASCII control characters (TAB0x09, and to a lesser extent CR/LF). Per the WHATWG URL spec, browsers strip all ASCII tab/newline bytes from a URL string wherever they occur (not just leading/trailing) before parsing it — so a value like"/\t/evil.com"passessafeNextPathunchanged (first char is/, second char is TAB, so neither the//nor/\\prefix check fires), and the browser reconstitutes it as"//evil.com"— a protocol-relative URL — after stripping the tab.This is directly exploitable on the local-login success path, which is the most dangerous of the three consumers because
nextgoes straight fromr.FormValue("next")throughsafeNextPathintohttp.Redirect(w, r, next, ...)with no intermediate cookie orurl.QueryEscaperound-trip to accidentally sanitize it:r.FormValue("next")URL-decodes%09to a literal TAB byte →"/\t/evil.com".safeNextPath("/\t/evil.com")returns it unchanged (verified with a standalone repro against the actual function body).http.Redirect(w, r, next, http.StatusFound)at handler.go:272 writesLocation: /\t/evil.com.net/http's actual header serialization (Header.Write): Go only replaces\n/\rwith a space (headerNewlineToSpace, net/http/header.go) — it does not touch TAB, andtextproto.TrimStringonly trims leading/trailing whitespace, not embedded whitespace. The raw wire bytes are literallyLocation: /\t/evil.com\r\n.//evil.com, which it resolves as a protocol-relative URL and navigates tohttps://evil.com(or the current scheme) — a full off-site redirect immediately after a successful login, e.g. to a phishing/credential-harvesting page.Repro (ran locally against the literal
safeNextPathbody from this diff):I also checked the two other consumers of
next:oidcLoginURL()(oidc_handler.go) appliesurl.QueryEscape(next)before embedding it in the same-origin/auth/oidc/login?next=...redirect — this percent-encodes the tab (%09), so that hop isn't directly exploitable at that redirect, but the escaped value gets URL-decoded straight back to a raw tab whenoidcLoginHandlerre-parsesr.URL.Query().Get("next"), then stored viasafeNextPathinto theoidc_nextcookie. It only survives to be caught downstream because Go'shttp.SetCookie/sanitizeCookieValuestrips control byte 0x09 from cookie values at set-time, which happens to collapse the string to the literal"//evil.com"before storage — a form the//prefix check then catches whenoidcCallbackHandlerre-validates viasafeNextPath(cookieNext). That's incidental, implementation-detail luck (a change to Go's cookie sanitization, or any code path that stores/forwardsnextwithout going throughhttp.SetCookie, reopens this), not a designed defense — the shared validator itself is unsound and must be fixed at the source, not rely on this side-effect.Fix: reject (fall back to
/) ifnextcontains any ASCII control character (< 0x20or0x7F), not just the////\\literal prefixes, e.g.:Add a regression test:
Entry("a tab-embedded protocol-relative bypass is rejected", "/\t/evil.com", "/")(and\n/\rsiblings) tointernal/users/next_path_test.go.[MINOR] internal/users/next_path_test.go:241-254 — test table doesn't cover the control-character bypass class
The existing
DescribeTablecovers//,/\\, absolute URLs, empty, and no-leading-slash, but not embedded TAB/CR/LF. Add cases for"/\t/evil.com","/\n/evil.com","/\r/evil.com"once the BLOCKER fix lands, so this bypass can't silently regress.[MINOR] internal/users/oidc_handler.go:279-284 (
oidcLoginURL) — relies onnextalready having been validated upstream, with only a comment as the contractoidcLoginURLdocuments "next is expected to already be validated by safeNextPath" but takes a rawstringwith no type-level enforcement. Low risk today (both call sites do validate first), but a future caller could pass an unvalidatednextand nothing would catch it. Not blocking — flagging for awareness; a comment-only contract is acceptable at this size.Everything else checked out:
state/nonce/PKCEcode_verifier) are untouched by this change —nextis threaded independently and validated separately fromcookieState/cookieNonce/cookieVerifier, which still flow unchanged intod.HandleCallback.oidc_nextcookie flags are correct:HttpOnly,Secure(fromd.SecureCookies),SameSite=Lax,Path=/auth/oidc, 10-minute TTL, and it's explicitly cleared inclearOIDCCookieson every callback outcome (success and every error branch that redirects to/login).login.html'shref="/auth/oidc/login?next={{.Next}}"is in ahtml/templateURL-query-value context, which auto-escapes/percent-encodes.Next— no template-injection/attribute-breakout path found.nextredirect target logged;audit.Recordcalls in this diff are unrelated tonextand already go throughaudit.SanitizeDescription.@(e.g./https://user@evil), and non-/-prefixed schemes (javascript:,http://,https:/evil) are all correctly rejected or rendered inert as same-origin relative paths by the existing prefix checks — could not defeat those.REVIEW VERDICT: 1 blocker, 0 major, 2 minor
Code Re-Review — PR #1392 (bookshelf-bxtat) at
45d80ad9Focused on the delta since the prior review (open-redirect BLOCKER fix + 2 code MINORs).
Confirmed resolved from prior review:
internal/users/handler.go:96-98(maxNextPathBytes = 512, rejectslen(next) > 512), asserted bynext_path_test.go("an oversized next is rejected").isControlByte(r < 0x20 || r == 0x7F) athandler.go:118-121, wired viastrings.IndexFunc(next, isControlByte)athandler.go:112-114; covered by TAB/CR/LF/DEL table entries innext_path_test.goand the black-boxhandler_test.go:559-570POST/logincase (next=%2F%09%2Fevil.com→ asserts exactLocation: /).safeNextPathis now the single shared choke point used by every entry point:loginPageHandler(handler.go:153),loginHandler(handler.go:241),oidcLoginHandler(oidc_handler.go:106), and defensively both inoidcLoginURL(oidc_handler.go:~28, re-validates before building the/auth/oidc/login?next=redirect) and inoidcCallbackHandler(oidc_handler.go:156-159, re-validates the cookie-sourcednextbefore using it in the finalLocationredirect). No entry point bypasses it — grepped for any remaining inlinestrings.HasPrefix(next, ...)duplication and found none on the branch.isControlBytepredicate is correct: strictly C0 (<0x20) + DEL (0x7F); does not reject any valid path byte (letters, digits,/,-,_,.,%-encoded sequences decode before this check runs and query-string percent-encoding is already decoded bynet/httpbeforeQuery().Get/FormValue, so a literal raw TAB in the decoded value is exactly the byte being defended against).nextvalues (e.g./books/123,/books/9) still round-trip unchanged — covered by bothnext_path_test.goand the OIDC-only deep-link tests inhandler_test.go(?next=/books/9→Location: /auth/oidc/login?next=%2Fbooks%2F9) and the new e2e Journey-9 step (e2e/api/journey_9_oidc_login_test.go) which proves the full cookie round-trip through a real HTTP flow.templates/pages/login.html:50(href="/auth/oidc/login?next={{.Next}}") is safe:.Nextis alreadysafeNextPath-validated server-side before being placed in template data, andhtml/template's contextual URL-attribute autoescaping provides defense-in-depth regardless. Matches the pre-existing hidden-input pattern at line 19.package users_test), using the newExportSafeNextPathre-export (export_test.go) rather than reaching into unexported internals directly. No coverage-exclusion or.golangci.ymlchanges in this diff.Findings:
[MINOR] internal/users/oidc_handler_test.go — new callback
Its violate one-Expect-per-ItTwo of the new tests bundle two
Expectcalls into a singleIt:"redirects to the stored next path on success"(Expect(w.Code)...+Expect(w.Header().Get("Location"))...) and"redirects to / instead of the hostile value (defense in depth)"(same pair). This mirrors an already-existing pattern in this file (the pre-existing"redirects to home on success..."It has the same two-Expect shape), so it's not a new regression this delta introduced, but it does propagate the violation into new tests rather than fixing it. Suggested fix: split each into a status-code It and a Location It (or fold the status check into the value assertion perproject-conventions.md's "fold no-error check into value assertion" guidance, usingExpect(w.Code, w.Header().Get("Location")).To(...)-style if a single combined actual is preferred) — low priority, can ride with a future cleanup of the surrounding pre-existing pattern rather than blocking this PR.No other issues found. The fix correctly closes the embedded-control-byte / oversized-input gaps flagged previously, keeps a single validation choke point, and the new tests (unit + e2e) demonstrate both the attack path is closed and legitimate deep-links still work end-to-end.
REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Security re-review — PR #1392 (bookshelf-bxtat), commit
45d80ad9Verified the fix for the embedded-control-byte open-redirect BLOCKER from the prior review.
Payload zoo re-run against new
safeNextPath/%09/evil.com(TAB, 0x09) →strings.IndexFunc(next, isControlByte)decodes the raw 0x09 byte as its own rune (valid ASCII),0x09 < 0x20→ caught → falls back to/. Closed. Confirmed by the new regression test assertingLocation == "/"exactly (not just non-nil) ininternal/users/handler_test.go.\n(0x0A),\r(0x0D),\x0b(VT),\x0c(FF),\x7f(DEL), NUL (0x00) — all satisfyr < 0x20 || r == 0x7F→ all caught. Table-driven unit coverage ininternal/users/next_path_test.goexercises TAB/LF/CR/DEL explicitly; VT/FF/NUL aren't individually enumerated in the table but are covered by the same predicate — no code path treats them differently, so this is a coverage nit, not a gap.strings.IndexFuncdecodes runes viautf8.DecodeRuneInString. A raw C0/DEL byte is always a complete, valid 1-byte rune in UTF-8, so it can't be "hidden" inside a multi-byte sequence — continuation bytes in valid UTF-8 are always ≥0x80, and an invalid/overlong sequence decodes toutf8.RuneError(also not< 0x20) without re-synthesizing a low ASCII byte. Traced through tohttp.Redirect'shexEscapeNonASCII, which percent-encodes every byte>= 0x80individually (byte-wise, not rune-wise) before writing theLocationheader — so even a malformed sequence that slipped past validation would arrive at the browser as literal%XXtext, never as a raw control byte or a decoded slash. No smuggling path found.hexEscapeNonASCIIpercent-encodes them in theLocationheader before it reaches the browser. Browsers do not decode percent-escapes inLocationback into path-separator semantics — they follow the URL as an opaque percent-encoded path segment. Not a redirect-bypass vector. (Residual note, not a finding: ifnextis ever echoed back into HTML — it isn't, in this diff —html/templateauto-escaping would apply.)len(next) > maxNextPathBytes— Golen()on a string is byte length, which is the correct measure for the header-size concern the comment describes. No off-by-one;>(not>=) means exactly 512 bytes is still accepted, which is fine (the boundary is documented as "longer is rejected").oidcLoginURLdefensive re-validation: re-callssafeNextPath(next)before building the redirect URL; idempotent (a value that already passedsafeNextPathmaps to itself). Correctly special-cases"/"to omit the query param. Query value isurl.QueryEscaped.oidcCallbackHandlerre-validates the cookie-sourcednextviasafeNextPath(cookieNext)before redirecting — closes the theoretical gap where a tampered/stale cookie value bypasses the login-time validation. Cookie attributes (Path,HttpOnly,Secure,SameSite=Lax, matchingMaxAge) are consistent with the existing state/nonce/verifier cookies, andclearOIDCCookiesnow also expires the newoidcNextCookieNamecookie.internal/users/handler_test.gonewContext("with next=/%09/evil.com …")assertsExpect(resp.Header.Get("Location")).To(Equal("/"))— an exact-match assertion, not a weaker non-nil/non-empty check. Good.next_path_test.goispackage users_testand reaches the unexportedsafeNextPathonly through the documentedExportSafeNextPathre-export inexport_test.go— consistent with the project's black-box test convention.Findings
None. All payload-zoo cases are closed by the new
isControlBytecheck, and the multi-byte/Unicode-lookalike smuggling paths are independently closed by Go'snet/http.Redirectbyte-wise non-ASCII percent-escaping — no bypass reaches the browser as a literal separator or control byte.REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Code Review — PR #1390 (bookshelf-bz643.6)
Reviewed the diff (17→3 browser e2e journey consolidation) against
.claude/rules/review-standard.mdand the CLAUDE.md E2E Testing Policy. Spot-checked every deleted file against its claimed destination in the 3 new journey files.Summary of what I verified as correctly preserved:
journey_bookdrop_test.go: all 7 original files' distinctive assertions present (bottom-bar.closest()checks, extract-pattern preview+apply+screenshot, field-parity series_total/page_count/categories/age_rating/content_rating, comic editor fetched-chip-pill rendering, auto-save-without-Save-click + DB persistence + Accept-flush, DragEvent merge-order reorder+persist, settings poll-interval CSS-hidden regression guard). Screenshots preserved at the same points.journey_metadata_fetch_test.go: scan-button + LLM-provider-modal correctly promoted to standalone top-levelOrdered, SerialDescribes (fixes the real Ginkgo "Invalid Serial Node in Non-Serial Ordered Container" CI failure from the first push — verifiedSerialnever appears nested inside anotherOrderedparent in the diff). Variant-cover, save-validation, cover-apply (fallback-only bookshelf-4xxe semantics), and Author Search (Audnexus, all 5 steps incl. bio-expand screenshot) all faithfully retained.journey_magic_shelf_test.go: create-modal, filter-stays-on-shelf (localStorage+navigation), infinite-scroll-stays-on-rule (IntersectionObserver regression guard for bookshelf-di1h) all faithfully retained with matching seed data and assertions.Describecount drops 52->40 (this PR's contribution), all top-level Describes areOrdered(make e2e-policy-check enforces this), each Ordered journey reusing a page acrossIts consistently callsrefreshPageTimeout/page.Timeoutat the start of each step viaBeforeEachor explicit re-set.Finding:
[MAJOR] e2e/browser/journey_magic_shelf_test.go:8-15 — dropped DOM regression-guard coverage for the library-field
<select>render (bookshelf-lkra), justification comment is factually inaccurateThe consolidation comment claims: "only the full-stack 'submit' It is kept; the field-picker render/select-population Its asserted DOM structure with no additional Chromium-unique behavior beyond what the submit It already proves end-to-end." This is not accurate. The original
journey_magic_shelf_library_dropdown_test.gohad 4 Its:clicking + Add Rule inserts a rule with a field pickerselecting Library renders a <select> (not freeform input) and posts screenshot— the actual regression guard for bookshelf-lkra (a real historical bug: the library field rendering as a freeform text input instead of a populated<select>)the library select contains the seeded library as an optionsubmitting a library rule saves the shelf and returns matching books— the full-stack round tripOnly It #4 survived, as "Library field rule saves and filters books" (journey_magic_shelf_test.go:520-621). But that surviving It explicitly bypasses the field-picker DOM — see its own comment around line 585: "Inject the rules JSON directly into the hidden input (bypasses the multi-step UI builder for robustness...)". It never opens the field picker, never clicks the "Library" field item, and never asserts a
<select class="rule-value-enum">renders. Nor does the "Create Magic Shelf Modal" journey earlier in the same file (lines 44-218) touch field selection — it only tests the modal opening and shelf-name submission.Net effect: the bookshelf-lkra regression guard (library rule field renders a
<select>populated with the user's real libraries, not a freeform text input) is now completely unverified by any browser e2e spec, while the PR's own inline comment claims it's redundantly covered. If this DOM path regresses (e.g. a future refactor of the rule-builder's field-picker JS reverts to a plain text input for enum fields), no test will catch it.Fix: restore at minimum It #2 (
selecting Library renders a <select>...) as a nestedItin the "Create Magic Shelf Modal" Describe (or a small dedicated Describe) injourney_magic_shelf_test.go, driving the real field-picker UI (open picker -> click library field item -> assertselect.rule-value-enumexists) before falling back to the JSON-injection shortcut for the full-stack round trip. It #1 and #3 can stay dropped (weaker DOM-presence checks with lower unique signal), but #2 is the one genuine regression guard.REVIEW VERDICT: 0 blocker, 1 major, 0 minor
zombor referenced this pull request2026-08-08 02:39:36 +00:00
zombor referenced this pull request2026-08-08 02:42:23 +00:00
zombor referenced this pull request2026-08-08 02:55:53 +00:00
zombor referenced this pull request2026-08-08 02:56:24 +00:00
zombor referenced this pull request2026-08-08 03:05:05 +00:00
zombor referenced this pull request2026-08-08 03:05:58 +00:00
zombor referenced this pull request2026-08-08 03:06:14 +00:00
zombor referenced this pull request2026-08-08 03:06:26 +00:00
zombor referenced this pull request2026-08-08 03:06:58 +00:00
zombor referenced this pull request2026-08-08 03:07:23 +00:00
45d80ad976916f9e74ee