fix: preserve csp-nonce on mergeHead, warn on dropped streamed resolve#1052
Conversation
#1050: mergeHead (the background full-body swap path) treated the live csp-nonce meta as stale whenever the incoming response carried a different per-request nonce, removing it and appending the incoming one. The browser enforces CSP against the ORIGINAL page-load nonce, so getCspNonce() then returned a nonce the active policy rejects and later nonce-stamped scripts/preloads were blocked. Skip csp-nonce in both mergeHead loops, the same carve-out reconcileHeadMetas already has. #1051: applyStreamedResolve silently drops a resolve whose boundary is absent. The drop is correct, but the silence is why the #1048 view-transition race was hard to diagnose. Add a dev-only, fire-once-per-id warning naming the dropped boundary; no behavior change, suppressed in production.
Review follow-ups: guard the csp-nonce skip in both mergeHead loops on tagName === 'META' (consistency with reconcileHeadMetas, closes a theoretical non-meta name=csp-nonce hole), drop a semicolon pause in the warnDropped message, and add the incoming-omits-csp-nonce mergeHead case.
vivek7405
left a comment
There was a problem hiding this comment.
Read through both fixes. The mergeHead csp-nonce carve-out and the dev-warn are correct and the tests hold. Three small things worth tightening: the nonce skip is called on every head child without a tag guard, the warn message has a semicolon pause, and the mergeHead test only covered the both-present case. All three addressed.
vivek7405
left a comment
There was a problem hiding this comment.
One more pass over the whole diff. Confirmed the production gate actually holds in browsers (the server injects process.env.NODE_ENV browser-side, ssr.js:1197, and the gate is byte-identical to devWarnFallback), the carve-out composes with the addNewHeadElements/reconcileHeadMetas exclusions so no path adds or removes a csp-nonce meta on any swap tier, and the streamer reaches the warn only past the isCurrent fence, so it stays quiet on routine superseded navs. One semantics note, accepted deliberately: warnOnce keys per id per page session, and boundary ids recycle across pages, so a second distinct s1 drop in one session is silent. That matches devWarnFallback convention, and dev iteration reloads reset it, so the first occurrence after any reload always surfaces. Nothing to change.
Add a 'CSP and the client router' subsection to the security docs page and a paragraph to the built-ins reference: enforcement is the HTTP header (not a meta http-equiv tag), the meta name=csp-nonce is a client-side carrier, and on a soft navigation the original page-load nonce stays authoritative and is re-stamped onto every dynamically-inserted script/preload (#1049/#1052). Gives users setting up strict CSP the why behind the model.
…ns (#1055) * docs: explain the CSP nonce model across client-router soft navigations Add a 'CSP and the client router' subsection to the security docs page and a paragraph to the built-ins reference: enforcement is the HTTP header (not a meta http-equiv tag), the meta name=csp-nonce is a client-side carrier, and on a soft navigation the original page-load nonce stays authoritative and is re-stamped onto every dynamically-inserted script/preload (#1049/#1052). Gives users setting up strict CSP the why behind the model. * docs: scope the soft-nav nonce claim to the document, not the session Review: 'the ORIGINAL nonce stays authoritative for the whole session' overstated. It holds for the life of the current document. A hard reload or full page load creates a new document with the response's own fresh nonce. Reword and add the harmless-full-load case so soft-nav vs hard-reload is clear (the built-ins reference already stated this conservatively).
Two small client-router fixes found during the #1049 review, both in
packages/core/src/router-client.js.Closes #1050 - mergeHead replaced the csp-nonce meta on a background full-body swap
mergeHead(used only on the background in-place full-body path: a popstate snapshot restore or its revalidation) removed the live<meta name="csp-nonce">and appended the incoming one whenever the per-request nonce differed (which it always does on a CSP-enabled app). That violatesgetCspNonce()'s contract, the browser enforces CSP against the nonce the ORIGINAL page load declared, so after the merge latercloneScriptWithCorrectNonce/cloneElementWithCorrectNonce-stamped scripts and modulepreloads got a nonce the active policy blocks. Same bug class #1049 fixed for the boundary/frame paths (reconcileHeadMetasexcludesMETA_KEY_CSP_NONCE);mergeHeadpredates it. Fix: skipcsp-noncein both mergeHead loops.Closes #1051 - dev-warn when a streamed Suspense resolve is dropped
applyStreamedResolvesilently drops a resolve whose boundary placeholder is absent. The drop is the correct designed posture (a superseded / degraded / discarded nav), and this PR does NOT change it (no retry, which would run outside the nav-token fence). But the silence is exactly what made the #1048 view-transition race take a full live-repro investigation. Add a dev-only, fire-once-per-idwarnDropped(id)naming the boundary, mirroring the existingdevWarnFallbackdev-diagnostic. Never warns in production, never throws.Tests
packages/core/test/routing/router-client.test.js): mergeHead keeps the original nonce (identity + content + a created script stamped with the original), no duplicate; the dropped-resolve warns once in dev, is silent on a successful resolve and in production. 185/185 pass.Definition-of-done surfaces