feat: initial public release
ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
This commit is contained in:
522
apps/banner/src/blocker.ts
Normal file
522
apps/banner/src/blocker.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* 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 and release any blocked scripts
|
||||
* that now have consent.
|
||||
*/
|
||||
export function updateAcceptedCategories(categories: CategorySlug[]): void {
|
||||
acceptedCategories = new Set(categories);
|
||||
releaseBlockedScripts();
|
||||
}
|
||||
|
||||
/** 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')
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
Reference in New Issue
Block a user