2222 */
2323import {
2424 verifySignedSection ,
25+ extractSignedSections ,
2526 evaluateTrustPolicy ,
2627 defaultResolverChain ,
2728 type VerifyResult ,
@@ -258,6 +259,64 @@ async function autoVerifyPage(
258259
259260 pageVerifications . length = 0 ;
260261
262+ // Fetch the original served HTML so we can verify against the pristine
263+ // signed-section content rather than the live DOM. This sidesteps the
264+ // runtime-DOM-mutation problem: any client-side script that adds, removes,
265+ // or rewrites nodes inside a <signed-section> after page load (theme
266+ // copy-button injection, syntax highlighters, lazy-loaders, share widgets)
267+ // would otherwise make element.innerHTML disagree with what the signer
268+ // hashed. Documented as "Known Issue: Runtime DOM Mutation" in the spec
269+ // README.
270+ //
271+ // The DOM section is still used for UI placement (badge anchor, decoration)
272+ // — only the bytes fed to verifySignedSection come from the pristine fetch.
273+ //
274+ // Pristine slices are position-paired with live DOM sections by document
275+ // order. A page that re-orders signed-sections at runtime would defeat
276+ // this pairing; that case is out of scope (would also defeat any
277+ // signature-validity semantics).
278+ //
279+ // Fetch is cache-friendly: 'force-cache' lets the browser HTTP cache
280+ // serve this near-instantaneously on the typical reload-after-load path.
281+ // On the first load it's a duplicate of the navigation, which the HTTP
282+ // cache catches per RFC 7234 when the origin sets reasonable cache headers.
283+ let pristineSlices : string [ ] = [ ] ;
284+ let pristineFetchError : string | null = null ;
285+ try {
286+ const pageResp = await fetch ( window . location . href , {
287+ cache : 'force-cache' ,
288+ credentials : 'same-origin' ,
289+ } ) ;
290+ if ( ! pageResp . ok ) {
291+ pristineFetchError = `pristine fetch HTTP ${ pageResp . status } ` ;
292+ } else {
293+ const pageHTML = await pageResp . text ( ) ;
294+ pristineSlices = extractSignedSections ( pageHTML ) ;
295+ }
296+ } catch ( err ) {
297+ pristineFetchError = err instanceof Error ? err . message : String ( err ) ;
298+ }
299+
300+ // If the pristine fetch failed entirely OR returned a different count
301+ // than the DOM (page re-rendered between navigation and our fetch, SPA
302+ // route change, intercepting service worker), we fall back to per-section
303+ // DOM-based verification. The runtime-mutation false-invalid risk
304+ // re-applies, but it's better than no verification at all.
305+ if ( pristineFetchError || pristineSlices . length !== sections . length ) {
306+ if ( pristineFetchError ) {
307+ console . warn ( '[htmltrust] pristine fetch failed; falling back to DOM verify:' , pristineFetchError ) ;
308+ } else {
309+ console . warn (
310+ '[htmltrust] pristine fetch returned' ,
311+ pristineSlices . length ,
312+ 'sections but DOM has' ,
313+ sections . length ,
314+ '— falling back to DOM verify' ,
315+ ) ;
316+ }
317+ pristineSlices = [ ] ;
318+ }
319+
261320 let i = 0 ;
262321 for ( const section of Array . from ( sections ) ) {
263322 // Idempotency: skip sections we've already decorated.
@@ -266,7 +325,13 @@ async function autoVerifyPage(
266325 }
267326
268327 try {
269- const verify = await verifySignedSection ( section , {
328+ // Prefer the pristine HTML slice (immune to runtime DOM mutation);
329+ // fall back to the live DOM element when pristine fetch failed or
330+ // the counts didn't match.
331+ const verifyInput : Element | string = pristineSlices . length
332+ ? pristineSlices [ i ]
333+ : section ;
334+ const verify = await verifySignedSection ( verifyInput , {
270335 keyResolvers : resolverChain ,
271336 domain,
272337 debug : true ,
0 commit comments