Skip to content

Commit dcc0492

Browse files
authored
Merge pull request #3 from HTMLTrust/feat/verify-pristine-html
feat(content-script): verify against pristine fetched HTML, not the DOM
2 parents efe2582 + 655126b commit dcc0492

1 file changed

Lines changed: 66 additions & 1 deletion

File tree

src/content-scripts/index.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323
import {
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

Comments
 (0)