From 137b63a47e2f47888947bea49c0fe3fda3e8c8a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 22 Apr 2026 16:59:02 +0400 Subject: [PATCH] Add cookie detector to JS SDK for automatic cookie discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overrides document.cookie setter to intercept cookie writes and detect unknown cookies not present in the banner config. Also scans pre-existing cookies on startup. Detected cookies are debounced and reported in batches to the detected-cookies endpoint with inferred durations from max-age/expires. Extracts shared cookie helpers (parseCookieName, parseDuration, isDeletion, removeCookies) into cookie-utils.ts, used by both activation.ts and detector.ts. Unifies the duplicated "probo_consent" constant into a single export from cookie.ts. Signed-off-by: Émile Ré --- packages/cookie-banner/src/activation.ts | 26 +--- packages/cookie-banner/src/client.ts | 23 +++- packages/cookie-banner/src/cookie-utils.ts | 133 +++++++++++++++++++++ packages/cookie-banner/src/cookie.ts | 2 +- packages/cookie-banner/src/detector.ts | 130 ++++++++++++++++++++ packages/cookie-banner/src/queue.ts | 5 +- packages/cookie-banner/src/visitor.ts | 4 +- 7 files changed, 291 insertions(+), 32 deletions(-) create mode 100644 packages/cookie-banner/src/cookie-utils.ts create mode 100644 packages/cookie-banner/src/detector.ts diff --git a/packages/cookie-banner/src/activation.ts b/packages/cookie-banner/src/activation.ts index 5d64d9625..e3bb899dc 100644 --- a/packages/cookie-banner/src/activation.ts +++ b/packages/cookie-banner/src/activation.ts @@ -12,6 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { removeCookies } from "./cookie-utils"; import { LOCK_ICON } from "./html"; const ATTR_CATEGORY = "data-cookie-consent"; @@ -315,31 +316,6 @@ function deactivateElement(el: Element, label?: string): void { } } -function getCandidateDomains(hostname: string): string[] { - const parts = hostname.split("."); - if (parts.length <= 1) return []; - - const candidates: string[] = []; - // Try progressively broader parent domains. The browser silently - // ignores attempts to clear cookies on public suffixes, so - // over-trying is safe and avoids maintaining a TLD list. - for (let i = 0; i < parts.length - 1; i++) { - candidates.push("." + parts.slice(i).join(".")); - } - - return candidates; -} - -function removeCookies(names: string[]): void { - const domains = getCandidateDomains(location.hostname); - - for (const name of names) { - document.cookie = `${name}=; path=/; max-age=0`; - for (const domain of domains) { - document.cookie = `${name}=; path=/; domain=${domain}; max-age=0`; - } - } -} export function deactivateElements( consentData: Record, diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts index 008232980..e2addba52 100644 --- a/packages/cookie-banner/src/client.ts +++ b/packages/cookie-banner/src/client.ts @@ -18,7 +18,8 @@ import { deactivateElements, observeAndActivate, } from "./activation"; -import { getConsentCookie, setConsentCookie } from "./cookie"; +import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie"; +import { CookieDetector } from "./detector"; import { NotFoundError } from "./errors"; import { fetchJSON } from "./http"; import { enqueue, flush } from "./queue"; @@ -77,6 +78,7 @@ export class CookieBannerClient { private bannerConfig: BannerConfig | null = null; private consent: VisitorConsent | null = null; private observer: MutationObserver | null = null; + private detector: CookieDetector | null = null; constructor(config: CookieBannerClientOptions) { let base = config.baseUrl; @@ -93,6 +95,8 @@ export class CookieBannerClient { const config = await fetchJSON(configUrl); this.bannerConfig = config; + this.startDetector(config); + const cookie = getConsentCookie(); if (cookie && cookie.v === config.version && cookie.vid === this.visitorId) { this.consent = { @@ -242,7 +246,24 @@ export class CookieBannerClient { this.observer = observeAndActivate(consentData, categoryLabels); } + private startDetector(config: BannerConfig): void { + const knownNames = new Set(); + knownNames.add(COOKIE_NAME); + for (const cat of config.categories) { + for (const cookie of cat.cookies) { + knownNames.add(cookie.name); + } + } + + this.detector = new CookieDetector(this.baseUrl, this.bannerId, knownNames); + this.detector.start(); + } + destroy(): void { + if (this.detector) { + this.detector.stop(); + this.detector = null; + } if (this.observer) { this.observer.disconnect(); this.observer = null; diff --git a/packages/cookie-banner/src/cookie-utils.ts b/packages/cookie-banner/src/cookie-utils.ts new file mode 100644 index 000000000..218eac380 --- /dev/null +++ b/packages/cookie-banner/src/cookie-utils.ts @@ -0,0 +1,133 @@ +// 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. + +// [unitSeconds, singular, plural, snapBuffer] +// snapBuffer: if the remainder is within this many seconds of the next +// whole unit, round up instead of carrying into smaller units. +const DURATION_UNITS: [number, string, string, number][] = [ + [365 * 24 * 3600, "year", "years", 21 * 24 * 3600], + [30 * 24 * 3600, "month", "months", 2 * 24 * 3600], + [7 * 24 * 3600, "week", "weeks", 12 * 3600], + [24 * 3600, "day", "days", 0], + [3600, "hour", "hours", 5 * 60], + [60, "minute", "minutes", 15], +]; + +function humanizeDuration(seconds: number): string { + if (seconds <= 0) return "session"; + + let remaining = seconds; + const parts: string[] = []; + + for (const [unit, singular, plural, snap] of DURATION_UNITS) { + if (remaining >= unit) { + let count = Math.floor(remaining / unit); + const leftover = remaining - count * unit; + + if (leftover >= unit - snap) { + count++; + remaining = 0; + } else if (leftover <= snap) { + remaining = 0; + } else { + remaining = leftover; + } + + parts.push(count === 1 ? `1 ${singular}` : `${count} ${plural}`); + } + } + + return parts.length > 0 ? parts.join(", ") : "session"; +} + +export function parseCookieName(raw: string): string { + const eqIdx = raw.indexOf("="); + if (eqIdx === -1) return raw.trim(); + return raw.substring(0, eqIdx).trim(); +} + +export function parseDuration(raw: string): string { + const parts = raw.split(";").map((s) => s.trim()); + + for (const part of parts) { + const lower = part.toLowerCase(); + if (lower.startsWith("max-age=")) { + const val = parseInt(part.substring(8), 10); + if (isNaN(val) || val <= 0) return "session"; + return humanizeDuration(val); + } + } + + for (const part of parts) { + const lower = part.toLowerCase(); + if (lower.startsWith("expires=")) { + const dateStr = part.substring(8); + const expires = new Date(dateStr); + if (isNaN(expires.getTime())) return "session"; + const deltaSeconds = Math.round( + (expires.getTime() - Date.now()) / 1000, + ); + if (deltaSeconds <= 0) return "session"; + return humanizeDuration(deltaSeconds); + } + } + + return "session"; +} + +export function isDeletion(raw: string): boolean { + const parts = raw.split(";").map((s) => s.trim().toLowerCase()); + + for (const part of parts) { + if (part.startsWith("max-age=")) { + const val = parseInt(part.substring(8), 10); + if (val <= 0) return true; + } + if (part.startsWith("expires=")) { + const dateStr = part.substring(8); + const expires = new Date(dateStr); + if (!isNaN(expires.getTime()) && expires.getTime() <= Date.now()) { + return true; + } + } + } + + return false; +} + +function getCandidateDomains(hostname: string): string[] { + const parts = hostname.split("."); + if (parts.length <= 1) return []; + + const candidates: string[] = []; + // Try progressively broader parent domains. The browser silently + // ignores attempts to clear cookies on public suffixes, so + // over-trying is safe and avoids maintaining a TLD list. + for (let i = 0; i < parts.length - 1; i++) { + candidates.push("." + parts.slice(i).join(".")); + } + + return candidates; +} + +export function removeCookies(names: string[]): void { + const domains = getCandidateDomains(location.hostname); + + for (const name of names) { + document.cookie = `${name}=; path=/; max-age=0`; + for (const domain of domains) { + document.cookie = `${name}=; path=/; domain=${domain}; max-age=0`; + } + } +} diff --git a/packages/cookie-banner/src/cookie.ts b/packages/cookie-banner/src/cookie.ts index 9cd0442d9..41c98f664 100644 --- a/packages/cookie-banner/src/cookie.ts +++ b/packages/cookie-banner/src/cookie.ts @@ -14,7 +14,7 @@ import type { ConsentAction } from "./client"; -const COOKIE_NAME = "probo_consent"; +export const COOKIE_NAME = "probo_consent"; const SECONDS_PER_DAY = 86400; export interface ConsentCookie { diff --git a/packages/cookie-banner/src/detector.ts b/packages/cookie-banner/src/detector.ts new file mode 100644 index 000000000..c15b9f0ef --- /dev/null +++ b/packages/cookie-banner/src/detector.ts @@ -0,0 +1,130 @@ +// 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 { isDeletion, parseCookieName, parseDuration } from "./cookie-utils"; +import { fetchJSON } from "./http"; + +interface DetectedCookieEntry { + name: string; + duration: string; +} + +const DEBOUNCE_MS = 2_000; + +export class CookieDetector { + private readonly reportUrl: URL; + private readonly knownNames: Set; + private readonly reported: Set = new Set(); + private readonly pending: Map = new Map(); + private timer: ReturnType | null = null; + private originalDescriptor: PropertyDescriptor | null = null; + + constructor(baseUrl: URL, bannerId: string, knownNames: Set) { + this.reportUrl = new URL(`${bannerId}/detected-cookies`, baseUrl); + this.knownNames = knownNames; + } + + start(): void { + const desc = + Object.getOwnPropertyDescriptor(Document.prototype, "cookie") ?? + Object.getOwnPropertyDescriptor(HTMLDocument.prototype, "cookie"); + + if (!desc?.set || !desc?.get) return; + + this.originalDescriptor = desc; + + const self = this; + const originalGet = desc.get; + const originalSet = desc.set; + + Object.defineProperty(document, "cookie", { + configurable: true, + get() { + return originalGet.call(this); + }, + set(value: string) { + originalSet.call(this, value); + self.onCookieSet(value); + }, + }); + + this.scanExisting(); + } + + stop(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + if (this.pending.size > 0) { + this.flush(); + } + + if (this.originalDescriptor) { + Object.defineProperty(document, "cookie", this.originalDescriptor); + this.originalDescriptor = null; + } + } + + private onCookieSet(raw: string): void { + if (isDeletion(raw)) return; + + const name = parseCookieName(raw); + if (!name || this.knownNames.has(name) || this.reported.has(name)) return; + + const duration = parseDuration(raw); + + this.reported.add(name); + this.pending.set(name, { name, duration }); + this.scheduleFlush(); + } + + private scanExisting(): void { + const cookieStr = document.cookie; + if (!cookieStr) return; + + for (const pair of cookieStr.split(";")) { + const name = pair.split("=")[0]?.trim(); + if (!name || this.knownNames.has(name) || this.reported.has(name)) { + continue; + } + this.reported.add(name); + this.pending.set(name, { name, duration: "session" }); + } + + 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 { + const entries = Array.from(this.pending.values()); + this.pending.clear(); + if (entries.length === 0) return; + + void fetchJSON(this.reportUrl, { + method: "POST", + body: { cookies: entries }, + }).catch(() => {}); + } +} diff --git a/packages/cookie-banner/src/queue.ts b/packages/cookie-banner/src/queue.ts index cdc859647..9259c05bf 100644 --- a/packages/cookie-banner/src/queue.ts +++ b/packages/cookie-banner/src/queue.ts @@ -12,9 +12,8 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { COOKIE_NAME } from "./cookie"; import { fetchJSON } from "./http"; - -const STORAGE_KEY_PREFIX = "probo_consent"; const MAX_QUEUE_SIZE = 10; const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; @@ -25,7 +24,7 @@ interface PendingConsent { } function storageKey(bannerId: string): string { - return `${STORAGE_KEY_PREFIX}:${bannerId}:queue`; + return `${COOKIE_NAME}:${bannerId}:queue`; } function readQueue(bannerId: string): PendingConsent[] { diff --git a/packages/cookie-banner/src/visitor.ts b/packages/cookie-banner/src/visitor.ts index 1ed5a93e8..e58257d45 100644 --- a/packages/cookie-banner/src/visitor.ts +++ b/packages/cookie-banner/src/visitor.ts @@ -12,10 +12,10 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -const STORAGE_KEY_PREFIX = "probo_consent"; +import { COOKIE_NAME } from "./cookie"; export function getOrCreateVisitorId(bannerId: string): string { - const key = `${STORAGE_KEY_PREFIX}:${bannerId}:vid`; + const key = `${COOKIE_NAME}:${bannerId}:vid`; try { const stored = localStorage.getItem(key);