Skip to content

PPR shell capture ignores a per-request nonce set via the nonce ContextVar token (only createRouter({ nonce }) gates it) → shared shell bakes a frozen nonce #656

Closed

Description

@ivogt

Summary

PPR's axis-1 safety-gate for a per-request CSP nonce only accounts for a nonce produced by the createRouter({ nonce }) provider option. A per-request nonce supplied via the nonce ContextVar token (ctx.set(nonce, value) in middleware) is rendered into the document by useNonce() (Scripts/MetaTags) but is invisible to the PPR gate. As a result a ppr route still enters capture and bakes the capture request's nonce into the shared shell; on a HIT that frozen nonce is served to every request.

This contradicts the documented guarantee (skills/ppr → "What always stays on axis 1": "per-request CSP nonce"). The guarantee holds for provider-supplied nonces only.

Provenance (code refs, packages/rangojs-router/src)

  • Gate: rsc/rsc-rendering.ts:110 — nonce === undefined is one of the PPR entry conditions; nonce is a threaded param of handleRscRendering / handleRscRenderingInner (:53, :80).
  • nonce source is the provider only: rsc/handler.ts:155 destructures nonce: nonceProvider from createRouter options; :362-366 sets let nonce only if (nonceProvider); :383-385 then sets both the nonceToken ContextVar and variables.nonce; the value is threaded down to handleRscRendering(… nonce) at :1038.
  • Token path bypasses the gate: an app that does not pass createRouter({ nonce }) but instead sets the token directly in middleware — ctx.set(nonce, perRequestValue) — has nonceProvider === undefined, so the gate's nonce stays undefined and PPR proceeds. The token nonce is still rendered into the HTML via useNonce() (handles/Scripts.tsx:158, handles/MetaTags.tsx:201), baking nonce="…" into every nonced <script>/<style>/meta tag.

Repro (minimal)

  1. createRouter({ document, urls, cache }) — no nonce option.
  2. Middleware sets a per-request nonce via the token: ctx.set(nonce, crypto.randomUUID()) (or mirror a per-request CSP nonce).
  3. path("/", Page, { name: "home", ppr: true }, () => [loading(<Skeleton/>)]), shell material in a layout.
  4. GET / with Accept: text/html.

Observed: x-rango-shell: MISS and a background capture runs despite the per-request nonce; on a warm HIT the served shell carries the capture-time nonce, byte-identical across distinct requests.

Observed in a real app

@rangojs/[email protected], React 19, Cloudflare Workers (vite RSC dev), CFCacheStore. The app supplies its per-request CSP nonce via the token (ctx.set(nonce, cspNonce) in global middleware), not via createRouter({ nonce }). With that nonce ON — ~850 nonce="…" attributes present in the rendered HTML — an Accept: text/html GET to a ppr: true route still enters capture (x-rango-shell: MISS). The gate did not treat the token nonce as blocking.

(That route never reaches HIT in this app only because it also lacks a route-level loading() boundary — a separate, correctly-warned "structural negative" condition. That warning masks the nonce problem: once a loading() hole is added to make the route capturable, the shared-frozen-nonce behavior surfaces.)

Impact

The "per-request CSP nonce always stays on axis 1" invariant does not hold for token-supplied nonces — they fall through to the "residual hazard: middleware-derived per-user state photographed into the shared shell" path silently. With a strict per-request CSP response header, HIT responses serve a stale nonce to every visitor → the browser's CSP blocks the nonced inline scripts/styles for all but the capture request.

Suggested fix (one of)

  1. Make the gate see the token nonce — in the PPR entry condition, also treat a token-set nonce as "nonce present" (read nonceToken from the request variables, not just the provider-derived nonce) and fall to axis 1 when set.
  2. Warn once — when a ppr route's capture render emits a nonce that was not declared via createRouter({ nonce }) (declared-intent-cannot-be-honored, mirroring the missing-shell-family warnShellStoreMissingOnce).
  3. Document that per-request nonces MUST be supplied via createRouter({ nonce }) for the PPR safety-gate to apply, and that the token path is unsafe under ppr.

Environment

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