Skip to content

Gap-review follow-ups: security defense-in-depth + @rangojs/router/testing refinements #572

Closed

Description

@ivogt

Summary

A targeted review of surfaces the original per-subsystem pass missed (the new @rangojs/router/testing module, a focused security pass, cross-subsystem seams, and an adversarial self-review) ran after the stabilization PR. The HIGH finding (cross-host loader-cache leak) and the M9 completion + MiddlewareFn export were fixed in PR #571. This issue tracks the remaining lower-severity / decision findings.

Security (defense-in-depth — mostly app-responsibility, decide on guarding in-framework)

medium — Open redirect on the no-JS (PE) / full-page path: redirect target not origin-validated server-side

The client has validate-redirect-origin.ts, but the server PE/full-page redirect path does not re-validate the target.

// If an app does this with a user-controlled value:
path("/login", async (ctx) => redirect(ctx.searchParams.get("returnTo") ?? "/"))
// ?returnTo=https://evil.example becomes an open redirect on the no-JS path.

This is partly app-responsibility (don't pass unvalidated user input to redirect()), but the JS and PE paths differ in protection. Decide: add a server-side same-origin check (or opt-in allowlist) for redirects, or document the contract explicitly. progressive-enhancement.ts:130-131,169-171,236-241, redirect.ts:104-110.

low — defaultOriginCheck trusts the request URL host when the Host header is absent

origin-guard.ts:100-106 — weakens the CSRF gate when no Host/Origin header is present. Tighten to fail-closed when origin cannot be established.

low — PE direct-action passes raw FormData as action args when Flight decode fails (the JS path throws)

progressive-enhancement.ts:158-167 — JS/PE divergence; the PE path should match the JS path's reject-on-decode-failure.

low — Loaded server action is invoked before verifying the requested action ID belongs to the matched route

server-action.ts:99-100, progressive-enhancement.ts:103-108,166-167 — verify ownership before invocation.

info — Action-arg decode failure surfaces raw error detail in the thrown message (JS path) — server-action.ts:87-91.

@rangojs/router/testing module refinements (the new #533 surface)

medium — dispatch() json route lacks the nested-Promise guard production enforces

dispatch.ts:165-173 does a bare JSON.stringify(result); production (response-route-handler.ts:139-156) uses a replacer that throws RESPONSE_NOT_SERIALIZABLE on a nested unresolved promise. A dispatch json test of () => ({ data: fetchSomething() }) (forgotten await) goes green while production would 500. Reuse production's replacer so the test rejects exactly as production does.

low — renderToFlightString/renderServerTree leak ctx.reverse to the GLOBAL route map

flight.ts:175-178 — no routeMap option; setRequestContextParams rebuilds ctx.reverse against getGlobalRouteMap(), so a server component that reverses is order-dependent on whatever router registered last. Add a scoped routeMap option (like renderHandler) or document.

low — renderRoute mutates the consumer's real loader/locationState/handle objects to assign synthetic ids (render-route.tsx:74-87). Clone before stamping.

info — dispatch resolves cache config with executionContext=undefined, diverging from production (dispatch.ts:339-346).

Cross-seam (low)

  • Prerendered route served on a prerender hit never records its cache({ tags }) into the document tag union (cache-lookup.ts:153-217, 287-309).
  • Document-cache tag union can miss first-write segment-DSL tags when combined with proactive/background segment caching (documented caveat; the proactive path widens the window).

Self-review (low)

  • loaderParentId regex has a residual edge for a slot name matching D<digits>. (e.g. @D3.foo) — segment-system.tsx. Pathological, but could be hardened by validating slot names at definition time.

Note

The self-review confirmed all PR #571 stabilization fixes are correct (trie backtracking, loader wrap, passthrough detect, loud cache errors, dead-export removals). These follow-ups are refinements, not regressions.

Activity

  1. reopened this on Jun 14, 2026
  2. ivogt commented on Jun 14, 2026

    @ivogt
    CollaboratorAuthor

    Closing — the Security defense-in-depth section is resolved by #580 (merged), and the remaining non-security items are split into a fresh, scoped issue (#582).

    Security section — resolved by #580

    Item Outcome
    medium — open redirect on the no-JS PE / full-page path Fixed. Same-origin guard at the single handler chokepoint; during review hardened further — the { external: true } opt-in is an unforgeable out-of-band brand (not a wire header), and external is http(s)-only so it can't become a scriptable location.assign.
    low — defaultOriginCheck trusts request URL host when Host absent Fixed. Fails closed when the host can't be established.
    info — action-arg decode failure leaks raw error detail (JS path) Fixed. Generic message; original kept on cause for server logs.
    low — PE direct-action passes raw FormData when Flight decode fails Declined (false positive). decodeReply legitimately throws for a raw <form action={fn}> POST; the [formData] fallback is the React form-action convention. The JS↔PE divergence is intentional and now documented in progressive-enhancement.ts.
    low — verify action ID belongs to the matched route before invocation Deferred (documented). 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.

    Remaining items → #582

    The @rangojs/router/testing refinements, cross-seam tag-union gaps, and the loaderParentId self-review item are tracked in #582. Note: #581 (merged) partially touched two testing items (routeName option, cloneHandleSeed) but did not fully satisfy the original asks (scoped routeMap; no consumer-object mutation) and did not touch the two dispatch.ts items — all carried forward to #582.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions