fix(banner): bridge blocker state loader↔bundle and sweep stale cookies on consent change (#4)

* 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.
This commit is contained in:
James Cottrill
2026-04-14 17:30:02 +01:00
committed by GitHub
parent 0fbe2717f2
commit bd465008e5
5 changed files with 416 additions and 6 deletions

View File

@@ -122,12 +122,29 @@ export function loadInitiatorMappings(mappings: InitiatorMapping[]): void {
}
/**
* Update the set of accepted categories and release any blocked scripts
* that now have consent.
* 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). */
@@ -492,6 +509,114 @@ function allNonEssentialConsented(): boolean {
);
}
// ─── 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. */