From bd465008e57e0b11ac00b2d35f23e5eed2b4c68f Mon Sep 17 00:00:00 2001 From: James Cottrill <32595786+jamescottrill@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:30:02 +0100 Subject: [PATCH] =?UTF-8?q?fix(banner):=20bridge=20blocker=20state=20loade?= =?UTF-8?q?r=E2=86=94bundle=20and=20sweep=20stale=20cookies=20on=20consent?= =?UTF-8?q?=20change=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 ``
`` — 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. --- .../src/__tests__/blocker-bridge.test.ts | 116 ++++++++++++++++ apps/banner/src/__tests__/blocker.test.ts | 106 ++++++++++++++ apps/banner/src/banner.ts | 29 +++- apps/banner/src/blocker.ts | 129 +++++++++++++++++- apps/banner/src/loader.ts | 42 +++++- 5 files changed, 416 insertions(+), 6 deletions(-) create mode 100644 apps/banner/src/__tests__/blocker-bridge.test.ts diff --git a/apps/banner/src/__tests__/blocker-bridge.test.ts b/apps/banner/src/__tests__/blocker-bridge.test.ts new file mode 100644 index 0000000..0d90cd1 --- /dev/null +++ b/apps/banner/src/__tests__/blocker-bridge.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for the loader ↔ bundle blocker bridge. + * + * ``consent-loader.js`` and ``consent-bundle.js`` are compiled as + * separate rollup IIFEs, so each one inlines its own copy of + * ``blocker.ts`` with private module state. The bundle therefore + * can't reach the loader's ``acceptedCategories`` set via a direct + * import — it has to call through ``window.__consentos._updateBlocker``, + * which the loader sets after ``installBlocker()``. + * + * We mock the imports the banner module pulls in so importing + * ``banner.ts`` here doesn't try to hit real network / timers. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Seed ``window.__consentos`` before banner.ts's init() IIFE runs at +// import time. Without this, destructuring ``window.__consentos`` at +// the top of init() throws and fills the test output with noise. +vi.hoisted(() => { + (globalThis as any).window = (globalThis as any).window || globalThis; + (globalThis as any).window.__consentos = { + siteId: '', + apiBase: '', + cdnBase: '', + loaded: false, + }; +}); + +vi.mock('../consent', () => ({ + buildConsentState: vi.fn(() => ({ accepted: [], rejected: [] })), + readConsent: vi.fn(() => null), + writeConsent: vi.fn(), +})); + +vi.mock('../gcm', () => ({ + buildGcmStateFromCategories: vi.fn(() => ({})), + updateGcm: vi.fn(), +})); + +vi.mock('../i18n', () => ({ + DEFAULT_TRANSLATIONS: {}, + detectLocale: vi.fn(() => 'en'), + interpolate: vi.fn((s: string) => s), + loadTranslations: vi.fn(async () => ({})), + renderLinks: vi.fn((s: string) => s), +})); + +vi.mock('../a11y', () => ({ + announce: vi.fn(), + createLiveRegion: vi.fn(), + focusFirst: vi.fn(), + onEscape: vi.fn(() => () => {}), + prefersReducedMotion: vi.fn(() => false), + trapFocus: vi.fn(() => () => {}), +})); + +// Prevent banner.ts's init() IIFE from running against real globals. +vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('mocked')))); + +import { updateAcceptedCategories } from '../banner'; + +describe('loader ↔ bundle blocker bridge', () => { + beforeEach(() => { + window.__consentos = { + siteId: 'test', + apiBase: 'https://api.example.com', + cdnBase: 'https://cdn.example.com', + loaded: false, + }; + }); + + it('calls window.__consentos._updateBlocker when the bridge is present', () => { + const bridge = vi.fn(); + window.__consentos._updateBlocker = bridge; + + updateAcceptedCategories(['necessary', 'analytics']); + + expect(bridge).toHaveBeenCalledTimes(1); + expect(bridge).toHaveBeenCalledWith(['necessary', 'analytics']); + }); + + it('forwards the exact array reference so the loader sees every slug', () => { + const bridge = vi.fn(); + window.__consentos._updateBlocker = bridge; + + const accepted = ['necessary', 'functional', 'marketing'] as const; + updateAcceptedCategories([...accepted]); + + const args = bridge.mock.calls[0][0]; + expect(args).toEqual(['necessary', 'functional', 'marketing']); + }); + + it('warns and returns cleanly when the bridge is missing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete window.__consentos._updateBlocker; + + expect(() => updateAcceptedCategories(['necessary'])).not.toThrow(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('blocker bridge missing'), + ); + + warn.mockRestore(); + }); + + it('warns when window.__consentos is missing entirely', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // @ts-expect-error — simulating a pre-init state + window.__consentos = undefined; + + expect(() => updateAcceptedCategories(['necessary'])).not.toThrow(); + expect(warn).toHaveBeenCalled(); + + warn.mockRestore(); + }); +}); diff --git a/apps/banner/src/__tests__/blocker.test.ts b/apps/banner/src/__tests__/blocker.test.ts index 1d0813f..5fd267e 100644 --- a/apps/banner/src/__tests__/blocker.test.ts +++ b/apps/banner/src/__tests__/blocker.test.ts @@ -7,6 +7,7 @@ import { isCategoryAllowed, addScriptPatterns, loadInitiatorMappings, + sweepDisallowedState, } from '../blocker'; describe('blocker', () => { @@ -330,4 +331,109 @@ describe('blocker', () => { expect(isCategoryAllowed('necessary')).toBe(true); }); }); + + describe('sweepDisallowedState', () => { + /** + * Reset the cookie jar by expiring any test cookies we know + * about. ``document.cookie =`` runs through the proxy so + * ``_consentos_*`` passes the shortcut and everything else gets + * eaten by the blocker's classifier. Expiring the well-known + * analytics cookies here gives the individual tests a clean + * starting point without relying on the jsdom harness to wipe + * between tests. + */ + function resetCookies(names: string[]) { + for (const name of names) { + document.cookie = `_consentos_reset_marker=${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + } + } + + beforeEach(() => { + // Directly seed cookies via the native descriptor so the + // proxied setter doesn't eat them as "tracker writes with + // no consent". + const nativeSet = Object.getOwnPropertyDescriptor( + Document.prototype, + 'cookie', + )?.set; + if (!nativeSet) throw new Error('cannot locate native cookie setter'); + nativeSet.call( + document, + '_ga=GA1.2.seed; path=/', + ); + nativeSet.call( + document, + '_fbp=fb.2.seed; path=/', + ); + nativeSet.call( + document, + 'unknown_cookie=opaque; path=/', + ); + nativeSet.call( + document, + '_consentos_consent=%7B%7D; path=/', + ); + }); + + afterEach(() => { + resetCookies(['_ga', '_fbp', 'unknown_cookie', '_consentos_consent']); + }); + + it('deletes non-consented analytics cookies', () => { + updateAcceptedCategories(['necessary']); + // ^^ updateAcceptedCategories calls sweep internally; the + // assertions below verify the post-sweep cookie jar. + expect(document.cookie).not.toContain('_ga='); + expect(document.cookie).not.toContain('_fbp='); + }); + + it('leaves consented cookies alone', () => { + updateAcceptedCategories(['necessary', 'analytics', 'marketing']); + expect(document.cookie).toContain('_ga='); + expect(document.cookie).toContain('_fbp='); + }); + + it('leaves unknown cookies alone even without consent', () => { + updateAcceptedCategories(['necessary']); + expect(document.cookie).toContain('unknown_cookie='); + }); + + it('never touches _consentos_* cookies', () => { + updateAcceptedCategories(['necessary']); + expect(document.cookie).toContain('_consentos_consent='); + }); + + it('standalone sweepDisallowedState respects the current set', () => { + updateAcceptedCategories(['necessary', 'analytics']); + // Re-seed _ga after the first sweep would have left it (analytics consented). + const nativeSet = Object.getOwnPropertyDescriptor( + Document.prototype, + 'cookie', + )?.set; + nativeSet?.call(document, '_fbp=fb.2.reseed; path=/'); + + // Revoke marketing, sweep again. + updateAcceptedCategories(['necessary', 'analytics']); + sweepDisallowedState(); + expect(document.cookie).toContain('_ga='); + expect(document.cookie).not.toContain('_fbp='); + }); + + it('cleans non-consented localStorage keys', () => { + localStorage.setItem('_consentos_keep', 'yes'); + // Seed via a direct setItem — the proxied setter would block + // non-necessary writes, but we want a pre-existing key. + const nativeSetItem = Object.getPrototypeOf(localStorage).setItem; + nativeSetItem.call(localStorage, '_ga_stuff', 'tracker'); + nativeSetItem.call(localStorage, 'opaque_key', 'leave-alone'); + + updateAcceptedCategories(['necessary']); + + expect(localStorage.getItem('_consentos_keep')).toBe('yes'); + expect(localStorage.getItem('_ga_stuff')).toBeNull(); + expect(localStorage.getItem('opaque_key')).toBe('leave-alone'); + + localStorage.clear(); + }); + }); }); diff --git a/apps/banner/src/banner.ts b/apps/banner/src/banner.ts index a0878a3..159fad0 100644 --- a/apps/banner/src/banner.ts +++ b/apps/banner/src/banner.ts @@ -10,12 +10,39 @@ */ import { announce, createLiveRegion, focusFirst, onEscape, prefersReducedMotion, trapFocus } from './a11y'; -import { updateAcceptedCategories } from './blocker'; +// NB: intentionally NOT importing from './blocker'. The loader already +// installed the blocker proxies in its own IIFE module scope, and +// the bundle can't share that state via a direct import — rollup +// builds ``consent-loader.js`` and ``consent-bundle.js`` as separate +// IIFEs so each one inlines its own private copy of every module. +// The loader exposes ``_updateBlocker`` on ``window.__consentos`` +// for us to drive its proxies — see ``updateAcceptedCategories`` +// below and ``apps/banner/src/loader.ts``. import { buildConsentState, readConsent, writeConsent } from './consent'; import { buildGcmStateFromCategories, updateGcm } from './gcm'; import { type TranslationStrings, DEFAULT_TRANSLATIONS, detectLocale, interpolate, loadTranslations, renderLinks } from './i18n'; import type { BannerConfig, ButtonConfig, CategorySlug, SiteConfig } from './types'; +/** + * Drive the loader's blocker proxies with a new accepted-categories + * set. Falls back to a ``console.warn`` if the bridge is missing, + * which would mean the loader hasn't finished ``installBlocker`` yet + * (shouldn't happen — the bundle only loads after the loader's + * synchronous init phase). Exported for unit testing only. + */ +export function updateAcceptedCategories(accepted: CategorySlug[]): void { + const bridge = window.__consentos?._updateBlocker; + if (typeof bridge === 'function') { + bridge(accepted); + } else if (typeof console !== 'undefined') { + console.warn( + '[ConsentOS] blocker bridge missing — consent granted but ' + + 'cookie/script blocker state was not updated. The loader ' + + 'may not have initialised correctly.', + ); + } +} + // -- Preference-centre closure captured during init() --------------------- /** diff --git a/apps/banner/src/blocker.ts b/apps/banner/src/blocker.ts index 4b26f7c..fd4b6fd 100644 --- a/apps/banner/src/blocker.ts +++ b/apps/banner/src/blocker.ts @@ -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