Add StorageDetector, ThirdPartyDetector, and Detector interface
Introduce a common Detector interface (start/stop) implemented by CookieDetector, StorageDetector, and ThirdPartyDetector. The client manages them as a uniform array, simplifying lifecycle management. StorageDetector wraps Storage.prototype.setItem and indexedDB.open to detect localStorage, sessionStorage, and IndexedDB usage. ThirdPartyDetector uses MutationObserver to detect cross-origin script and iframe elements, reporting at origin level. Both report to POST /detected-trackers with 2s debounce and max 100 items per batch. Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -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<string>();
|
||||
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;
|
||||
|
||||
18
packages/cookie-banner/src/detector-interface.ts
Normal file
18
packages/cookie-banner/src/detector-interface.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
@@ -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<string>;
|
||||
private readonly reported: Set<string> = new Set();
|
||||
|
||||
205
packages/cookie-banner/src/storage-detector.ts
Normal file
205
packages/cookie-banner/src/storage-detector.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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<string> = new Set();
|
||||
private readonly pending: Map<string, DetectedStorageEntry> = new Map();
|
||||
private timer: ReturnType<typeof setTimeout> | 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
157
packages/cookie-banner/src/third-party-detector.ts
Normal file
157
packages/cookie-banner/src/third-party-detector.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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<string> = new Set();
|
||||
private readonly pending: Map<string, DetectedResourceEntry> = new Map();
|
||||
private timer: ReturnType<typeof setTimeout> | 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<HTMLScriptElement>("script[src]")) {
|
||||
this.processElement(script.src, "script");
|
||||
}
|
||||
for (const iframe of document.querySelectorAll<HTMLIFrameElement>("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<HTMLScriptElement>("script[src]")) {
|
||||
this.processElement(script.src, "script");
|
||||
}
|
||||
for (const iframe of node.querySelectorAll<HTMLIFrameElement>("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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user