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:
Émile Ré
2026-05-05 16:30:58 +04:00
parent ea22de7ced
commit 57e035c64e
5 changed files with 404 additions and 10 deletions

View File

@@ -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;

View 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;
}

View File

@@ -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();

View 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();
}
}
}

View 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();
}
}
}