Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/ext/hx-nonce.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}` : ''
);
}

Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions www/src/content/docs/06-extensions/16-nonce.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ Partial responses should include a `Content-Security-Policy` header with a fresh
Content-Security-Policy: script-src 'nonce-<response-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 `<script nonce>`, `hx-nonce` attributes are not blanked by browsers after parse, so they are a possible additional nonce exposure surface. The scrub step is a defense-in-depth measure to ensure a stolen nonce cannot be pre-stamped into injected content to pass nonce checks.

## Inline Scripts in Swapped Content

When htmx swaps in HTML containing `<script>` tags, it re-creates them to trigger execution. The `hx-nonce` extension ensures the response nonce is rewritten to the page nonce before parsing, so script nonces are correctly promoted and execute under a strict `script-src 'nonce-<nonce>'` policy.
Expand Down