diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts index 786749b79..cb7a87fea 100644 --- a/packages/cookie-banner/src/client.ts +++ b/packages/cookie-banner/src/client.ts @@ -18,7 +18,10 @@ import { } from "./activation"; import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie"; import { CookieDetector } from "./detector"; +import type { Detector } from "./detector-interface"; import { NotFoundError } from "./errors"; +import { StorageDetector } from "./storage-detector"; +import { ThirdPartyDetector } from "./third-party-detector"; import { fetchJSON } from "./http"; import { detectLanguage } from "./i18n"; import type { ConsentIntegration } from "./integrations"; @@ -56,7 +59,7 @@ export class CookieBannerClient { private bannerConfig: BannerConfig | null = null; private consent: VisitorConsent | null = null; private observer: MutationObserver | null = null; - private detector: CookieDetector | null = null; + private detectors: Detector[] = []; private _gpcApplied = false; constructor(config: CookieBannerClientOptions) { @@ -295,6 +298,8 @@ export class CookieBannerClient { } private startDetector(config?: BannerConfig): void { + this.stopDetectors(); + const knownNames = new Set(); knownNames.add(COOKIE_NAME); if (config) { @@ -305,18 +310,26 @@ export class CookieBannerClient { } } - if (this.detector) { - this.detector.stop(); + this.detectors = [ + new CookieDetector(this.baseUrl, this.bannerId, knownNames), + new StorageDetector(this.baseUrl, this.bannerId), + new ThirdPartyDetector(this.baseUrl, this.bannerId), + ]; + + for (const d of this.detectors) { + d.start(); } - this.detector = new CookieDetector(this.baseUrl, this.bannerId, knownNames); - this.detector.start(); + } + + private stopDetectors(): void { + for (const d of this.detectors) { + d.stop(); + } + this.detectors = []; } destroy(): void { - if (this.detector) { - this.detector.stop(); - this.detector = null; - } + this.stopDetectors(); if (this.observer) { this.observer.disconnect(); this.observer = null; diff --git a/packages/cookie-banner/src/detector-interface.ts b/packages/cookie-banner/src/detector-interface.ts new file mode 100644 index 000000000..9533b1357 --- /dev/null +++ b/packages/cookie-banner/src/detector-interface.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +export interface Detector { + start(): void; + stop(): void; +} diff --git a/packages/cookie-banner/src/detector.ts b/packages/cookie-banner/src/detector.ts index 1d8e5c53a..2a2983283 100644 --- a/packages/cookie-banner/src/detector.ts +++ b/packages/cookie-banner/src/detector.ts @@ -13,6 +13,7 @@ // PERFORMANCE OF THIS SOFTWARE. import { isDeletion, parseCookieName, parseMaxAgeSeconds } from "./cookie-utils"; +import type { Detector } from "./detector-interface"; import { NotFoundError } from "./errors"; import { fetchJSON } from "./http"; @@ -31,7 +32,7 @@ function isExtensionCaller(): boolean { return EXTENSION_URL_RE.test(stack); } -export class CookieDetector { +export class CookieDetector implements Detector { private readonly reportUrl: URL; private readonly knownNames: Set; private readonly reported: Set = new Set(); diff --git a/packages/cookie-banner/src/storage-detector.ts b/packages/cookie-banner/src/storage-detector.ts new file mode 100644 index 000000000..140d55366 --- /dev/null +++ b/packages/cookie-banner/src/storage-detector.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { Detector } from "./detector-interface"; +import { NotFoundError } from "./errors"; +import { fetchJSON } from "./http"; + +interface DetectedStorageEntry { + key: string; + storage_type: "local_storage" | "session_storage" | "indexed_db"; + value_size: number | null; +} + +const DEBOUNCE_MS = 2_000; +const MAX_ITEMS_PER_REQUEST = 100; +const OWN_KEY_PREFIX = "probo_consent:"; +const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//; + +function isExtensionCaller(): boolean { + const stack = new Error().stack ?? ""; + return EXTENSION_URL_RE.test(stack); +} + +export class StorageDetector implements Detector { + private readonly reportUrl: URL; + private readonly reported: Set = new Set(); + private readonly pending: Map = new Map(); + private timer: ReturnType | null = null; + private originalLocalSetItem: typeof Storage.prototype.setItem | null = null; + private originalSessionSetItem: typeof Storage.prototype.setItem | null = null; + private originalIDBOpen: typeof IDBFactory.prototype.open | null = null; + + constructor(baseUrl: URL, bannerId: string) { + this.reportUrl = new URL(`${bannerId}/detected-trackers`, baseUrl); + } + + start(): void { + this.wrapStorage(); + this.wrapIndexedDB(); + this.scanExisting(); + } + + stop(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + if (this.pending.size > 0) { + this.flush(); + } + + if (this.originalLocalSetItem) { + Storage.prototype.setItem = this.originalLocalSetItem; + this.originalLocalSetItem = null; + } + if (this.originalSessionSetItem) { + Storage.prototype.setItem = this.originalLocalSetItem ?? this.originalSessionSetItem!; + this.originalSessionSetItem = null; + } + if (this.originalIDBOpen) { + IDBFactory.prototype.open = this.originalIDBOpen; + this.originalIDBOpen = null; + } + } + + private wrapStorage(): void { + const originalSetItem = Storage.prototype.setItem; + this.originalLocalSetItem = originalSetItem; + + const self = this; + + Storage.prototype.setItem = function (key: string, value: string) { + originalSetItem.call(this, key, value); + + if (isExtensionCaller()) return; + + const storageType: "local_storage" | "session_storage" = + this === localStorage ? "local_storage" : "session_storage"; + + self.onStorageWrite(key, value, storageType); + }; + } + + private wrapIndexedDB(): void { + if (typeof indexedDB === "undefined") return; + + const originalOpen = IDBFactory.prototype.open; + this.originalIDBOpen = originalOpen; + + const self = this; + + IDBFactory.prototype.open = function (name: string, version?: number) { + const request = originalOpen.call(this, name, version); + self.onIndexedDBOpen(name); + return request; + }; + } + + private onStorageWrite( + key: string, + value: string, + storageType: "local_storage" | "session_storage", + ): void { + if (key.startsWith(OWN_KEY_PREFIX)) return; + + const reportKey = `${storageType}:${key}`; + if (this.reported.has(reportKey)) return; + + this.reported.add(reportKey); + this.pending.set(reportKey, { + key, + storage_type: storageType, + value_size: value.length * 2, + }); + this.scheduleFlush(); + } + + private onIndexedDBOpen(name: string): void { + const reportKey = `indexed_db:${name}`; + if (this.reported.has(reportKey)) return; + + this.reported.add(reportKey); + this.pending.set(reportKey, { + key: name, + storage_type: "indexed_db", + value_size: null, + }); + this.scheduleFlush(); + } + + private scanExisting(): void { + this.scanStorage(localStorage, "local_storage"); + this.scanStorage(sessionStorage, "session_storage"); + } + + private scanStorage( + storage: Storage, + storageType: "local_storage" | "session_storage", + ): void { + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (!key || key.startsWith(OWN_KEY_PREFIX)) continue; + + const reportKey = `${storageType}:${key}`; + if (this.reported.has(reportKey)) continue; + + const value = storage.getItem(key); + this.reported.add(reportKey); + this.pending.set(reportKey, { + key, + storage_type: storageType, + value_size: value ? value.length * 2 : null, + }); + } + + if (this.pending.size > 0) { + this.scheduleFlush(); + } + } + + private scheduleFlush(): void { + if (this.timer) return; + this.timer = setTimeout(() => { + this.timer = null; + this.flush(); + }, DEBOUNCE_MS); + } + + private flush(): void { + if (this.pending.size === 0) return; + + const entries: DetectedStorageEntry[] = []; + for (const [key, entry] of this.pending) { + entries.push(entry); + this.pending.delete(key); + if (entries.length >= MAX_ITEMS_PER_REQUEST) break; + } + + void fetchJSON(this.reportUrl, { + method: "POST", + body: { storage: entries }, + }).catch((err) => { + if (err instanceof NotFoundError) { + this.pending.clear(); + this.stop(); + } + }); + + if (this.pending.size > 0) { + this.scheduleFlush(); + } + } +} diff --git a/packages/cookie-banner/src/third-party-detector.ts b/packages/cookie-banner/src/third-party-detector.ts new file mode 100644 index 000000000..77678acb8 --- /dev/null +++ b/packages/cookie-banner/src/third-party-detector.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { Detector } from "./detector-interface"; +import { NotFoundError } from "./errors"; +import { fetchJSON } from "./http"; + +interface DetectedResourceEntry { + origin: string; + resource_type: "script" | "iframe"; +} + +const DEBOUNCE_MS = 2_000; +const MAX_ITEMS_PER_REQUEST = 100; +const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//; + +export class ThirdPartyDetector implements Detector { + private readonly reportUrl: URL; + private readonly pageOrigin: string; + private readonly proboOrigin: string; + private readonly reported: Set = new Set(); + private readonly pending: Map = new Map(); + private timer: ReturnType | null = null; + private observer: MutationObserver | null = null; + + constructor(baseUrl: URL, bannerId: string) { + this.reportUrl = new URL(`${bannerId}/detected-trackers`, baseUrl); + this.pageOrigin = location.origin; + this.proboOrigin = baseUrl.origin; + } + + start(): void { + this.scanExisting(); + this.observeMutations(); + } + + stop(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + if (this.pending.size > 0) { + this.flush(); + } + + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + } + + private scanExisting(): void { + for (const script of document.querySelectorAll("script[src]")) { + this.processElement(script.src, "script"); + } + for (const iframe of document.querySelectorAll("iframe[src]")) { + this.processElement(iframe.src, "iframe"); + } + + if (this.pending.size > 0) { + this.scheduleFlush(); + } + } + + private observeMutations(): void { + this.observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (!(node instanceof HTMLElement)) continue; + + if (node instanceof HTMLScriptElement && node.src) { + this.processElement(node.src, "script"); + } else if (node instanceof HTMLIFrameElement && node.src) { + this.processElement(node.src, "iframe"); + } + + for (const script of node.querySelectorAll("script[src]")) { + this.processElement(script.src, "script"); + } + for (const iframe of node.querySelectorAll("iframe[src]")) { + this.processElement(iframe.src, "iframe"); + } + } + } + }); + + this.observer.observe(document.documentElement, { + childList: true, + subtree: true, + }); + } + + private processElement(src: string, resourceType: "script" | "iframe"): void { + if (EXTENSION_URL_RE.test(src)) return; + + let origin: string; + try { + origin = new URL(src).origin; + } catch { + return; + } + + if (origin === this.pageOrigin || origin === this.proboOrigin) return; + + const reportKey = `${resourceType}:${origin}`; + if (this.reported.has(reportKey)) return; + + this.reported.add(reportKey); + this.pending.set(reportKey, { origin, resource_type: resourceType }); + this.scheduleFlush(); + } + + private scheduleFlush(): void { + if (this.timer) return; + this.timer = setTimeout(() => { + this.timer = null; + this.flush(); + }, DEBOUNCE_MS); + } + + private flush(): void { + if (this.pending.size === 0) return; + + const entries: DetectedResourceEntry[] = []; + for (const [key, entry] of this.pending) { + entries.push(entry); + this.pending.delete(key); + if (entries.length >= MAX_ITEMS_PER_REQUEST) break; + } + + void fetchJSON(this.reportUrl, { + method: "POST", + body: { resources: entries }, + }).catch((err) => { + if (err instanceof NotFoundError) { + this.pending.clear(); + this.stop(); + } + }); + + if (this.pending.size > 0) { + this.scheduleFlush(); + } + } +}