diff --git a/packages/cookie-banner/src/detectors/resource-detector.ts b/packages/cookie-banner/src/detectors/resource-detector.ts index 498a7dbed..17856183e 100644 --- a/packages/cookie-banner/src/detectors/resource-detector.ts +++ b/packages/cookie-banner/src/detectors/resource-detector.ts @@ -53,6 +53,30 @@ function mapInitiatorType(it: string): ResourceType | null { } } +// Attribute names that can carry a resource URL on at least one of the +// element types we track. Used as a fast-path filter at the top of the +// `setAttribute`/`setAttributeNS` wrap so non-resource calls (e.g. +// `setAttribute("class", ...)`) bail before we ever touch the stack. +const RESOURCE_ATTRIBUTES: ReadonlySet = new Set([ + "src", + "href", + "data", +]); + +// SavedDescriptor lets stop() restore the exact (configurable, enumerable, +// get, set) shape we replaced via Object.defineProperty. +interface SavedDescriptor { + target: object; + key: string; + descriptor: PropertyDescriptor; +} + +// Hard cap on the synchronously-marked extension URL set so a malicious +// extension cannot grow the SDK's memory footprint indefinitely. FIFO +// eviction is fine because the most recent injections are also the most +// likely to still be loading and need filtering. +const MAX_EXTENSION_URLS = 256; + export class ResourceDetector implements Detector { private readonly queue: ReportQueue; private readonly pageOrigin: string; @@ -61,6 +85,35 @@ export class ResourceDetector implements Detector { private perfObserver: PerformanceObserver | null = null; private originalSWRegister: typeof ServiceWorkerContainer.prototype.register | null = null; + // extensionElements is populated synchronously by the property-setter, + // setAttribute, and HTML-parsing wraps when the call originates from a + // browser-extension stack. The MutationObserver consults it before + // reporting, which is the only way we can reliably attribute a + // resource insertion to an extension -- the observer's own callback + // fires from a browser-internal stack with no extension frame visible. + private extensionElements: WeakSet = new WeakSet(); + + // extensionUrls covers the URL-only paths (PerformanceObserver, fetch, + // XHR, sendBeacon) where we have no element to key on. It mirrors the + // origin+pathname identifier used in processResource so a hit drops + // the report without further work. Capped at MAX_EXTENSION_URLS with + // FIFO eviction. + private extensionUrls: Set = new Set(); + + // Saved property descriptors restored by stop(). + private savedDescriptors: SavedDescriptor[] = []; + + // Saved originals for direct method/global replacements restored by + // stop(). + private originalSetAttribute: typeof Element.prototype.setAttribute | null = null; + private originalSetAttributeNS: typeof Element.prototype.setAttributeNS | null = null; + private originalInsertAdjacentHTML: typeof Element.prototype.insertAdjacentHTML | null = null; + private originalDocWrite: typeof Document.prototype.write | null = null; + private originalDocWriteln: typeof Document.prototype.writeln | null = null; + private originalFetch: typeof window.fetch | null = null; + private originalXHROpen: typeof XMLHttpRequest.prototype.open | null = null; + private originalSendBeacon: typeof navigator.sendBeacon | null = null; + constructor(queue: ReportQueue, apiOrigin: string) { this.queue = queue; this.pageOrigin = location.origin; @@ -74,6 +127,11 @@ export class ResourceDetector implements Detector { this.observePerformance(); this.wrapServiceWorker(); + this.wrapElementSrcSetters(); + this.wrapSetAttribute(); + this.wrapHTMLParsing(); + this.wrapNetworkAPIs(); + if (isExtensionContext()) return; this.scanExisting(); @@ -99,6 +157,59 @@ export class ResourceDetector implements Detector { navigator.serviceWorker.register = this.originalSWRegister; this.originalSWRegister = null; } + + for (const saved of this.savedDescriptors) { + try { + Object.defineProperty(saved.target, saved.key, saved.descriptor); + } catch { + // Restoration is best-effort: another piece of code may have + // re-defined the same property after we did. We swallow rather + // than throw because failing to restore is not a correctness + // problem -- our wrap simply keeps running until the page + // unloads. + } + } + this.savedDescriptors = []; + + if (this.originalSetAttribute) { + Element.prototype.setAttribute = this.originalSetAttribute; + this.originalSetAttribute = null; + } + + if (this.originalSetAttributeNS) { + Element.prototype.setAttributeNS = this.originalSetAttributeNS; + this.originalSetAttributeNS = null; + } + + if (this.originalInsertAdjacentHTML) { + Element.prototype.insertAdjacentHTML = this.originalInsertAdjacentHTML; + this.originalInsertAdjacentHTML = null; + } + + if (this.originalDocWrite) { + Document.prototype.write = this.originalDocWrite; + this.originalDocWrite = null; + } + + if (this.originalDocWriteln) { + Document.prototype.writeln = this.originalDocWriteln; + this.originalDocWriteln = null; + } + + if (this.originalFetch && typeof window !== "undefined") { + window.fetch = this.originalFetch; + this.originalFetch = null; + } + + if (this.originalXHROpen && typeof XMLHttpRequest !== "undefined") { + XMLHttpRequest.prototype.open = this.originalXHROpen; + this.originalXHROpen = null; + } + + if (this.originalSendBeacon && typeof navigator !== "undefined") { + navigator.sendBeacon = this.originalSendBeacon; + this.originalSendBeacon = null; + } } private scanExisting(): void { @@ -115,6 +226,7 @@ export class ResourceDetector implements Detector { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) continue; + if (this.extensionElements.has(node)) continue; if (node instanceof HTMLScriptElement && node.src) { this.processResource(node.src, "script"); @@ -123,9 +235,11 @@ export class ResourceDetector implements Detector { } for (const script of node.querySelectorAll("script[src]")) { + if (this.extensionElements.has(script)) continue; this.processResource(script.src, "script"); } for (const iframe of node.querySelectorAll("iframe[src]")) { + if (this.extensionElements.has(iframe)) continue; this.processResource(iframe.src, "iframe"); } } @@ -211,7 +325,7 @@ export class ResourceDetector implements Detector { let parsed: URL; try { - parsed = new URL(src); + parsed = new URL(src, location.href); } catch { return; } @@ -225,6 +339,546 @@ export class ResourceDetector implements Detector { if (resourceType !== "service_worker" && parsed.origin === this.pageOrigin) return; const identifier = parsed.origin + parsed.pathname; + if (this.extensionUrls.has(identifier)) return; + this.queue.reportResource({ url: identifier, resource_type: resourceType }); } + + // identifierOf normalises a raw URL string into the same origin+pathname + // shape used by processResource, returning null for anything that is + // not an http(s) URL. Used by the synchronous wraps to populate + // extensionUrls and stay consistent with the value the async paths + // will later compare against. + private identifierOf(src: string): string | null { + let parsed: URL; + try { + parsed = new URL(src, location.href); + } catch { + return null; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return parsed.origin + parsed.pathname; + } + + private markExtensionElement(el: Element): void { + this.extensionElements.add(el); + } + + private markExtensionUrl(id: string | null): void { + if (!id) return; + if (this.extensionUrls.size >= MAX_EXTENSION_URLS) { + const first = this.extensionUrls.values().next().value; + if (first !== undefined) this.extensionUrls.delete(first); + } + this.extensionUrls.add(id); + } + + private markExtension(el: Element | null, url: string | null): void { + if (el) this.markExtensionElement(el); + if (url) this.markExtensionUrl(url); + } + + // wrapElementSrcSetters wraps the `src` (or `href` for ) IDL + // setter on each prototype that loads a tracker-relevant resource. + // Because the wrap runs on the caller's synchronous stack, + // isExtensionCaller() actually sees the extension frame here -- which + // it cannot from MutationObserver/PerformanceObserver. This is the + // primary point at which extension attribution becomes reliable. + private wrapElementSrcSetters(): void { + this.wrapPropertySetter( + HTMLScriptElement.prototype, + "src", + (_el, absolute) => this.processResource(absolute, "script"), + ); + this.wrapPropertySetter( + HTMLIFrameElement.prototype, + "src", + (_el, absolute) => this.processResource(absolute, "iframe"), + ); + this.wrapPropertySetter( + HTMLImageElement.prototype, + "src", + (_el, absolute) => this.processResource(absolute, "image"), + ); + this.wrapPropertySetter( + HTMLLinkElement.prototype, + "href", + (el, absolute) => { + // only initiates a load for some `rel` values; for the + // others (icon, manifest, dns-prefetch, ...) we leave reporting + // to PerformanceObserver if and when an actual load occurs. + // preload/prefetch/modulepreload are deferred too because their + // mapped resource type depends on the `as` attribute. + if ((el as HTMLLinkElement).rel.toLowerCase() === "stylesheet") { + this.processResource(absolute, "stylesheet"); + } + }, + ); + if (typeof HTMLSourceElement !== "undefined") { + this.wrapPropertySetter( + HTMLSourceElement.prototype, + "src", + (_el, absolute) => this.processResource(absolute, "media"), + ); + } + } + + private wrapPropertySetter( + proto: object, + key: string, + onPageCaller: (el: Element, absolute: string) => void, + ): void { + const desc = Object.getOwnPropertyDescriptor(proto, key); + if (!desc?.set || !desc?.get) return; + + this.savedDescriptors.push({ target: proto, key, descriptor: desc }); + + const originalSet = desc.set; + const originalGet = desc.get; + const self = this; + + Object.defineProperty(proto, key, { + configurable: true, + enumerable: desc.enumerable, + get: originalGet, + set(value: unknown) { + originalSet.call(this, value); + if (typeof value !== "string" || value === "") return; + + const fromExtension = isExtensionCaller(); + // The original getter resolves relative URLs against the + // document base. Reading through it gives the same absolute + // form processResource would later produce. + const absolute = (originalGet.call(this) as string) || value; + + if (fromExtension) { + self.markExtension(this as Element, self.identifierOf(absolute)); + return; + } + onPageCaller(this as Element, absolute); + }, + }); + } + + // wrapSetAttribute installs a single hook on Element.prototype that + // covers every `el.setAttribute("src" | "href" | "data", ...)` call, + // regardless of the element type. The first thing it does is bail on + // attribute names we never care about, keeping the overhead near zero + // on the hot path of generic setAttribute usage (class names, ARIA + // attributes, dataset entries, ...). + private wrapSetAttribute(): void { + const original = Element.prototype.setAttribute; + this.originalSetAttribute = original; + const self = this; + + Element.prototype.setAttribute = function ( + this: Element, + name: string, + value: string, + ): void { + original.call(this, name, value); + if (typeof name !== "string") return; + // Common case: name is already lowercase. Skip the toLowerCase() + // allocation by checking the canonical names directly first. + if ( + name !== "src" + && name !== "href" + && name !== "data" + && !RESOURCE_ATTRIBUTES.has(name.toLowerCase()) + ) { + return; + } + const lower = name === "src" || name === "href" || name === "data" + ? name + : name.toLowerCase(); + self.handleAttributeMutation(this, lower, value); + }; + + if (typeof Element.prototype.setAttributeNS === "function") { + const originalNS = Element.prototype.setAttributeNS; + this.originalSetAttributeNS = originalNS; + + Element.prototype.setAttributeNS = function ( + this: Element, + ns: string | null, + name: string, + value: string, + ): void { + originalNS.call(this, ns, name, value); + if (typeof name !== "string") return; + // Strip any namespace prefix so xlink:href etc. resolve to the + // local name we filter on. + const colon = name.indexOf(":"); + const local = (colon >= 0 ? name.slice(colon + 1) : name).toLowerCase(); + if (!RESOURCE_ATTRIBUTES.has(local)) return; + self.handleAttributeMutation(this, local, value); + }; + } + } + + private handleAttributeMutation( + el: Element, + attrName: string, + value: unknown, + ): void { + if (typeof value !== "string" || value === "") return; + + const rt = this.resourceTypeForElement(el, attrName); + if (rt === null) return; + + if (isExtensionCaller()) { + this.markExtension(el, this.identifierOf(value)); + return; + } + + this.processResource(value, rt); + } + + private resourceTypeForElement(el: Element, attrName: string): ResourceType | null { + if (attrName === "src") { + if (el instanceof HTMLScriptElement) return "script"; + if (el instanceof HTMLIFrameElement) return "iframe"; + if (el instanceof HTMLImageElement) return "image"; + if (typeof HTMLSourceElement !== "undefined" && el instanceof HTMLSourceElement) return "media"; + if (typeof HTMLEmbedElement !== "undefined" && el instanceof HTMLEmbedElement) return "media"; + if (typeof HTMLMediaElement !== "undefined" && el instanceof HTMLMediaElement) return "media"; + return null; + } + if (attrName === "href") { + if (el instanceof HTMLLinkElement) { + // Only report stylesheet loads from the sync hook; other rel + // values either don't load (dns-prefetch, preconnect, icon, + // manifest) or have a resource type that depends on the `as` + // attribute, which is best handled by PerformanceObserver. + return el.rel.toLowerCase() === "stylesheet" ? "stylesheet" : null; + } + return null; + } + if (attrName === "data") { + if (typeof HTMLObjectElement !== "undefined" && el instanceof HTMLObjectElement) return "media"; + return null; + } + return null; + } + + // wrapHTMLParsing covers the four entry points where the browser HTML + // parser builds element trees from a string: setting innerHTML or + // outerHTML, insertAdjacentHTML, and document.write/writeln. None of + // those invoke the per-element setter or setAttribute hooks above + // (the parser writes attributes via internal C++), so we capture the + // extension verdict synchronously at the entry point and then walk + // the parsed result to tag the new resource-bearing descendants. + private wrapHTMLParsing(): void { + this.wrapInnerHTMLSetter(); + this.wrapOuterHTMLSetter(); + this.wrapInsertAdjacentHTML(); + this.wrapDocumentWrite(); + } + + private wrapInnerHTMLSetter(): void { + const desc = Object.getOwnPropertyDescriptor(Element.prototype, "innerHTML"); + if (!desc?.set || !desc?.get) return; + + this.savedDescriptors.push({ target: Element.prototype, key: "innerHTML", descriptor: desc }); + + const originalSet = desc.set; + const originalGet = desc.get; + const self = this; + + Object.defineProperty(Element.prototype, "innerHTML", { + configurable: true, + enumerable: desc.enumerable, + get: originalGet, + set(value: unknown) { + const fromExtension = isExtensionCaller(); + originalSet.call(this, value); + if (!fromExtension) return; + // innerHTML replaces every existing child, so after the call + // every descendant is new. + self.markResourceTree(this as Element, true); + }, + }); + } + + private wrapOuterHTMLSetter(): void { + const desc = Object.getOwnPropertyDescriptor(Element.prototype, "outerHTML"); + if (!desc?.set || !desc?.get) return; + + this.savedDescriptors.push({ target: Element.prototype, key: "outerHTML", descriptor: desc }); + + const originalSet = desc.set; + const originalGet = desc.get; + const self = this; + + Object.defineProperty(Element.prototype, "outerHTML", { + configurable: true, + enumerable: desc.enumerable, + get: originalGet, + set(value: unknown) { + const fromExtension = isExtensionCaller(); + const parent = (this as Element).parentNode; + if (!fromExtension || !parent) { + originalSet.call(this, value); + return; + } + // outerHTML replaces this element with parsed siblings inside + // the parent. We can't walk the parent wholesale (pre-existing + // children would be wrongly marked), so we use a one-shot + // MutationObserver to capture only the actually-added nodes. + self.observeAndMark(parent, () => originalSet.call(this, value)); + }, + }); + } + + private wrapInsertAdjacentHTML(): void { + const original = Element.prototype.insertAdjacentHTML; + if (typeof original !== "function") return; + + this.originalInsertAdjacentHTML = original; + const self = this; + + Element.prototype.insertAdjacentHTML = function ( + this: Element, + position: InsertPosition, + text: string, + ): void { + const fromExtension = isExtensionCaller(); + if (!fromExtension) { + original.call(this, position, text); + return; + } + + // The affected parent depends on the position: beforebegin and + // afterend insert siblings of `this` (so observe parentNode); + // afterbegin and beforeend insert children of `this`. + const root: Node | null + = position === "beforebegin" || position === "afterend" + ? this.parentNode + : this; + + if (!root) { + original.call(this, position, text); + return; + } + + self.observeAndMark(root, () => original.call(this, position, text)); + }; + } + + private wrapDocumentWrite(): void { + if (typeof Document === "undefined") return; + + const originalWrite = Document.prototype.write; + const originalWriteln = Document.prototype.writeln; + if (typeof originalWrite !== "function") return; + + this.originalDocWrite = originalWrite; + this.originalDocWriteln = typeof originalWriteln === "function" ? originalWriteln : null; + + const self = this; + + Document.prototype.write = function (this: Document, ...args: string[]): void { + const fromExtension = isExtensionCaller(); + if (!fromExtension) { + originalWrite.apply(this, args); + return; + } + self.observeAndMark(this, () => originalWrite.apply(this, args)); + }; + + if (typeof originalWriteln === "function") { + Document.prototype.writeln = function (this: Document, ...args: string[]): void { + const fromExtension = isExtensionCaller(); + if (!fromExtension) { + originalWriteln.apply(this, args); + return; + } + self.observeAndMark(this, () => originalWriteln.apply(this, args)); + }; + } + } + + // observeAndMark wraps a single synchronous DOM mutation in a + // disposable MutationObserver so we get a precise list of the nodes + // the operation actually inserted. takeRecords() drains the queue + // synchronously, before the main observer's microtask runs, so we + // can tag the new elements before observeMutations() sees them. + private observeAndMark(root: Node, fn: () => void): void { + if (typeof MutationObserver === "undefined") { + fn(); + return; + } + + const observer = new MutationObserver(() => {}); + try { + observer.observe(root, { childList: true, subtree: true }); + } catch { + // observe() throws on detached or unusual roots. Fall back to + // running the operation untracked rather than failing the page. + fn(); + return; + } + + try { + fn(); + } finally { + const records = observer.takeRecords(); + observer.disconnect(); + for (const record of records) { + for (const node of record.addedNodes) { + if (node instanceof HTMLElement) { + this.markResourceTree(node, true); + } + } + } + } + } + + // markResourceTree walks `root` and its descendants and tags any + // resource-bearing element. `fromParser` distinguishes elements + // produced by the HTML parser (innerHTML/outerHTML/insertAdjacentHTML/ + // document.write): parser-inserted