From b04454eaee9fee1ab306cd9211315767f39e9238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 22 May 2026 17:36:55 +0200 Subject: [PATCH] Drop ineffective extension-caller wraps from cookie banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synchronous Storage/Document/Element/fetch/XHR/sendBeacon wraps and the resource-detector attribution machinery relied on isExtensionCaller() finding a chrome-extension:// frame in the JS stack. For Chromium/Edge/Safari MV3 isolated-world content scripts -- the dominant case -- those wraps live in the page realm while the extension uses its own copy of every prototype we hook, so the check never fires and the marking never runs. Backend denylisting (planned) covers the same cases more cheaply, retroactively, and across all tenants, so the elaborate frontend plumbing no longer earns its complexity. Keep only the parts that backend classification cannot replace: isExtensionContext() (SDK loaded inside an extension page) and the http/https-only filter in processResource (drops chrome-extension:// URLs surfaced via PerformanceObserver). resource-detector.ts shrinks from ~920 to ~225 lines. Signed-off-by: Émile Ré --- .../src/detectors/cookie-detector.ts | 3 +- .../src/detectors/extension-context.ts | 12 +- .../src/detectors/resource-detector.ts | 695 +----------------- .../src/detectors/storage-detector.ts | 9 +- 4 files changed, 5 insertions(+), 714 deletions(-) diff --git a/packages/cookie-banner/src/detectors/cookie-detector.ts b/packages/cookie-banner/src/detectors/cookie-detector.ts index 1aa99d0cd..d30f87ce1 100644 --- a/packages/cookie-banner/src/detectors/cookie-detector.ts +++ b/packages/cookie-banner/src/detectors/cookie-detector.ts @@ -14,7 +14,7 @@ import { isDeletion, parseCookieName, parseMaxAgeSeconds } from "../cookie-utils"; import type { Detector } from "./detector"; -import { isExtensionCaller, isExtensionContext } from "./extension-context"; +import { isExtensionContext } from "./extension-context"; import { getInitiatorURL } from "./initiator"; import type { ReportQueue } from "./report-queue"; import type { DetectedCookieEntry } from "./types"; @@ -78,7 +78,6 @@ export class CookieDetector implements Detector { private onCookieSet(raw: string): void { if (isDeletion(raw)) return; - if (isExtensionCaller()) return; const name = parseCookieName(raw); if (!name || this.knownNames.has(name)) return; diff --git a/packages/cookie-banner/src/detectors/extension-context.ts b/packages/cookie-banner/src/detectors/extension-context.ts index 84513f2f2..6338f11db 100644 --- a/packages/cookie-banner/src/detectors/extension-context.ts +++ b/packages/cookie-banner/src/detectors/extension-context.ts @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -export const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//; +const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//; const EXTENSION_PROTOCOLS = new Set([ "chrome-extension:", @@ -20,16 +20,6 @@ const EXTENSION_PROTOCOLS = new Set([ "safari-web-extension:", ]); -// isExtensionCaller inspects the synchronous JS call stack for a frame -// whose URL is a browser extension origin. It is a best-effort check: -// some hooks (PerformanceObserver, browser internals firing events from -// their own threads) will not carry the original injector frame, so a -// `false` result does not guarantee the caller is first-party. -export function isExtensionCaller(): boolean { - const stack = new Error().stack ?? ""; - return EXTENSION_URL_RE.test(stack); -} - // extensionContext is evaluated once at module load. We capture // `document.currentScript?.src` here because that reference only // resolves while the loading script is still executing -- by the time diff --git a/packages/cookie-banner/src/detectors/resource-detector.ts b/packages/cookie-banner/src/detectors/resource-detector.ts index fc12da4db..521cf8d0f 100644 --- a/packages/cookie-banner/src/detectors/resource-detector.ts +++ b/packages/cookie-banner/src/detectors/resource-detector.ts @@ -13,7 +13,7 @@ // PERFORMANCE OF THIS SOFTWARE. import type { Detector } from "./detector"; -import { isExtensionCaller, isExtensionContext } from "./extension-context"; +import { isExtensionContext } from "./extension-context"; import type { ReportQueue } from "./report-queue"; import type { ResourceType } from "./types"; @@ -53,30 +53,6 @@ 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; @@ -85,35 +61,6 @@ 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; @@ -127,11 +74,6 @@ export class ResourceDetector implements Detector { this.observePerformance(); this.wrapServiceWorker(); - this.wrapElementSrcSetters(); - this.wrapSetAttribute(); - this.wrapHTMLParsing(); - this.wrapNetworkAPIs(); - if (isExtensionContext()) return; this.scanExisting(); @@ -157,59 +99,6 @@ 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 { @@ -226,7 +115,6 @@ 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"); @@ -235,11 +123,9 @@ 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"); } } @@ -321,8 +207,6 @@ export class ResourceDetector implements Detector { } private processResource(src: string, resourceType: ResourceType): void { - if (isExtensionCaller()) return; - let parsed: URL; try { parsed = new URL(src, location.href); @@ -339,583 +223,6 @@ 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; - - // Extension marking runs before classification because the - // current attribute alone is not always enough to know whether a - // load will happen. The canonical case is ``: a stylesheet - // load is only triggered once both `href` and `rel="stylesheet"` - // are set, in any order. If the extension sets `href` first we - // would otherwise miss tagging, and the eventual PerformanceObserver - // entry -- which has no extension frame on its stack -- would leak - // through as a page tracker. The same holds for `` - // and any future rel value that initiates a load. - if (isExtensionCaller()) { - if (this.couldLoadResource(el, attrName)) { - this.markExtension(el, this.identifierOf(value)); - } - return; - } - - const rt = this.resourceTypeForElement(el, attrName); - if (rt === null) return; - - this.processResource(value, rt); - } - - // couldLoadResource reports whether `el` is an element type that can - // ever initiate a network load via `attrName`, regardless of any - // other attributes that may or may not be set yet. It is a superset - // of resourceTypeForElement: it returns true for `` even - // when `rel` has not been set, because the rel may change later and - // turn the href into a real load. - private couldLoadResource(el: Element, attrName: string): boolean { - if (attrName === "src") { - return ( - el instanceof HTMLScriptElement - || el instanceof HTMLIFrameElement - || el instanceof HTMLImageElement - || (typeof HTMLSourceElement !== "undefined" && el instanceof HTMLSourceElement) - || (typeof HTMLEmbedElement !== "undefined" && el instanceof HTMLEmbedElement) - || (typeof HTMLMediaElement !== "undefined" && el instanceof HTMLMediaElement) - ); - } - if (attrName === "href") { - return el instanceof HTMLLinkElement; - } - if (attrName === "data") { - return typeof HTMLObjectElement !== "undefined" && el instanceof HTMLObjectElement; - } - return false; - } - - 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