* fix(banner): bridge blocker state between loader and bundle
``consent-loader.js`` and ``consent-bundle.js`` are built as two
separate rollup IIFEs, so each inlines a private copy of
``blocker.ts``. The loader's copy is the one that actually matters
— it installs the ``document.cookie`` / ``Storage.prototype.setItem``
/ ``MutationObserver`` proxies, and those proxies close over the
loader's private ``acceptedCategories`` set and its
``blockedScripts`` queue.
The bundle used to ``import { updateAcceptedCategories } from './blocker'``
and call it from ``handleConsent``. That updated the **bundle's**
dead-end copy of the blocker module — a different ``Set`` object in
a different scope from the one the proxies read — so after the user
granted consent the loader's proxies stayed stuck on
``Set(['necessary'])``, any non-necessary cookie write kept being
silently dropped, and the loader's queue of blocked scripts was
never released.
Fix by exposing the loader's live ``updateAcceptedCategories`` on
``window.__consentos._updateBlocker`` right after
``installBlocker()``, and replacing the bundle's dead-end import
with a helper that calls through the bridge. The bundle no longer
imports from ``./blocker`` at all; rollup tree-shakes the bundle's
copy out, so ``consent-bundle.js`` gets slightly smaller.
Tests (``__tests__/blocker-bridge.test.ts``) cover:
* Bridge is called with the exact accepted list.
* Bridge is forwarded verbatim (no slug filtering).
* Missing bridge logs a warning and doesn't throw.
* Missing ``window.__consentos`` logs a warning and doesn't throw.
``vi.hoisted`` seeds ``window.__consentos`` before banner.ts's
module-level ``init()`` runs so the import-time IIFE doesn't throw
on the empty global.
* fix(banner): sweep disallowed cookies + storage on consent update
When the loader runs it now proactively deletes any pre-existing
cookies (and localStorage / sessionStorage keys) that classify into
a category the visitor hasn't consented to. Fixes the common case
of an ``_ga`` that slipped through before the loader was installed
— e.g. on a host page that loads the loader with ``async`` or
places another tracker above it in ``<head>`` — sitting there
forever because the blocker's only defence was a setter proxy on
future writes.
Sweep semantics:
- Runs on every ``updateAcceptedCategories`` call (consent narrowed
→ the just-revoked categories' cookies are wiped) plus an
explicit call from the loader's "no consent yet" branch (initial
visit with pre-existing trackers from elsewhere).
- Only deletes cookies / keys whose classifier hits a known
pattern (``_ga``, ``_fbp``, ``intercom-*`` etc. — same lists as
the proxied setters). Unknown / unclassified cookies are left
alone: we can't attribute them and won't risk clobbering
first-party session state.
- ``_consentos_*`` is always preserved.
- For cookies, we don't know the original ``domain`` / ``path``
(``document.cookie`` doesn't expose them), so we fire deletes
against every plausible domain variant — bare hostname, leading
``.``, and every parent domain walked up from the left. Covers
the GA "set on ``.example.com`` from a subdomain page" case
without over-deleting.
- Deletions bypass the proxied setter and go directly through the
cached ``originalCookieDescriptor`` captured before the proxy was
installed, so the blocker doesn't eat its own expiry writes.
- Storage access is wrapped in ``safeStorage`` — sandboxed /
cross-origin iframes that throw on ``window.localStorage`` are
skipped rather than crashing the loader.
Tests in ``__tests__/blocker.test.ts`` cover: non-consented analytics
cookies are deleted, consented ones are preserved, unknown cookies
survive, ``_consentos_*`` is untouched, revoking a category after
seeding new cookies triggers a follow-up sweep, and localStorage
cleanup follows the same rules. 6 new cases, 373 passing total in
the banner suite.
648 lines
21 KiB
TypeScript
648 lines
21 KiB
TypeScript
/**
|
|
* blocker.ts — Script interceptor, cookie blocker, and release manager.
|
|
*
|
|
* Installs before any third-party scripts run. Intercepts script creation,
|
|
* proxies document.cookie and Storage writes, and maintains a queue of
|
|
* blocked resources that are released per-category when consent is granted.
|
|
*/
|
|
|
|
import type { CategorySlug, InitiatorMapping } from './types';
|
|
|
|
/** A script element that was blocked, along with its assigned category. */
|
|
interface BlockedScript {
|
|
/** The original script element or a clone of it. */
|
|
element: HTMLScriptElement;
|
|
/** The consent category this script belongs to. */
|
|
category: CategorySlug;
|
|
}
|
|
|
|
/** Pattern-to-category mapping for URL-based script classification. */
|
|
interface ScriptPattern {
|
|
pattern: RegExp;
|
|
category: CategorySlug;
|
|
}
|
|
|
|
/** Categories that have been consented to. */
|
|
let acceptedCategories: Set<CategorySlug> = new Set(['necessary']);
|
|
|
|
/** Queue of blocked scripts awaiting consent. */
|
|
const blockedScripts: BlockedScript[] = [];
|
|
|
|
/** URL patterns for classifying scripts by category. */
|
|
const scriptPatterns: ScriptPattern[] = [];
|
|
|
|
/** Root initiator URL → category mappings for root-level blocking. */
|
|
const initiatorMappings: Array<{ pattern: RegExp; category: CategorySlug }> = [];
|
|
|
|
/** Whether the blocker has been installed. */
|
|
let installed = false;
|
|
|
|
/** Original document.createElement reference. */
|
|
let originalCreateElement: typeof document.createElement;
|
|
|
|
/** Original document.cookie descriptor. */
|
|
let originalCookieDescriptor: PropertyDescriptor | undefined;
|
|
|
|
/** Original Storage.prototype.setItem reference. */
|
|
let originalLocalStorageSetItem: typeof Storage.prototype.setItem;
|
|
|
|
// ─── Well-known script patterns (built-in defaults) ───
|
|
|
|
const BUILTIN_PATTERNS: ScriptPattern[] = [
|
|
// Analytics
|
|
{ pattern: /google-analytics\.com/i, category: 'analytics' },
|
|
{ pattern: /googletagmanager\.com/i, category: 'analytics' },
|
|
{ pattern: /gtag\/js/i, category: 'analytics' },
|
|
{ pattern: /analytics\./i, category: 'analytics' },
|
|
{ pattern: /hotjar\.com/i, category: 'analytics' },
|
|
{ pattern: /clarity\.ms/i, category: 'analytics' },
|
|
{ pattern: /plausible\.io/i, category: 'analytics' },
|
|
{ pattern: /matomo\./i, category: 'analytics' },
|
|
|
|
// Marketing
|
|
{ pattern: /doubleclick\.net/i, category: 'marketing' },
|
|
{ pattern: /facebook\.net/i, category: 'marketing' },
|
|
{ pattern: /fbevents\.js/i, category: 'marketing' },
|
|
{ pattern: /connect\.facebook/i, category: 'marketing' },
|
|
{ pattern: /ads-twitter\.com/i, category: 'marketing' },
|
|
{ pattern: /linkedin\.com\/insight/i, category: 'marketing' },
|
|
{ pattern: /snap\.licdn\.com/i, category: 'marketing' },
|
|
{ pattern: /tiktok\.com\/i18n/i, category: 'marketing' },
|
|
{ pattern: /googlesyndication\.com/i, category: 'marketing' },
|
|
{ pattern: /adservice\.google/i, category: 'marketing' },
|
|
|
|
// Functional
|
|
{ pattern: /intercom\.com/i, category: 'functional' },
|
|
{ pattern: /crisp\.chat/i, category: 'functional' },
|
|
{ pattern: /livechatinc\.com/i, category: 'functional' },
|
|
{ pattern: /zendesk\.com/i, category: 'functional' },
|
|
];
|
|
|
|
// ─── Public API ───
|
|
|
|
/** Install all interception hooks. Call once, as early as possible. */
|
|
export function installBlocker(): void {
|
|
if (installed) return;
|
|
installed = true;
|
|
|
|
// Merge built-in patterns
|
|
scriptPatterns.push(...BUILTIN_PATTERNS);
|
|
|
|
// Install hooks
|
|
installCreateElementOverride();
|
|
installMutationObserver();
|
|
installCookieProxy();
|
|
installStorageProxy();
|
|
}
|
|
|
|
/** Add custom URL-to-category patterns (e.g. from site config allow-list). */
|
|
export function addScriptPatterns(patterns: Array<{ pattern: string; category: CategorySlug }>): void {
|
|
for (const p of patterns) {
|
|
try {
|
|
scriptPatterns.push({ pattern: new RegExp(p.pattern, 'i'), category: p.category });
|
|
} catch {
|
|
console.warn(`[ConsentOS] Invalid script pattern: ${p.pattern}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load initiator mappings from the site config. Each mapping identifies a root
|
|
* script URL that is known to set cookies in a given category via a chain of
|
|
* child scripts. Blocking the root prevents the entire chain from executing.
|
|
*/
|
|
export function loadInitiatorMappings(mappings: InitiatorMapping[]): void {
|
|
for (const m of mappings) {
|
|
try {
|
|
initiatorMappings.push({ pattern: new RegExp(m.root_script, 'i'), category: m.category });
|
|
} catch {
|
|
console.warn(`[ConsentOS] Invalid initiator pattern: ${m.root_script}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the set of accepted categories, release any blocked scripts
|
|
* that now have consent, and sweep any existing cookies / storage
|
|
* items that belong to a category the visitor has **not** consented
|
|
* to. Consented categories are left untouched — those cookies are
|
|
* presumed to be in use by the site.
|
|
*/
|
|
export function updateAcceptedCategories(categories: CategorySlug[]): void {
|
|
acceptedCategories = new Set(categories);
|
|
releaseBlockedScripts();
|
|
sweepDisallowedState();
|
|
}
|
|
|
|
/**
|
|
* Delete any existing cookies and storage items whose classified
|
|
* category isn't currently consented. Runs on install and every
|
|
* consent-state update so historical trackers from pre-consent, a
|
|
* previous session, or a narrowed consent decision get removed.
|
|
* Unknown / unclassified cookies are left alone since we can't
|
|
* attribute them to a category.
|
|
*/
|
|
export function sweepDisallowedState(): void {
|
|
sweepDisallowedCookies();
|
|
sweepDisallowedStorage();
|
|
}
|
|
|
|
/** Get the current blocked script count (useful for debugging/reporting). */
|
|
export function getBlockedCount(): number {
|
|
return blockedScripts.length;
|
|
}
|
|
|
|
/** Check whether a given category is currently accepted. */
|
|
export function isCategoryAllowed(category: CategorySlug): boolean {
|
|
return acceptedCategories.has(category);
|
|
}
|
|
|
|
// ─── Script interception ───
|
|
|
|
/**
|
|
* Override document.createElement to intercept <script> creation.
|
|
* When a script is created and its src matches a known pattern,
|
|
* we set its type to 'text/blocked' to prevent execution.
|
|
*/
|
|
function installCreateElementOverride(): void {
|
|
originalCreateElement = document.createElement.bind(document);
|
|
|
|
document.createElement = function (
|
|
tagName: string,
|
|
options?: ElementCreationOptions
|
|
): HTMLElement {
|
|
const element = originalCreateElement(tagName, options);
|
|
|
|
if (tagName.toLowerCase() === 'script') {
|
|
const script = element as HTMLScriptElement;
|
|
wrapScriptElement(script);
|
|
}
|
|
|
|
return element;
|
|
} as typeof document.createElement;
|
|
}
|
|
|
|
/**
|
|
* Wrap a script element's `src` setter so that when a src is assigned,
|
|
* we can classify and potentially block it.
|
|
*/
|
|
function wrapScriptElement(script: HTMLScriptElement): void {
|
|
const originalSrcDescriptor = Object.getOwnPropertyDescriptor(
|
|
HTMLScriptElement.prototype,
|
|
'src'
|
|
);
|
|
if (!originalSrcDescriptor) return;
|
|
|
|
let pendingSrc = '';
|
|
|
|
Object.defineProperty(script, 'src', {
|
|
get() {
|
|
return pendingSrc || originalSrcDescriptor.get?.call(this) || '';
|
|
},
|
|
set(value: string) {
|
|
pendingSrc = value;
|
|
const category = classifyScript(value, script);
|
|
|
|
if (category && category !== 'necessary' && !acceptedCategories.has(category)) {
|
|
// Block: change type to prevent execution
|
|
script.type = 'text/blocked';
|
|
script.setAttribute('data-consentos-blocked', 'true');
|
|
script.setAttribute('data-consentos-category', category);
|
|
script.setAttribute('data-consentos-original-src', value);
|
|
}
|
|
|
|
originalSrcDescriptor.set?.call(this, value);
|
|
},
|
|
configurable: true,
|
|
enumerable: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* MutationObserver watches for script elements being added to the DOM.
|
|
* If a script should be blocked, we remove it and queue it.
|
|
*/
|
|
function installMutationObserver(): void {
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
for (const node of mutation.addedNodes) {
|
|
if (node instanceof HTMLScriptElement) {
|
|
handleInsertedScript(node);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Observe as early as possible
|
|
if (document.documentElement) {
|
|
observer.observe(document.documentElement, {
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Handle a script element that was just inserted into the DOM. */
|
|
function handleInsertedScript(script: HTMLScriptElement): void {
|
|
// Skip if it's our own script or already processed
|
|
if (script.hasAttribute('data-consentos-allowed') || script.hasAttribute('data-consentos-queued')) {
|
|
return;
|
|
}
|
|
|
|
// Check explicit data-category attribute first
|
|
const explicitCategory = script.getAttribute('data-category') as CategorySlug | null;
|
|
const src = script.getAttribute('data-consentos-original-src') || script.src || '';
|
|
const category = explicitCategory || classifyScript(src, script);
|
|
|
|
// Necessary scripts always pass through
|
|
if (!category || category === 'necessary') {
|
|
return;
|
|
}
|
|
|
|
// If already consented, allow through
|
|
if (acceptedCategories.has(category)) {
|
|
return;
|
|
}
|
|
|
|
// Block: remove from DOM and queue
|
|
script.setAttribute('data-consentos-queued', 'true');
|
|
|
|
// Clone the script for later re-insertion
|
|
const clone = originalCreateElement('script') as HTMLScriptElement;
|
|
// Copy attributes
|
|
for (const attr of Array.from(script.attributes)) {
|
|
if (attr.name !== 'type' && attr.name !== 'data-consentos-blocked' && attr.name !== 'data-consentos-queued') {
|
|
clone.setAttribute(attr.name, attr.value);
|
|
}
|
|
}
|
|
// Copy inline content
|
|
if (script.textContent) {
|
|
clone.textContent = script.textContent;
|
|
}
|
|
// Restore original src if it was rewritten
|
|
const originalSrc = script.getAttribute('data-consentos-original-src');
|
|
if (originalSrc) {
|
|
clone.setAttribute('data-consentos-original-src', originalSrc);
|
|
}
|
|
|
|
blockedScripts.push({ element: clone, category });
|
|
|
|
// Remove from DOM to prevent execution
|
|
if (script.parentNode) {
|
|
script.parentNode.removeChild(script);
|
|
}
|
|
}
|
|
|
|
// ─── Cookie proxy ───
|
|
|
|
/**
|
|
* Proxy document.cookie setter to block cookie writes from
|
|
* non-essential categories. We check the cookie name against
|
|
* known patterns and the ConsentOS's own cookie is always allowed.
|
|
*/
|
|
function installCookieProxy(): void {
|
|
originalCookieDescriptor = Object.getOwnPropertyDescriptor(
|
|
Document.prototype,
|
|
'cookie'
|
|
);
|
|
if (!originalCookieDescriptor) return;
|
|
|
|
Object.defineProperty(document, 'cookie', {
|
|
get() {
|
|
return originalCookieDescriptor!.get?.call(document) ?? '';
|
|
},
|
|
set(value: string) {
|
|
// Always allow ConsentOS's own cookies
|
|
if (value.startsWith('_consentos_')) {
|
|
originalCookieDescriptor!.set?.call(document, value);
|
|
return;
|
|
}
|
|
|
|
// If consent hasn't been collected yet and we're in opt-in mode,
|
|
// block all non-essential cookie writes
|
|
if (!allNonEssentialConsented()) {
|
|
const cookieName = parseCookieName(value);
|
|
const category = classifyCookie(cookieName);
|
|
|
|
if (category && category !== 'necessary' && !acceptedCategories.has(category)) {
|
|
// Silently block
|
|
return;
|
|
}
|
|
}
|
|
|
|
originalCookieDescriptor!.set?.call(document, value);
|
|
},
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
// ─── Storage proxy ───
|
|
|
|
/** Proxy localStorage and sessionStorage setItem to block non-essential writes. */
|
|
function installStorageProxy(): void {
|
|
if (typeof Storage !== 'undefined') {
|
|
originalLocalStorageSetItem = Storage.prototype.setItem;
|
|
Storage.prototype.setItem = function (key: string, value: string): void {
|
|
if (shouldBlockStorageWrite(key)) return;
|
|
originalLocalStorageSetItem.call(this, key, value);
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Check if a storage write should be blocked. */
|
|
function shouldBlockStorageWrite(key: string): boolean {
|
|
// Always allow ConsentOS's own storage
|
|
if (key.startsWith('_consentos_')) return false;
|
|
|
|
// If all non-essential categories are consented, allow everything
|
|
if (allNonEssentialConsented()) return false;
|
|
|
|
// Block known tracking storage keys
|
|
const category = classifyStorageKey(key);
|
|
if (category && category !== 'necessary' && !acceptedCategories.has(category)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ─── Release manager ───
|
|
|
|
/** Release blocked scripts whose categories are now accepted. */
|
|
function releaseBlockedScripts(): void {
|
|
const toRelease: BlockedScript[] = [];
|
|
const remaining: BlockedScript[] = [];
|
|
|
|
for (const blocked of blockedScripts) {
|
|
if (acceptedCategories.has(blocked.category)) {
|
|
toRelease.push(blocked);
|
|
} else {
|
|
remaining.push(blocked);
|
|
}
|
|
}
|
|
|
|
// Clear and repopulate the queue
|
|
blockedScripts.length = 0;
|
|
blockedScripts.push(...remaining);
|
|
|
|
// Re-insert released scripts in order
|
|
for (const { element } of toRelease) {
|
|
const script = originalCreateElement('script') as HTMLScriptElement;
|
|
|
|
// Copy all attributes
|
|
for (const attr of Array.from(element.attributes)) {
|
|
if (attr.name !== 'data-consentos-blocked' && attr.name !== 'data-consentos-queued' && attr.name !== 'data-consentos-category') {
|
|
script.setAttribute(attr.name, attr.value);
|
|
}
|
|
}
|
|
|
|
// Use original src if stored
|
|
const originalSrc = element.getAttribute('data-consentos-original-src');
|
|
if (originalSrc) {
|
|
script.src = originalSrc;
|
|
script.removeAttribute('data-consentos-original-src');
|
|
}
|
|
|
|
// Copy inline script content
|
|
if (element.textContent && !script.src) {
|
|
script.textContent = element.textContent;
|
|
}
|
|
|
|
// Mark as allowed so the observer doesn't re-block it
|
|
script.setAttribute('data-consentos-allowed', 'true');
|
|
|
|
// Insert into head
|
|
(document.head || document.documentElement).appendChild(script);
|
|
}
|
|
}
|
|
|
|
// ─── Classification helpers ───
|
|
|
|
/** Classify a script by its URL against known patterns and initiator mappings. */
|
|
function classifyScript(src: string, script: HTMLScriptElement): CategorySlug | null {
|
|
if (!src) return null;
|
|
|
|
// Explicit data-category always wins
|
|
const explicit = script.getAttribute('data-category') as CategorySlug | null;
|
|
if (explicit) return explicit;
|
|
|
|
// Match against URL patterns
|
|
for (const { pattern, category } of scriptPatterns) {
|
|
if (pattern.test(src)) return category;
|
|
}
|
|
|
|
// Check initiator mappings — block root scripts that are known to set
|
|
// cookies in non-consented categories via downstream child scripts
|
|
for (const { pattern, category } of initiatorMappings) {
|
|
if (pattern.test(src)) return category;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Well-known cookie name patterns mapped to categories. */
|
|
const COOKIE_PATTERNS: Array<{ pattern: RegExp; category: CategorySlug }> = [
|
|
// Analytics
|
|
{ pattern: /^_ga/i, category: 'analytics' },
|
|
{ pattern: /^_gid$/i, category: 'analytics' },
|
|
{ pattern: /^_gat/i, category: 'analytics' },
|
|
{ pattern: /^_hjSession/i, category: 'analytics' },
|
|
{ pattern: /^_hj/i, category: 'analytics' },
|
|
{ pattern: /^_pk_/i, category: 'analytics' },
|
|
{ pattern: /^_clck$/i, category: 'analytics' },
|
|
{ pattern: /^_clsk$/i, category: 'analytics' },
|
|
|
|
// Marketing
|
|
{ pattern: /^_fbp$/i, category: 'marketing' },
|
|
{ pattern: /^_fbc$/i, category: 'marketing' },
|
|
{ pattern: /^_gcl_/i, category: 'marketing' },
|
|
{ pattern: /^IDE$/i, category: 'marketing' },
|
|
{ pattern: /^NID$/i, category: 'marketing' },
|
|
{ pattern: /^test_cookie$/i, category: 'marketing' },
|
|
{ pattern: /^_uetsid/i, category: 'marketing' },
|
|
{ pattern: /^_uetvid/i, category: 'marketing' },
|
|
|
|
// Functional
|
|
{ pattern: /^intercom-/i, category: 'functional' },
|
|
{ pattern: /^crisp-/i, category: 'functional' },
|
|
];
|
|
|
|
/** Classify a cookie by its name. */
|
|
function classifyCookie(name: string): CategorySlug | null {
|
|
for (const { pattern, category } of COOKIE_PATTERNS) {
|
|
if (pattern.test(name)) return category;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Well-known storage key patterns. */
|
|
const STORAGE_PATTERNS: Array<{ pattern: RegExp; category: CategorySlug }> = [
|
|
{ pattern: /^_ga/i, category: 'analytics' },
|
|
{ pattern: /^_hj/i, category: 'analytics' },
|
|
{ pattern: /^intercom\./i, category: 'functional' },
|
|
{ pattern: /^crisp-/i, category: 'functional' },
|
|
{ pattern: /^fb_/i, category: 'marketing' },
|
|
];
|
|
|
|
/** Classify a storage key by known patterns. */
|
|
function classifyStorageKey(key: string): CategorySlug | null {
|
|
for (const { pattern, category } of STORAGE_PATTERNS) {
|
|
if (pattern.test(key)) return category;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Parse the cookie name from a Set-Cookie string. */
|
|
function parseCookieName(cookieString: string): string {
|
|
const eqIndex = cookieString.indexOf('=');
|
|
if (eqIndex === -1) return cookieString.trim();
|
|
return cookieString.substring(0, eqIndex).trim();
|
|
}
|
|
|
|
/** Check if all non-essential categories have been consented to. */
|
|
function allNonEssentialConsented(): boolean {
|
|
return (
|
|
acceptedCategories.has('functional') &&
|
|
acceptedCategories.has('analytics') &&
|
|
acceptedCategories.has('marketing') &&
|
|
acceptedCategories.has('personalisation')
|
|
);
|
|
}
|
|
|
|
// ─── Sweep existing state ──────────────────────────────────────────────
|
|
|
|
/** Delete classified cookies that aren't in a consented category. */
|
|
function sweepDisallowedCookies(): void {
|
|
if (typeof document === 'undefined') return;
|
|
if (!originalCookieDescriptor?.set) return;
|
|
|
|
const cookieHeader = document.cookie || '';
|
|
if (!cookieHeader) return;
|
|
|
|
const nativeSet = originalCookieDescriptor.set.bind(document);
|
|
const seen = new Set<string>();
|
|
|
|
for (const entry of cookieHeader.split(';')) {
|
|
const name = parseCookieName(entry);
|
|
if (!name || seen.has(name)) continue;
|
|
seen.add(name);
|
|
|
|
// Never touch ConsentOS's own cookies.
|
|
if (name.startsWith('_consentos_')) continue;
|
|
|
|
const category = classifyCookie(name);
|
|
// Unknown / unclassified cookies get left alone — we can't
|
|
// attribute them so we can't safely delete them.
|
|
if (!category || category === 'necessary') continue;
|
|
if (acceptedCategories.has(category)) continue;
|
|
|
|
// Expire the cookie. We don't know the domain / path the cookie
|
|
// was set on, so we fire deletes for every plausible combination:
|
|
// the current hostname bare, the leading-dot form, and every
|
|
// parent domain walked up from the left. This catches the common
|
|
// case of analytics cookies set on ``.example.com`` from a
|
|
// subdomain page without over-deleting.
|
|
const expired = 'expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
|
try {
|
|
nativeSet(`${name}=; ${expired}`);
|
|
for (const domain of domainVariants()) {
|
|
nativeSet(`${name}=; ${expired}; domain=${domain}`);
|
|
}
|
|
} catch {
|
|
// Writing a cookie can throw in exotic sandboxed contexts;
|
|
// best-effort, don't crash the loader.
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Derived list of plausible cookie domains for the current hostname. */
|
|
function domainVariants(): string[] {
|
|
if (typeof location === 'undefined' || !location.hostname) return [];
|
|
|
|
const hostname = location.hostname;
|
|
// IP addresses and ``localhost`` have no "parent domain" concept.
|
|
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname === 'localhost') {
|
|
return [hostname];
|
|
}
|
|
|
|
const parts = hostname.split('.');
|
|
const variants: string[] = [];
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const parent = parts.slice(i).join('.');
|
|
if (parent) {
|
|
variants.push(parent, `.${parent}`);
|
|
}
|
|
}
|
|
return Array.from(new Set(variants));
|
|
}
|
|
|
|
/** Delete classified localStorage / sessionStorage keys that aren't consented. */
|
|
function sweepDisallowedStorage(): void {
|
|
if (typeof Storage === 'undefined') return;
|
|
|
|
for (const storage of [safeStorage('local'), safeStorage('session')]) {
|
|
if (!storage) continue;
|
|
|
|
const toRemove: string[] = [];
|
|
try {
|
|
for (let i = 0; i < storage.length; i++) {
|
|
const key = storage.key(i);
|
|
if (!key || key.startsWith('_consentos_')) continue;
|
|
const category = classifyStorageKey(key);
|
|
if (!category || category === 'necessary') continue;
|
|
if (acceptedCategories.has(category)) continue;
|
|
toRemove.push(key);
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const key of toRemove) {
|
|
try {
|
|
storage.removeItem(key);
|
|
} catch {
|
|
// Ignore quota / security errors — best-effort.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Return the requested Storage instance, or null if inaccessible. */
|
|
function safeStorage(kind: 'local' | 'session'): Storage | null {
|
|
try {
|
|
return kind === 'local' ? window.localStorage : window.sessionStorage;
|
|
} catch {
|
|
// Access can throw on cross-origin / sandboxed iframes.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Teardown (for testing) ───
|
|
|
|
/** Remove all interception hooks. Used in tests. */
|
|
export function uninstallBlocker(): void {
|
|
if (!installed) return;
|
|
|
|
// Restore document.createElement
|
|
if (originalCreateElement) {
|
|
document.createElement = originalCreateElement;
|
|
}
|
|
|
|
// Restore document.cookie
|
|
if (originalCookieDescriptor) {
|
|
Object.defineProperty(document, 'cookie', originalCookieDescriptor);
|
|
}
|
|
|
|
// Restore storage
|
|
if (originalLocalStorageSetItem) {
|
|
Storage.prototype.setItem = originalLocalStorageSetItem;
|
|
}
|
|
|
|
// Clear state
|
|
blockedScripts.length = 0;
|
|
scriptPatterns.length = 0;
|
|
initiatorMappings.length = 0;
|
|
acceptedCategories = new Set(['necessary']);
|
|
installed = false;
|
|
}
|