Unify the unlock redirect onto one builder #165
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lz/agent-nexus!165
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/unlock-redirect"
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?
Closes #150.
Four sites decided where a locked operator lands and they disagreed. The client 401 bounce carried a sanitized destination; the phase gate and the Lock button carried nothing, and the bounce back off the lock screens hardcoded
/. A cold navigation — the path OAuth consent will arrive on (#151) — therefore dropped the whole destination.What changed
lib/api/unlock-redirect.tsis the only place that builds an unlock URL or reads one back:lib/api/unlock-redirect.tsunlockUrl(dest)routes/+layout.server.tsphase gateredirect(303, required)unlockUrl(url.pathname + url.search)routes/+layout.server.tsbounce outredirect(303, '/')destFromSearch(url.search)lib/components/Header.svelteLockgoto('/unlock')invalidateAll()lets the layout decidelib/components/Unlock.sveltesanitizeNext(...)destFromSearch(window.location.search)UNLOCK_ROUTEis the single literal andphase-routes.tsimports it.destFromSearchis the one reader ofnext, so the server (url.search) and the client (window.location.search) agree about an absent, empty, repeated or differently-cased parameter by construction rather than by luck. Thenextis read only on the screen that writes one.The Lock button stops navigating entirely.
invalidateAll()already follows the layout's redirect (_invalidate()awaits_goto()on a redirect result, kit 2.59.1), so an explicitgotowas a second decision to keep in step — and it pushed a history entry over the redirect'sreplaceState, so Back returned to the URL you were already on.Setup.sveltealready makes the/setup → /unlockhop on a barerefresh().The sites-3-and-4 fight in the issue is real, and measured rather than reasoned: with the new layout and the old
goto('/unlock')left in place, clicking Lock on/settings?tab=tokensproduced/unlock?next=%2Fsettings%3Ftab%3Dtokensand then overwrote it with a bare/unlock, landing on/after unlocking. The fix is not ordering — it is that the second decision is gone.NO_REDIRECT_FROMdid NOT subsume the layout's loop guard, and could not. They answer different questions:url.pathname !== requiredasks "am I already at this phase's route" and all three gated phases need it, while the builder's rule governs what anextmay point at. Drop the pathname test andunlockUrlon the unlock screen returns a bare/unlock— its own fixed point, so an infinite redirect. Both are stated where they live rather than left as silent neighbours.A security defect the review caught
The third commit fixes an open redirect that the first commit made reachable. Browsers strip tab, CR and LF from a URL before parsing, so
/<tab>/evil.compasses "starts with/and is not//" and then resolves to//evil.com; a tab is a legal HTTP field-value character, so it survives aLocationheader. Measured against the previous commit on a live instance:On
mainthis was latent:sanitizeNext's only consumer wasUnlock.svelte'sgoto, and SvelteKit's publicgotorejects a cross-origin URL. Teaching the bounce to honournextput it in a header, where nothing checks anything. The same shape walked/./unlock,/x/../unlockand/unlock/past the lock-screen rule, and/%75nlockpast it in a way that parked an unlocked operator back on the passphrase form — a small regression frommain.Fix:
sanitizeNextrefuses anything the URL parser would rewrite (refuse, never repair — the rule the module already had), and the lock-screen comparison resolves the route the way the router does,decodeURIincluded, per fact #23. This deviates from the issue's "keepsanitizeNextas is" — deliberately, and only by strengthening it; every existing rule is untouched.Verified live after the fix (
/unlock?next=…while unlocked):%2F%09%2Fevil.com→/,%2F%2575nlock→/,%2F.%2Funlock→/,%2Fsettings%3Ftab%3Dtokens→/settings?tab=tokens.Gates
1825 tests / 157 files green, typecheck 0 errors across 4960 files, lint clean.
Guards proven able to fail
Each mutation applied, the named test confirmed red, then restored from a copy (never
git checkout):redirect(303, required)redirect(303, '/')if (true)isCanonicalremoveddecodeURIremovednextread widened to all gate routes303→307'/unlock'added tolib/keepalive.tsALLOWEDentry re-addedThe meta-test caught two real hits I did not know about on its first run (
hooks.server.ts,lib/api/client.ts— both/api/auth/unlock, which is why the regex has a leading lookbehind).Browser-verified
A real instance (fake
claude-sessionvolume →needs-provider, so the whole app renders), driven with playwright-cli on the final code: cold/settings?tab=tokenswhile locked →/unlock?next=%2Fsettings%3Ftab%3Dtokens→ unlock → back on/settings?tab=tokens; Lock →/unlock?next=%2Fsettings%3Ftab%3Dtokens→ unlock → back again; both attack URLs →/, same origin.Rejected review findings
PHASE_ROUTE. It would add/claude-session, which is a gate but not a lock screen, and stop honouringnext=/claude-session. The sets differ on purpose.nextthrough every gated phase. Getting the operator back needs each gate screen's completion to honour it; none does, so it would look like it works. This is the issue's decision 1 and it holds.Header.sveltecontains nogoto. Pinning an absence is the weakest kind of guard and would fire on any legitimate navigation added later./setupand/claude-session. Defensible, but this issue is scoped to the unlock route and the meta-test's blast radius grows with it.next. They are hostile input, not upstream bugs, and a universal load would log on both sides of the wire.stripCommentswith the two other copies (sealed-columns.test.ts,tabs-render.test.ts). They are deliberately different functions; sharing one changes two tests this branch does not touch.sanitizeNextmodule-local. It has no caller outside its own test, which is a fair observation, but the issue asked for it kept and its corpus is pinned against the function that implements the rule. Its docblock now points a caller atdestAfterUnlock.Not verified
Location: /<TAB>/evil.com) andnew URL('/\t/evil.com', base).href === 'http://evil.com/'in the same WHATWG parser family, but I did not drive the pre-fix build in a browser to watch it leave the origin. The post-fix behaviour is browser-verified./%75nlocktyped directly (not as anext) by an already-unlocked operator still renders a stale unlock form:GATING_ROUTES.includes(url.pathname)compares the encoded pathname while the router matched the decoded one. Pre-existing, cosmetic, and the real fix is keying that test onroute.id(fact #23) — a separate change to a guard AGENTS.md documents as pathname-based by design.Header.svelteorUnlock.svelte; the suite is node-only. Those two are covered by the browser pass above and nothing else.Conflicts to expect
Branched from
8a7e36elikefix/unlock-rate-limit(#160) andchore/zod-v4(#148).routes/api/auth/unlock/+server.ts(untouched here) and may touchUnlock.svelte. This branch changes two lines there — the import and thegotoargument — both in the<script>block. A textual conflict is possible if #160 edits the same region; there is no semantic overlap.mainhas moved toaccb51fsince branching; that commit is docs-only.Four sites decided where a locked operator lands and they disagreed. The client 401 bounce carried a sanitized destination; the server phase gate and the Lock button carried nothing, and the bounce back off the lock screens hardcoded '/'. So a cold navigation — the one path OAuth consent will arrive on — dropped the whole destination: /settings?tab=tokens 303'd to a bare /unlock and landed on / after unlocking. unlockUrl() is now the only way to build that URL and destAfterUnlock() the only way to read it back. Both refuse a lock screen as a destination, which is what makes next=/unlock unrepresentable rather than merely unlikely, and both strip the query before that comparison because /unlock?foo is still the unlock screen. UNLOCK_ROUTE is the single literal; phase-routes.ts imports it. The Lock button builds its URL BEFORE invalidateAll(). That ordering is the whole fix for the interaction the two sites now have: invalidateAll re-runs the layout, and _invalidate() awaits _goto() when a load returns a redirect, so by the time the explicit goto runs the browser is already at /unlock?next=… and window.location would hand back a bare one. Measured in a browser — with the old goto('/unlock') left in place the destination is overwritten and unlocking lands on /. The layout keeps its `url.pathname !== required` test. It is the loop guard and it is generic: /setup and /claude-session need it too, and the builder's lock-screen rule answers a different question — what a next may point at, not whether to make the hop. Dropping it and letting the builder decide returns a bare /unlock while already on /unlock, which is the loop. next rides the unlock hop only. Threading it through the other gated phases would look like it works and would not: both hand off to another phase that redirects from scratch.