You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
mergeHead (the full remove+add head merge used ONLY on the background in-place full-body swap path, a snapshot restore or its revalidation) treats the live <meta name="csp-nonce"> as stale whenever the incoming response carries a different per-request nonce (which it always does on a CSP-enabled app), removes it, and appends the incoming one. That violates the contract documented on getCspNonce(): the browser enforces CSP against the nonce the ORIGINAL page load declared, so after the merge getCspNonce() returns a nonce the active policy does not allow, and every later cloneScriptWithCorrectNonce / cloneElementWithCorrectNonce-stamped script or modulepreload is blocked.
This is the same bug class PR #1049 fixed for the boundary/frame paths (reconcileHeadMetas excludes META_KEY_CSP_NONCE), found while reviewing that PR. mergeHead predates #1049 and was not touched by it, so this is a pre-existing gap on the background path, filed separately.
Reach is narrow: CSP opt-in (webjs.csp) + a popstate snapshot restore's background revalidation (the only mergeHead caller) + a later dynamically-inserted nonce-stamped element. But when it hits, scripts silently stop executing until a hard reload.
Design / approach
Give mergeHead the same csp-nonce carve-out reconcileHeadMetas has: skip meta[name="csp-nonce"] in BOTH its removal loop and its add loop (the incoming per-request nonce is useless client-side anyway, since the enforced header is the original page load's). META_KEY_CSP_NONCE / metaIdentity already exist in the same file, so the check can reuse them or just match the name attribute directly.
Implementation notes (for the implementing agent)
Where to edit: packages/core/src/router-client.jsmergeHead (~L3895). The removal loop (~L3915-3928) and the add loop (~L3930-3943) both need the skip. META_KEY_CSP_NONCE + metaIdentity are defined ~L3741-3770.
Landmine: the existing test mergeHead: applies meta csp-nonce to created scripts (replaces source nonce) (packages/core/test/routing/router-client.test.js ~L942) uses the SAME nonce on both sides, so it passes today and does not cover this. Do not weaken it; add the differing-nonce case alongside it.
The counterfactual: live head <meta name="csp-nonce" content="ORIGINAL">, incoming head with content="INCOMING", run _mergeHead, assert exactly one csp-nonce meta remains with ORIGINAL content. Fails before the fix (the meta is replaced).
Tests: node-level in router-client.test.js (the _merge harness export already exists). No browser layer strictly needed (pure head-merge logic, same as the existing mergeHead tests), but confirm nothing in the CSP browser suites asserts the opposite.
Acceptance criteria
After mergeHead with a differing incoming nonce, the live csp-nonce meta keeps the ORIGINAL content and no duplicate exists
Scripts created after the merge get the original page-load nonce via getCspNonce()
The existing same-nonce test still passes; a differing-nonce counterfactual fails when the fix is reverted
Problem
mergeHead(the full remove+add head merge used ONLY on the background in-place full-body swap path, a snapshot restore or its revalidation) treats the live<meta name="csp-nonce">as stale whenever the incoming response carries a different per-request nonce (which it always does on a CSP-enabled app), removes it, and appends the incoming one. That violates the contract documented ongetCspNonce(): the browser enforces CSP against the nonce the ORIGINAL page load declared, so after the mergegetCspNonce()returns a nonce the active policy does not allow, and every latercloneScriptWithCorrectNonce/cloneElementWithCorrectNonce-stamped script or modulepreload is blocked.This is the same bug class PR #1049 fixed for the boundary/frame paths (
reconcileHeadMetasexcludesMETA_KEY_CSP_NONCE), found while reviewing that PR.mergeHeadpredates #1049 and was not touched by it, so this is a pre-existing gap on the background path, filed separately.Reach is narrow: CSP opt-in (
webjs.csp) + a popstate snapshot restore's background revalidation (the onlymergeHeadcaller) + a later dynamically-inserted nonce-stamped element. But when it hits, scripts silently stop executing until a hard reload.Design / approach
Give
mergeHeadthe same csp-nonce carve-outreconcileHeadMetashas: skipmeta[name="csp-nonce"]in BOTH its removal loop and its add loop (the incoming per-request nonce is useless client-side anyway, since the enforced header is the original page load's).META_KEY_CSP_NONCE/metaIdentityalready exist in the same file, so the check can reuse them or just match the name attribute directly.Implementation notes (for the implementing agent)
packages/core/src/router-client.jsmergeHead(~L3895). The removal loop (~L3915-3928) and the add loop (~L3930-3943) both need the skip.META_KEY_CSP_NONCE+metaIdentityare defined ~L3741-3770.mergeHead: applies meta csp-nonce to created scripts (replaces source nonce)(packages/core/test/routing/router-client.test.js~L942) uses the SAME nonce on both sides, so it passes today and does not cover this. Do not weaken it; add the differing-nonce case alongside it.<meta name="csp-nonce" content="ORIGINAL">, incoming head withcontent="INCOMING", run_mergeHead, assert exactly one csp-nonce meta remains withORIGINALcontent. Fails before the fix (the meta is replaced)..js+ JSDoc only inpackages/; dogfood: CSS drops on client-router soft nav on real Android Chrome (styled on refresh) #936 stylesheet preservation inmergeHeadmust stay untouched.router-client.test.js(the_mergeharness export already exists). No browser layer strictly needed (pure head-merge logic, same as the existing mergeHead tests), but confirm nothing in the CSP browser suites asserts the opposite.Acceptance criteria
mergeHeadwith a differing incoming nonce, the live csp-nonce meta keeps the ORIGINAL content and no duplicate existsgetCspNonce()