Skip to content

fix(router): same-origin redirects by default on PE/full-page paths + redirect({ external: true }) opt-in (#572) - #580

Merged
ivogt merged 4 commits into
mainfrom
fix/open-redirect-defense-in-depth
Jun 14, 2026
Merged

ivogt merged 4 commits into
mainfrom
fix/open-redirect-defense-in-depth

Conversation

@ivogt

@ivogt ivogt commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

Rango already blocks cross-origin redirect targets on the JS/fetch path: validateRedirectOrigin runs in the navigation/partial/action bridges, so a redirect("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 GET match.redirect, a middleware redirect() short-circuit, a response-route 3xx — has no client in the loop, so those paths emitted the cross-origin Location unchecked.

Concretely, an app that does the common thing:

// route middleware or an action, target derived from request input
return redirect(ctx.searchParams.get("returnTo") ?? "/");

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:

// before — worked on no-JS, silently blocked on JS (inconsistent)
return redirect(userControlledUrl);

// after — same-origin everywhere; off-host requires the explicit opt-in
return redirect("https://accounts.google.com/o/oauth2/v2/auth?...", {
  external: true,
});

{ external: true } is the audit point (the Rails allow_other_host: true model): the attacker controls the URL value, never whether your code wrote external: true, so redirect(userInput) stays safe by construction.

End-to-end example

// urls.tsx — a login bounce that echoes ?returnTo
path("/login", LoginPage, { name: "login" }, () => [
  middleware((ctx, next) => {
    if (ctx.get("user")) return next();
    // returnTo is request-controlled; redirect() keeps it same-origin.
    return redirect(ctx.searchParams.get("returnTo") ?? "/login");
  }),
]),

// OAuth start — an intentional off-host redirect, explicitly opted in
path.any("/auth/google", () =>
  redirect("https://accounts.google.com/o/oauth2/v2/auth?client_id=...", {
    external: true,
  }),
),

With ?returnTo=https://evil.example, the no-JS browser now lands on /login, not evil.example. /auth/google still reaches Google.

Semantics

Redirect JS / SPA no-JS PE form full-page GET / middleware / response-route 3xx
redirect("/dash") (same-origin) ✅ navigates ✅ follows ✅ follows
redirect("//evil") / redirect("https://evil") (no opt-in) 🚫 dropped (client, unchanged) 🚫 → app root + dev warn 🚫 → app root + dev warn
redirect(url, { external: true }) ✅ hard location.assign ✅ follows off-host ✅ follows off-host
Soft SPA redirect (X-RSC-Redirect / Flight metadata.redirect) validated client-side (unchanged) n/a not a 3xx → guard is a no-op

Plus two lower-severity hardenings in the same slice:

Case Before After
originCheck: Origin present, no Host header compared against url.host (request line) fails closed (rejects) — a browser always sends Host with Origin
Action arg decode failure (JS path) thrown message interpolated the raw decode error message is static; original error kept as cause (no client-facing detail leak)

Code-change summary

The 14 unvalidated redirect exits a completeness sweep found all funnel into one place — the single return in createRSCHandler — 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 internal EXTERNAL_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 are 200/204, so they pass through untouched (still validated client-side).
  • src/route-definition/redirect.ts — external?: boolean option; stamps the internal marker.
  • src/rsc/helpers.ts — interceptRedirectForPartial propagates external onto metadata.redirect.external (Flight) so the SPA channel does a hard location.assign; carryOverRedirectHeaders strips the marker so it never reaches the browser.
  • src/browser/server-action-bridge.ts, partial-update.ts — honor metadata.redirect.external; two init-window hard-nav fallbacks (dispatchRedirect, rsc-router.tsx) re-validate defensively.
  • src/rsc/origin-guard.ts — fail-closed when Host absent. src/rsc/server-action.ts — drop raw decode-error detail.
  • src/testing/dispatch.ts — applies the guard at its final return so the public dispatch primitive mirrors the production chokepoint (consumers can unit-test the contract).

Test plan

  • Unit — 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).
  • Userland (via the @rangojs/router/testing dispatch primitive) — testing/__tests__/dispatch.test.ts: cross-origin middleware/response-route redirect → root; external allowed + marker stripped; same-origin passthrough. Pins the consumer-visible contract through the same primitive consumers use.
  • E2E dev + production — e2e/redirect-guard.test.ts: full-page middleware block / protocol-relative block / same-origin allow / external allow (raw HTTP, maxRedirects: 0) + no-JS PE form-action cross-origin block (real browser, lands on root). Green in both modes.
  • Regression e2e — 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

  • #572 PE decodeReply-reject item is a false positive (declined, documented). Making the PE direct-action path reject on decode failure broke 3 no-JS form tests: decodeReply legitimately 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.ts now carries a comment saying so.
  • #572 action-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.
  • External redirects on the SPA channel ride metadata.redirect.external in 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), and redirect(userInput) never carries the flag. Same trust model as Rails allow_other_host.
  • Docs updated in-PR: docs/internal/security-checklist.md (new "Outgoing Redirect Guard" section + fail-closed note), feature-map.md, feature-file-map.md, and the server-actions skill.

… 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.
@pkg-pr-new

pkg-pr-new Bot commented Jun 14, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@580

commit: 0aaceb2

…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).
@ivogt

ivogt commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator Author

Review hardening: redirect({ external: true }) is now unforgeable + scheme-validated

Pushed 5dbe63bd addressing two HIGH review findings on this PR, plus two more surfaced by an adversarial review pass.

Finding 1 (HIGH) — the external opt-in was forgeable

Problem (consumer-observable): the opt-in rode a wire header (x-rango-redirect-external). An app that proxies an upstream through a response route —

// response route that proxies an upstream API
path.json("/proxy", async () => fetch("https://some-upstream.example/r"))

— would have rewrapResponse copy all upstream headers. An attacker who controls that upstream could return 302 Location: https://evil.example plus x-rango-redirect-external: 1, and the guard would honor the opt-in even though the app never wrote { external: true }. That contradicts the documented model (only app code controls the opt-in; the attacker controls only the URL value).

Fix: the opt-in is now an out-of-band brand — a WeakSet<Response> (markExternalRedirect/isExternalRedirect in redirect-origin.ts) that cannot cross the wire. redirect({ external: true }) brands the Response object; the internal redirect-rebuild paths transfer the brand (mergeResponse, carryOverRedirectHeaders, the response-route rewrapResponse, and the dispatch mirror); the guard and the SPA intercept read it. A proxied upstream Response is never branded, so a forged header is inert — and the reserved header name is now always stripped, never trusted. Brand transfer is fail-closed: a dropped brand neutralizes to root, never opens off-host.

Finding 2 (HIGH) — external skipped scheme validation

Problem: external: true bypassed all URL validation, and the SPA/action clients then call window.location.assign() directly. So redirect("javascript:alert(document.cookie)", { external: true }) (or a forged external payload) was a scriptable navigation, while normal navigation already rejects javascript:.

Fix: one shared resolveExternalRedirect(url, origin) (http/https only) used by both the server guard and the two client location.assign paths (validateExternalRedirect). external waives the same-origin rule, not scheme safety.

Adversarial-review follow-ups (both fixed)

  • HIGH (fail-closed): an onResponse callback that returns a new Response dropped the brand → a legit external redirect was silently neutralized. drainOnResponseCallbacks now re-applies the brand across the rebuild.
  • LOW: a forged marker on a non-3xx middleware response could reach the browser (inert — header ignored, no navigation). mergeResponse now strips the reserved header unconditionally.

Resulting semantics

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; resolveExternalRedirect scheme rule; redirect() brands (no wire header); carryOverRedirectHeaders skips marker + transfers brand; PE-extract brand transfer + forged-header neutralization; client javascript: block on both SPA and action paths; onResponse brand-preservation; non-3xx marker strip.
  • Userland (@rangojs/router/testing dispatch): response-route forged-header forgery is neutralized; onResponse + external stays 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-guard all 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).
@ivogt

ivogt commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up (2723a56f): reserved marker stripped from the stub-merge path

Addresses the LOW finding that the reserved x-rango-redirect-external header could still leak on a non-3xx response when set through the stub (a stray ctx.header("x-rango-redirect-external", "1") in middleware or a response route). mergeResponse() stripped it from the base response, but the stub-merge primitives re-introduced it (applyStubHeaders copying ctx.res.headers; mergeStubHeaders/mergeReqCtxStub merging back in after the delete).

Not a bypass (3xx still hits the guard, which strips), but it contradicted the "stripped at every browser-facing exit" guarantee.

Fix: applyStubHeaders, mergeStubHeaders, and mergeReqCtxStub now refuse to copy the reserved marker — the same special-casing Set-Cookie already gets. The opt-in stays the out-of-band brand; the header is never a trust signal and never leaves.

Tests (red-before-green, via dispatch): a marker set via ctx.header() is stripped on a non-3xx middleware short-circuit (mergeStubHeaders), a response-route 200 (applyStubHeaders), and the request-context stub through the middleware chain (mergeReqCtxStub). Full gate green: typecheck, test:unit:all, lint, format; e2e redirect-guard + semantic-matrix (dev + production).

…ense-in-depth

# Conflicts:
#	packages/rangojs-router/docs/internal/feature-file-map.md
@ivogt
ivogt merged commit 0e9f3a1 into main Jun 14, 2026
25 checks passed
@ivogt
ivogt deleted the fix/open-redirect-defense-in-depth branch June 14, 2026 12:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant