diff --git a/README.md b/README.md index 46b1a8f..71bddac 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ After each page load or same-document navigation, the content script refetches t - npm - Chromium, Firefox, or Safari for loading a built extension -The published dependency is pinned to browser-client commit `698a6fba7ada94ea1e26348dda0e1c87e8dd8fc9`. A sibling browser-client checkout is optional. Use one when developing both repositories together. +The published dependency is pinned to browser-client commit `a846e3d971ab93bdcc8a9f599fb1987828344983`. A sibling browser-client checkout is optional. Use one when developing both repositories together. For a standalone checkout: @@ -67,7 +67,7 @@ container with: ``` The script copies this checkout into the container, installs the pinned browser -client from Git, runs 60 extension tests, checks types and lint, then builds all +client from Git, runs 63 extension tests, checks types and lint, then builds all three browser packages. Generated files stay outside the checkout. ### Build diff --git a/package-lock.json b/package-lock.json index acb7521..4fef15e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { - "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#698a6fba7ada94ea1e26348dda0e1c87e8dd8fc9", + "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#a846e3d971ab93bdcc8a9f599fb1987828344983", "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/refs/tags/v0.2.2.tar.gz", "@simplewebauthn/typescript-types": "^8.3.4", "axios": "^1.9.0", @@ -790,8 +790,8 @@ }, "node_modules/@htmltrust/browser-client": { "version": "0.1.2", - "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-browser-client.git#698a6fba7ada94ea1e26348dda0e1c87e8dd8fc9", - "integrity": "sha512-ZJQNS/+dV5av/jATwQJuWHCiN7l1k+oRgzwPXWwQ+RCMZDaAnOkRu1ovyFR4nFHaVZaVi0qX/Um7v0lLVYVNvg==", + "resolved": "git+ssh://git@github.com/HTMLTrust/htmltrust-browser-client.git#a846e3d971ab93bdcc8a9f599fb1987828344983", + "integrity": "sha512-tsYFw9rPhxILM525gsUgBSIr3e1fDCnhdwaeQSFSaw1f7qmUXNNw9zV2ZnqF6Y5L+XwtbSPsD+GN66OZyoT5vQ==", "license": "LicenseRef-PolyForm-Noncommercial-1.0.0", "dependencies": { "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/refs/tags/v0.2.2.tar.gz" diff --git a/package.json b/package.json index 685f8d2..c93e426 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "webpack-cli": "^6.0.1" }, "dependencies": { - "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#698a6fba7ada94ea1e26348dda0e1c87e8dd8fc9", + "@htmltrust/browser-client": "git+https://github.com/HTMLTrust/htmltrust-browser-client.git#a846e3d971ab93bdcc8a9f599fb1987828344983", "@htmltrust/canonicalization": "https://github.com/HTMLTrust/htmltrust-canonicalization/archive/refs/tags/v0.2.2.tar.gz", "@simplewebauthn/typescript-types": "^8.3.4", "axios": "^1.9.0", diff --git a/src/content-scripts/auto-verify.test.ts b/src/content-scripts/auto-verify.test.ts index d6071a8..c95ecd3 100644 --- a/src/content-scripts/auto-verify.test.ts +++ b/src/content-scripts/auto-verify.test.ts @@ -7,8 +7,8 @@ * verify here instead are the load-bearing invariants the migration is * supposed to guarantee: * - * 1. The selector `signed-section[signature]` finds the elements the - * content script's autoVerifyPage walks over. + * 1. The selector `signed-section` finds every element the content + * script's autoVerifyPage walks over, including malformed sections. * 2. Mocking @htmltrust/browser-client and replaying the same end-to-end * shape autoVerifyPage uses produces a badge container with the * expected CSS classes for both verified and unverified results, and @@ -27,6 +27,7 @@ * not apply to test fixtures. */ import { CSS_CLASSES } from '../core/common/constants'; +import { SIGNED_SECTION_SELECTOR } from '../core/content/navigation-lifecycle'; jest.mock('@htmltrust/browser-client', () => ({ verifySignedSection: jest.fn(), @@ -105,7 +106,7 @@ function buildErrorBadges(): HTMLElement { * isolates the DOM-walking + lib-invocation + badge-insertion logic. */ async function autoVerifyPage(): Promise { - const sections = document.querySelectorAll('signed-section[signature]'); + const sections = document.querySelectorAll(SIGNED_SECTION_SELECTOR); for (const section of Array.from(sections)) { if (section.nextElementSibling?.classList.contains(AUTO_BADGE_MARKER)) { continue; @@ -135,17 +136,18 @@ describe('content script auto-verify (selector and badge wiring)', () => { jest.clearAllMocks(); }); - it('querySelectorAll(signed-section[signature]) finds signed sections only', () => { + it('querySelectorAll(signed-section) includes malformed sections for failure reporting', () => { fixture(` a b
c
d `); - const found = document.querySelectorAll('signed-section[signature]'); - expect(found.length).toBe(2); + const found = document.querySelectorAll(SIGNED_SECTION_SELECTOR); + expect(found.length).toBe(3); expect(found[0].id).toBe('s1'); - expect(found[1].id).toBe('s4'); + expect(found[1].id).toBe('s2'); + expect(found[2].id).toBe('s4'); }); it('calls verifySignedSection for each signed-section on the page', async () => { diff --git a/src/content-scripts/index.ts b/src/content-scripts/index.ts index 64ee03f..d0cbd97 100644 --- a/src/content-scripts/index.ts +++ b/src/content-scripts/index.ts @@ -5,7 +5,7 @@ * * 1. Auto-verify on page load. On DOMContentLoaded (the manifest registers * this script as document_idle equivalent for content_scripts), find - * every on the page, verify each via + * every on the page, verify each via * @htmltrust/browser-client (Layer 1, SubtleCrypto-backed), evaluate the * trust policy locally (Layer 2), and inject the corresponding status * marker beside each section. No popup interaction required. @@ -33,9 +33,14 @@ import { MESSAGE_TYPES, CSS_CLASSES, TRUST_STATUS, STORAGE_KEYS } from '../core/ import { ContentProcessor } from '../core/content'; import { captureNavigationSnapshot, + documentBaseUrl, mapSnapshotToLiveSections, + mutationTouchesDocumentBase, + observeDocumentBase, observeSignedSection, + outermostSignedSection, SIGNED_SECTION_SELECTOR, + sourceElementForSnapshot, type NavigationSnapshot, } from '../core/content/navigation-lifecycle'; import { PlatformAdapter, MessageContext } from '../platforms/common'; @@ -99,6 +104,7 @@ type SectionVerificationRun = { const pageVerifications: PageVerification[] = []; const pageVerificationBySection = new WeakMap(); const sectionObserverDisposers = new WeakMap void>(); +const sectionMarkers = new WeakMap(); let navigationSnapshot: NavigationSnapshot | null = null; let observedSections = new Set(); let rerenderObserver: MutationObserver | null = null; @@ -107,6 +113,7 @@ let rerenderTimer: ReturnType | null = null; let navigationPollTimer: ReturnType | null = null; let lastObservedUrl = ''; let lifecycleInstalled = false; +let baseObserverDisposer: (() => void) | null = null; const sectionReverifyGeneration = new WeakMap(); /** @@ -284,7 +291,11 @@ function installNavigationLifecycle(): void { return result; }; } - rerenderObserver = new MutationObserver(() => { + rerenderObserver = new MutationObserver((mutations) => { + if (mutations.some(mutationTouchesDocumentBase)) { + notify(); + return; + } const current = new Set(document.querySelectorAll(SIGNED_SECTION_SELECTOR)); if (current.size !== observedSections.size || [...current].some((section) => !observedSections.has(section))) { notify(); @@ -293,6 +304,7 @@ function installNavigationLifecycle(): void { if (document.documentElement) { rerenderObserver.observe(document.documentElement, { childList: true, subtree: true }); } + baseObserverDisposer = observeDocumentBase(document, notify); // Extension content scripts run in an isolated JavaScript world in Chromium. // A page-world pushState call may bypass the wrapper above, so compare the // shared location on a short interval as a cross-browser fallback. @@ -305,6 +317,8 @@ function installNavigationLifecycle(): void { navigationPollTimer = null; } rerenderObserver?.disconnect(); + baseObserverDisposer?.(); + baseObserverDisposer = null; observedSections.forEach((section) => sectionObserverDisposers.get(section)?.()); }, { once: true }); } @@ -344,6 +358,12 @@ function currentOrigin(): string { return window.location.origin; } +function sourceFailureReason(error: string | null): VerifyResult['reason'] { + return error?.startsWith('network-policy-blocked') + ? 'network-policy-blocked' + : 'source-refetch-failed'; +} + function redactForLog(value: unknown): unknown { if (typeof value === 'string') { if (value.length > 80) return `${value.slice(0, 24)}...[redacted:${value.length}]`; @@ -427,9 +447,11 @@ async function fetchPristineSignedSections(settings: Settings): Promise<{ async function verifySectionWithState( section: Element, - sourceSlice: string | null, + sourceElement: Element | null, + sourceBaseUrl: string | null, resolverChain: KeyResolver[], settings: Settings, + sourceFailure?: VerifyResult['reason'] | null, ): Promise { const origin = currentOrigin(); const options = { @@ -440,23 +462,39 @@ async function verifySectionWithState( debug: settings.developerDebugLogging === true, }; - if (!sourceSlice) { - const verify = await verifySignedSection(section, options); - const inputState = verify.inputState as VerificationInputState; + if (!sourceElement) { + // A live DOM is not an accepted Layer-1 source. Page script can construct + // or rewrite it after navigation, so treating it as verified would make a + // valid indicator attacker-controlled. + const verify: VerifyResult = { + valid: false, + keyid: section.getAttribute('keyid') ?? '', + algorithm: section.getAttribute('algorithm') ?? '', + contentHash: section.getAttribute('content-hash') ?? '', + claimsHash: '', + claims: {}, + signedAt: '', + domain: origin, + origin, + inputState: 'source-only', + reason: sourceFailure ?? 'source-refetch-failed', + }; return { verify, - inputState, + inputState: 'source-only', sourceVerified: false, - renderedVerified: verify.valid && inputState === 'rendered-match', - displayValid: verify.valid && inputState === 'rendered-match', - reason: verify.valid ? null : verify.reason ?? 'unknown', + renderedVerified: false, + displayValid: false, + reason: verify.reason ?? 'source-refetch-failed', }; } - const sourceVerify = await verifySignedSection(sourceSlice, { + const sourceVerify = await verifySignedSection(sourceElement, { ...options, + baseUrl: sourceBaseUrl ?? options.baseUrl, + renderedBaseUrl: documentBaseUrl(document, window.location.href), renderedSection: section, - }); + } as Parameters[1]); const inputState = sourceVerify.inputState as VerificationInputState; if (!sourceVerify.valid) { return { @@ -485,7 +523,7 @@ async function verifySectionWithState( } /** - * Walk every on the page and verify it locally. + * Walk every on the page and verify it locally. * * Each section is verified independently. A failure on one does not skip * the others. Markers are inserted as the next sibling of the section @@ -548,13 +586,11 @@ async function autoVerifyPage( ? mapSnapshotToLiveSections(fetchedSnapshot, liveSections) : { matches: [], complete: false }; - // If the pristine fetch failed entirely OR returned a different count - // than the DOM (page re-rendered between navigation and our fetch, SPA - // route change, intercepting service worker), we fall back to per-section - // DOM-based verification. The runtime-mutation false-invalid risk - // re-applies, but it's better than no verification at all. + // A missing or ambiguous source is a hard failure. Verifying the live DOM + // here would allow page script to manufacture a valid result with no + // accepted source representation. if (pristineFetchError || !mapped.complete) { - debugLog(settings, 'source snapshot unavailable; falling back to rendered DOM verification', { + debugLog(settings, 'source snapshot unavailable; refusing rendered-DOM verification', { reason: pristineFetchError, sourceSections: fetchedSnapshot?.sections.length ?? 0, renderedSections: sections.length, @@ -566,16 +602,21 @@ async function autoVerifyPage( for (const section of liveSections) { if (expectedNavigationRun !== navigationRun) return; // Idempotency: skip sections we've already decorated. - if (section.nextElementSibling?.classList.contains(AUTO_BADGE_MARKER)) { + const knownMarker = sectionMarkers.get(section); + if (knownMarker && !knownMarker.isConnected) sectionMarkers.delete(section); + if ((knownMarker?.isConnected ?? false) || section.nextElementSibling?.classList.contains(AUTO_BADGE_MARKER)) { continue; } try { + const match = mapped.matches.find((candidate) => candidate.live === section); const run = await verifySectionWithState( section, - mapped.complete ? (mapped.matches.find((match) => match.live === section)?.source.outerHTML ?? null) : null, + mapped.complete ? (match ? sourceElementForSnapshot(match.source) : null) : null, + fetchedSnapshot?.baseUrl ?? null, resolverChain, settings, + pristineFetchError || !mapped.complete ? sourceFailureReason(pristineFetchError) : null, ); const verify = run.verify; @@ -613,7 +654,13 @@ async function autoVerifyPage( }; pageVerifications.push(pageVerification); pageVerificationBySection.set(section, pageVerification); - armSectionMutationInvalidation(section, mapped.complete ? (mapped.matches.find((match) => match.live === section)?.source.outerHTML ?? null) : null, resolverChain, settings); + armSectionMutationInvalidation( + section, + mapped.complete ? (match ? sourceElementForSnapshot(match.source) : null) : null, + fetchedSnapshot?.baseUrl ?? null, + resolverChain, + settings, + ); } catch (err) { const reason = (err as Error).message ?? 'verification error'; console.error('Content Signing: verification failed for a signed-section'); @@ -638,7 +685,7 @@ async function autoVerifyPage( }; pageVerifications.push(pageVerification); pageVerificationBySection.set(section, pageVerification); - armSectionMutationInvalidation(section, null, resolverChain, settings); + armSectionMutationInvalidation(section, null, null, resolverChain, settings); } i++; } @@ -713,11 +760,21 @@ function applySectionStatusUI( sig.textContent = valid ? '✓' : stale || verify?.valid ? '!' : '✗'; sig.title = tooltip; badges.appendChild(sig); - section.parentNode?.insertBefore(badges, section.nextSibling); + const anchor = outermostSignedSection(section); + anchor.parentNode?.insertBefore(badges, anchor.nextSibling); + sectionMarkers.set(section, badges); } /** Remove only extension-owned sibling UI, leaving signed content untouched. */ function clearSectionStatusUI(section: Element): void { + const marker = sectionMarkers.get(section); + if (marker) { + marker.remove(); + sectionMarkers.delete(section); + return; + } + // Legacy markers created before this module's nested-section anchor was + // introduced may still be direct siblings. Remove only our own marker. let sibling = section.nextElementSibling; while (sibling?.classList.contains(AUTO_BADGE_MARKER)) { const next = sibling.nextElementSibling; @@ -729,7 +786,8 @@ function clearSectionStatusUI(section: Element): void { /** Re-verify a section after live content changes, against its frozen source. */ function armSectionMutationInvalidation( section: Element, - sourceSlice: string | null, + sourceElement: Element | null, + sourceBaseUrl: string | null, resolverChain: KeyResolver[], settings: Settings, ): void { @@ -752,7 +810,8 @@ function armSectionMutationInvalidation( try { const run = await verifySectionWithState( changedSection, - sourceSlice, + sourceElement, + sourceBaseUrl, currentResolverChain.length ? currentResolverChain : resolverChain, activeSettings, ); @@ -1025,7 +1084,9 @@ function addVerificationBadges(element: Element, verificationResult: Verificatio // Keep extension UI outside the signed element. This prevents a badge or // tooltip from becoming part of the bytes that the signature protects. - element.parentNode?.insertBefore(badgeContainer, element.nextSibling); + const anchor = outermostSignedSection(element); + anchor.parentNode?.insertBefore(badgeContainer, anchor.nextSibling); + sectionMarkers.set(element, badgeContainer); } catch (error) { console.error('Failed to add verification badges:', error); } diff --git a/src/core/content/navigation-lifecycle.test.ts b/src/core/content/navigation-lifecycle.test.ts index 3fd3366..4fbb9f3 100644 --- a/src/core/content/navigation-lifecycle.test.ts +++ b/src/core/content/navigation-lifecycle.test.ts @@ -3,6 +3,9 @@ import { mapSnapshotToLiveSections, mutationTouchesSignedSection, observeSignedSection, + mutationTouchesDocumentBase, + outermostSignedSection, + sourceElementForSnapshot, SIGNED_SECTION_SELECTOR, } from './navigation-lifecycle'; @@ -19,6 +22,17 @@ describe('navigation lifecycle snapshots', () => { expect(Object.isFrozen(snapshot)).toBe(true); expect(Object.isFrozen(snapshot.sections)).toBe(true); expect(Object.isFrozen(snapshot.sections[0])).toBe(true); + expect(sourceElementForSnapshot(snapshot.sections[0])?.localName).toBe('signed-section'); + }); + + it('keeps nested sections paired as parser nodes and computes the source base URL', () => { + const snapshot = captureNavigationSnapshot( + 'inner', + 'https://example.test/article', + ); + expect(snapshot.baseUrl).toBe('https://cdn.example/assets/'); + expect(snapshot.sections.map((section) => section.identity.includes('signature=')).every(Boolean)).toBe(true); + expect(sourceElementForSnapshot(snapshot.sections[0])?.querySelector('signed-section')?.getAttribute('signature')).toBe('inner'); }); it('maps reordered live sections by signed identity rather than array position', () => { @@ -37,6 +51,13 @@ describe('navigation lifecycle snapshots', () => { expect(result.matches.map((match) => match.live.textContent)).toEqual(['B changed', 'A changed']); }); + it('anchors nested-section markers after the outermost signed section', () => { + document.body.innerHTML = 'text'; + const outer = document.querySelector('signed-section')!; + const inner = outer.querySelector('signed-section')!; + expect(outermostSignedSection(inner)).toBe(outer); + }); + it('marks a missing or added live section as an incomplete mapping', () => { const source = captureNavigationSnapshot( 'A', @@ -81,6 +102,14 @@ describe('signed-section mutation invalidation', () => { expect(mutationTouchesSignedSection({ type: 'attributes', target: document.querySelector('#indicator')! } as unknown as MutationRecord, section)).toBe(false); }); + it('recognizes source base URL changes but not ordinary sibling mutations', () => { + document.body.innerHTML = 'text
'; + const base = document.querySelector('base')!; + const indicator = document.querySelector('#indicator')!; + expect(mutationTouchesDocumentBase({ type: 'attributes', target: base, attributeName: 'href' } as unknown as MutationRecord)).toBe(true); + expect(mutationTouchesDocumentBase({ type: 'attributes', target: indicator, attributeName: 'class' } as unknown as MutationRecord)).toBe(false); + }); + it('notifies after a mutation and ignores sibling indicators', async () => { document.body.innerHTML = 'text
'; const section = document.querySelector('signed-section')!; diff --git a/src/core/content/navigation-lifecycle.ts b/src/core/content/navigation-lifecycle.ts index d4503d5..5ada391 100644 --- a/src/core/content/navigation-lifecycle.ts +++ b/src/core/content/navigation-lifecycle.ts @@ -8,9 +8,12 @@ * elements. */ -export const SIGNED_SECTION_SELECTOR = 'signed-section[signature]'; +/** Every signed-section is inspected, including malformed ones. */ +export const SIGNED_SECTION_SELECTOR = 'signed-section'; const IDENTITY_ATTRIBUTES = [ + 'profile', + 'signature-scope', 'signature', 'keyid', 'algorithm', @@ -23,9 +26,16 @@ export interface SignedSectionSnapshot { readonly outerHTML: string; } +// Keep parser nodes out of the public/frozen snapshot records. The weak map +// still lets the content script verify the exact parser tree without +// serializing nested sections and reparsing them through a different path. +const sourceElements = new WeakMap(); + export interface NavigationSnapshot { readonly url: string; readonly origin: string; + /** HTML document base URL computed from the accepted source response. */ + readonly baseUrl: string; readonly capturedAt: number; readonly sections: readonly SignedSectionSnapshot[]; } @@ -38,9 +48,9 @@ export interface SnapshotSectionMatch { /** * Capture signed sections from the post-load, same-URL response snapshot. * - * The returned values are deeply immutable from the caller's perspective. - * The raw response is deliberately not retained, which limits accidental use - * of mutable strings or a later DOM serialization as verification input. + * The returned records are immutable. Parser-owned source nodes remain private + * behind sourceElementForSnapshot, so callers cannot replace the verification + * input with a later DOM serialization or a live page node. */ export function captureNavigationSnapshot( html: string, @@ -53,6 +63,17 @@ export function captureNavigationSnapshot( } const parsed = new DOMParser().parseFromString(html, 'text/html'); + const baseElement = parsed.querySelector('base[href]'); + let baseUrl = url; + if (baseElement) { + try { + baseUrl = new URL(baseElement.getAttribute('href') ?? '', url).href; + } catch { + // The canonicalizer will reject an unsafe/invalid signed URL. Keep the + // response URL here so capture itself remains a pure snapshot operation. + baseUrl = url; + } + } const sections = Array.from(parsed.querySelectorAll(SIGNED_SECTION_SELECTOR)).map( (section, index): SignedSectionSnapshot => { const snapshot = { @@ -60,18 +81,26 @@ export function captureNavigationSnapshot( identity: sectionIdentity(section), outerHTML: section.outerHTML, }; - return Object.freeze(snapshot); + const frozen = Object.freeze(snapshot); + sourceElements.set(frozen, section); + return frozen; }, ); return Object.freeze({ url, origin: new URL(url).origin, + baseUrl, capturedAt, sections: Object.freeze(sections), }); } +/** Retrieve the parser-owned source element for internal verification. */ +export function sourceElementForSnapshot(snapshot: SignedSectionSnapshot): Element | null { + return sourceElements.get(snapshot) ?? null; +} + /** * Pair source sections with their current live counterparts. * @@ -111,6 +140,55 @@ export function sectionIdentity(section: Element): string { .join('\u001f'); } +/** Find the outermost signed ancestor, so markers remain outside nesting. */ +export function outermostSignedSection(section: Element): Element { + let anchor = section; + while (anchor.parentElement?.matches(SIGNED_SECTION_SELECTOR)) anchor = anchor.parentElement; + return anchor; +} + +/** Return the current document base URL, including an applicable . */ +export function documentBaseUrl(document: Document, fallbackUrl: string): string { + const base = document.querySelector('base[href]'); + if (!base) return fallbackUrl; + try { + return new URL(base.getAttribute('href') ?? '', fallbackUrl).href; + } catch { + return fallbackUrl; + } +} + +/** A mutation that can change the base URL used for signed URL attributes. */ +export function mutationTouchesDocumentBase(mutation: MutationRecord): boolean { + if (mutation.type === 'attributes') { + return mutation.target instanceof Element && + mutation.target.localName.toLowerCase() === 'base' && + mutation.attributeName === 'href'; + } + if (mutation.type === 'childList') { + const nodes = [...Array.from(mutation.addedNodes), ...Array.from(mutation.removedNodes)]; + return nodes.some((node) => + node instanceof Element && + (node.localName.toLowerCase() === 'base' || node.querySelector('base[href]') !== null), + ); + } + return false; +} + +/** Observe document-level changes without observing extension UI. */ +export function observeDocumentBase( + document: Document, + onInvalidated: () => void, +): () => void { + const root = document.documentElement; + if (!root) return () => undefined; + const observer = new MutationObserver((mutations) => { + if (mutations.some(mutationTouchesDocumentBase)) onInvalidated(); + }); + observer.observe(root, { attributes: true, attributeFilter: ['href'], childList: true, subtree: true }); + return () => observer.disconnect(); +} + /** * Return true when a mutation can change the signed input for a section. * Extension indicators are siblings of the section, so their mutations never