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