From b3a4ba1b3f697350da04768010689386934443bc Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Tue, 5 May 2026 20:06:52 +1200 Subject: [PATCH 1/3] add nonce reuse protection to hx-nonce --- src/ext/hx-nonce.js | 13 ++++++++----- www/src/content/docs/06-extensions/16-nonce.md | 8 ++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/ext/hx-nonce.js b/src/ext/hx-nonce.js index ad434c1d1..76a13f267 100644 --- a/src/ext/hx-nonce.js +++ b/src/ext/hx-nonce.js @@ -38,8 +38,7 @@ function checkNonce(elt) { if (!pageNonce) return false; let eltNonce = getNonce(elt); - if (eltNonce === pageNonce) return; - if (stripHxAttributes(elt, eltNonce)) return false; + if (eltNonce !== pageNonce && stripHxAttributes(elt, eltNonce)) return false; } // Anchors to script-src/default-src to avoid matching nonces in other directives @@ -59,13 +58,14 @@ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } - // Rewrites responseNonce → pageNonce in raw HTML before DOM parsing, + // Rewrites responseNonce → replacement in raw HTML before DOM parsing, // covering hx-nonce and script nonce attributes in one pass. - function rewriteNoncesInText(text, responseNonce) { + // Pass replacement='' to strip nonce attributes entirely (stolen-nonce scrub). + function rewriteNoncesInText(text, responseNonce, replacement = pageNonce) { let escaped = escapeRegex(responseNonce); return text.replace( new RegExp(`(nonce=)(["'])${escaped}\\2`, 'gi'), - (_, attr, quote) => `${attr}${quote}${pageNonce}${quote}` + (_, attr, quote) => replacement ? `${attr}${quote}${replacement}${quote}` : '' ); } @@ -162,6 +162,9 @@ try { if (new URL(responseURL).origin !== location.origin) return; } catch (_) { return; } } + // Scrub any pre-existing pageNonce from response — server can't know it, + // so its presence indicates injection using a stolen nonce. + ctx.text = rewriteNoncesInText(ctx.text, pageNonce, ''); let responseNonce = extractNonceFromCSP(ctx?.response?.headers?.get('Content-Security-Policy')) ?? extractNonceFromMetaTag(ctx?.text); if (responseNonce && responseNonce !== pageNonce) { diff --git a/www/src/content/docs/06-extensions/16-nonce.md b/www/src/content/docs/06-extensions/16-nonce.md index 38dc602f8..1c5649a51 100644 --- a/www/src/content/docs/06-extensions/16-nonce.md +++ b/www/src/content/docs/06-extensions/16-nonce.md @@ -93,6 +93,14 @@ Partial responses should include a `Content-Security-Policy` header with a fresh Content-Security-Policy: script-src 'nonce-' ``` +## Nonce Reuse Protection + +The extension rewrites the response nonce to the page nonce so swapped-in elements pass subsequent nonce checks. Before doing that rewrite, it scrubs any element that already carries the page nonce value from the raw response text. + +The server cannot know the page nonce — it only knows its own per-response nonce. So if the page nonce appears in a response, it was put there by an attacker, not the server. Scrubbing it first means the rewrite pass cannot accidentally promote attacker-controlled elements to trusted status. + +The risk: unlike `