Repository navigation
fix(router): same-origin redirects by default on PE/full-page paths + redirect({ external: true }) opt-in (#572) - #580
Conversation
… redirect({ external: true }) opt-in (#572)
The client already blocks cross-origin redirect targets on the JS/fetch path
(validateRedirectOrigin), but a redirect the browser follows natively — a no-JS
PE form POST, a full-page GET match.redirect, a middleware redirect()
short-circuit, a response-route 3xx — had no client in the loop and emitted the
cross-origin Location unchecked, so redirect(userInput) was an open redirect on
the no-JS / full-page path while being blocked with JS.
A completeness sweep found 14 unvalidated redirect exits, all funneling through
one place: the single return in createRSCHandler. The fix is one server
chokepoint (guardOutgoingRedirect) there, future-proof against new exits:
- same-origin / relative -> passes through
- cross-origin without opt-in -> Location rewritten to the basename root (dev logs it)
- redirect(url, { external: true }) -> off-host target allowed (Rails allow_other_host model)
One shared same-origin rule (src/redirect-origin.ts) backs BOTH the client
validator and the server guard so they cannot drift. The external marker rides
to the guard on the document channel and as metadata.redirect.external (Flight)
on the SPA channel, where the client does a hard location.assign; it is stripped
at both browser-facing exits and never reaches the browser.
Also in this defense-in-depth slice:
- origin-guard fails closed when an Origin is present but no Host header can
establish the expected origin (was: trust url.host)
- server-action decode failure no longer interpolates the raw error into the
thrown message (kept as cause for logging)
- the PE decodeReply [formData] fallback is kept and documented as the
intentional no-JS <form action={fn}> convention (proven load-bearing)
- the dispatch testing primitive mirrors the chokepoint so consumers can
unit-test the same-origin contract
Docs: security-checklist (new Outgoing Redirect Guard section), feature-map,
feature-file-map, server-actions skill.
commit: |
…alidated (#572) Address two HIGH review findings on the open-redirect defense (PR #580), plus two follow-ups from an adversarial review pass. 1. Forgeable opt-in. The external opt-in was a wire header (x-rango-redirect-external). A proxy-style response route copying an attacker-controlled upstream's headers could inject it and bypass the same-origin guard without app code ever calling redirect({ external: true }). Replace the wire header with an out-of-band brand (WeakSet<Response>) set only by redirect({ external: true }) and transferred only across internal redirect-rebuild paths (middleware mergeResponse, carryOverRedirectHeaders, response-route rewrap, and the dispatch mirror). A proxied upstream Response is never branded, so its forged header is inert; the reserved header name is now always stripped and never trusted. Brand transfer is fail-closed. 2. Unvalidated scheme. external:true skipped scheme validation, so redirect("javascript:...", { external: true }) reached window.location.assign as a scriptable navigation. Add resolveExternalRedirect (http/https only), shared by the server guard and both client location.assign paths via validateExternalRedirect. external now waives the same-origin rule, not scheme safety; a non-http(s) target is neutralized like a blocked cross-origin one. Adversarial review follow-ups: - HIGH (fail-closed): an onResponse callback returning a NEW Response dropped the object-identity brand, silently neutralizing a legit external redirect. drainOnResponseCallbacks now re-applies the brand across the rebuild. - LOW: a forged marker on a non-3xx middleware response could reach the browser (inert). mergeResponse now strips the reserved header unconditionally. Tests: red-before-green regressions for all four (guard forged-header + javascript:/data:, client javascript: block on SPA/action paths, PE-extract and response-route forgery via the dispatch userland primitive, onResponse brand-preservation, non-3xx header strip). Docs updated (security-checklist, feature-map, feature-file-map, server-actions SKILL). Full gate green: typecheck, test:unit:all, lint, format; e2e redirect-guard + semantic-matrix + progressive-enhancement + origin-guard (dev + production).
Review hardening:
|
redirect(url, opts) |
target | result |
|---|---|---|
no external |
same-origin / relative | allowed unchanged |
no external |
cross-origin | neutralized to app root (dev logs) |
{ external: true } |
off-origin http(s) |
allowed off-host |
{ external: true } |
javascript: / data: / other scheme |
neutralized to app root |
forged x-rango-redirect-external header, no brand |
any | not trusted — treated as no opt-in |
The opt-in can only be set by app code calling redirect(..., { external: true }); the attacker controls only the URL value (Rails allow_other_host: true model).
Test plan
- Unit (red-before-green for each): guard forged-header +
javascript:/data:neutralization;resolveExternalRedirectscheme rule;redirect()brands (no wire header);carryOverRedirectHeadersskips marker + transfers brand; PE-extract brand transfer + forged-header neutralization; clientjavascript:block on both SPA and action paths;onResponsebrand-preservation; non-3xx marker strip. - Userland (
@rangojs/router/testingdispatch): response-route forged-header forgery is neutralized;onResponse+externalstays allowed; non-3xx forged marker stripped. - E2E (dev + production):
redirect-guard(middleware block/allow/external + no-JS PE block/external-allow) +semantic-matrix+progressive-enhancement+origin-guardall green. - typecheck,
test:unit:all, lint, format green.
Docs updated: security-checklist.md, feature-map.md, feature-file-map.md, server-actions/SKILL.md.
#572) Review follow-up: the reserved x-rango-redirect-external header could still leak on a non-3xx response when set through the stub (e.g. a stray ctx.header("x-rango-redirect-external", "1") in middleware or a response route). mergeResponse() strips the marker from the base response, but the stub/header- merge primitives re-introduced it: createResponseWithMergedHeaders -> applyStubHeaders copies ctx.res.headers, and middleware mergeResponse() merges mergeStubHeaders/mergeReqCtxStub back in after the delete. This did not reopen the redirect bypass (3xx still hits the guard, which strips), but it contradicted the guarantee that the reserved header is stripped at every browser-facing exit. Fix: applyStubHeaders, mergeStubHeaders, and mergeReqCtxStub now refuse to copy the reserved marker (same treatment Set-Cookie already gets). The opt-in remains the out-of-band brand; the header is never a trust signal and never leaves. Tests (red-before-green, via the dispatch userland primitive): a marker set via ctx.header() on a non-3xx middleware short-circuit (mergeStubHeaders), on a response-route 200 (applyStubHeaders), and on the request-context stub through the middleware chain (mergeReqCtxStub) are all stripped. Docs updated. Full gate green: typecheck, test:unit:all, lint, format; e2e redirect-guard + semantic-matrix (dev + production).
Follow-up (
|
…ense-in-depth # Conflicts: # packages/rangojs-router/docs/internal/feature-file-map.md
Problem
Rango already blocks cross-origin redirect targets on the JS/fetch path:
validateRedirectOriginruns in the navigation/partial/action bridges, so aredirect("https://evil.example")triggered from a client-driven action or navigation is dropped and the user stays put. But that guard only runs where Rango's own JS decides to navigate. A redirect the browser follows natively — a no-JS progressive-enhancement (PE) form POST, a full-page GETmatch.redirect, a middlewareredirect()short-circuit, a response-route3xx— has no client in the loop, so those paths emitted the cross-originLocationunchecked.Concretely, an app that does the common thing:
was protected with JS enabled but became an open redirect on the no-JS / full-page path (
?returnTo=https://evil.example→ the browser is sent off-site). The JS and no-JS paths disagreed.What changed (consumer-visible)
redirect()is now same-origin by default on every path — JS, no-JS PE, and full-page. A cross-origin target without an explicit opt-in is neutralized to the app root (dev logs the blocked target). To redirect off-host on purpose, opt in:{ external: true }is the audit point (the Railsallow_other_host: truemodel): the attacker controls the URL value, never whether your code wroteexternal: true, soredirect(userInput)stays safe by construction.End-to-end example
With
?returnTo=https://evil.example, the no-JS browser now lands on/login, notevil.example./auth/googlestill reaches Google.Semantics
redirect("/dash")(same-origin)redirect("//evil")/redirect("https://evil")(no opt-in)redirect(url, { external: true })location.assignX-RSC-Redirect/ Flightmetadata.redirect)Plus two lower-severity hardenings in the same slice:
originCheck:Originpresent, noHostheaderurl.host(request line)HostwithOrigincause(no client-facing detail leak)Code-change summary
The 14 unvalidated redirect exits a completeness sweep found all funnel into one place — the single
returnincreateRSCHandler— so the fix is one chokepoint, not 12 patches, and is future-proof:src/redirect-origin.ts(new) — the ONE shared same-origin rule (resolveSameOriginRedirect+ the internalEXTERNAL_REDIRECT_MARKER). Imported by both the client validator and the server guard so they cannot drift.src/browser/validate-redirect-origin.ts— rewired to the shared rule (behavior + tests unchanged).src/rsc/redirect-guard.ts(new) —guardOutgoingRedirect: same-origin passes; cross-origin → basename root + dev log; marker present → strip + allow.src/rsc/handler.ts— the chokepoint:return guardOutgoingRedirect(response, url.origin, router.basename). Soft SPA redirects are200/204, so they pass through untouched (still validated client-side).src/route-definition/redirect.ts—external?: booleanoption; stamps the internal marker.src/rsc/helpers.ts—interceptRedirectForPartialpropagatesexternalontometadata.redirect.external(Flight) so the SPA channel does a hardlocation.assign;carryOverRedirectHeadersstrips the marker so it never reaches the browser.src/browser/server-action-bridge.ts,partial-update.ts— honormetadata.redirect.external; two init-window hard-nav fallbacks (dispatchRedirect,rsc-router.tsx) re-validate defensively.src/rsc/origin-guard.ts— fail-closed whenHostabsent.src/rsc/server-action.ts— drop raw decode-error detail.src/testing/dispatch.ts— applies the guard at its final return so the publicdispatchprimitive mirrors the production chokepoint (consumers can unit-test the contract).Test plan
src/rsc/__tests__/redirect-guard.test.ts(guard + shared rule: same-origin pass, cross-origin → root, basename root, cookie preservation, external strip),route-definition/__tests__/redirect.test.ts(external marker),rsc/__tests__/helpers.test.ts(SPA external propagation + marker not leaked),rsc/__tests__/origin-guard.test.ts(3 fail-closed cases).@rangojs/router/testingdispatchprimitive) —testing/__tests__/dispatch.test.ts: cross-origin middleware/response-route redirect → root;externalallowed + marker stripped; same-origin passthrough. Pins the consumer-visible contract through the same primitive consumers use.e2e/redirect-guard.test.ts: full-page middleware block / protocol-relative block / same-origin allow /externalallow (raw HTTP,maxRedirects: 0) + no-JS PE form-action cross-origin block (real browser, lands on root). Green in both modes.origin-guard(prod),progressive-enhancement(dev + prod, confirms the kept no-JS<form action={fn}>fallback),semantic-matrix(dev 61 + prod 21).pnpm run typecheck,pnpm run test:unit:all(router + all consumer dogfood suites),pnpm run lint,pnpm run format.Notes / call-outs
#572PEdecodeReply-reject item is a false positive (declined, documented). Making the PE direct-action path reject on decode failure broke 3 no-JS form tests:decodeReplylegitimately throws for a raw<form action={fn}>POST (no Flight-encoded args), and the[formData]fallback IS the React form-action convention. The JS↔PE divergence is intentional;progressive-enhancement.tsnow carries a comment saying so.#572action-ownership item deferred. Verifying an action ID "belongs to the matched route" fights the RSC global-action model (a shared layout action is invoked from many routes); no route→action ownership map exists. Documented as app responsibility rather than enforced.metadata.redirect.externalin the Flight payload and are honored client-side. This is the documented, explicit opt-out; a crafted response could set it, but that requires controlling the server response (a strictly larger compromise than open-redirect), andredirect(userInput)never carries the flag. Same trust model as Railsallow_other_host.docs/internal/security-checklist.md(new "Outgoing Redirect Guard" section + fail-closed note),feature-map.md,feature-file-map.md, and theserver-actionsskill.