@freshjuice/zest 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/zest.d.ts +7 -0
- package/dist/zest.de.js +658 -50
- package/dist/zest.de.js.map +1 -1
- package/dist/zest.de.min.js +1 -1
- package/dist/zest.en.js +658 -50
- package/dist/zest.en.js.map +1 -1
- package/dist/zest.en.min.js +1 -1
- package/dist/zest.es.js +658 -50
- package/dist/zest.es.js.map +1 -1
- package/dist/zest.es.min.js +1 -1
- package/dist/zest.esm.js +658 -50
- package/dist/zest.esm.js.map +1 -1
- package/dist/zest.esm.min.js +1 -1
- package/dist/zest.fr.js +658 -50
- package/dist/zest.fr.js.map +1 -1
- package/dist/zest.fr.min.js +1 -1
- package/dist/zest.headless.d.ts +7 -0
- package/dist/zest.headless.esm.js +612 -32
- package/dist/zest.headless.esm.js.map +1 -1
- package/dist/zest.headless.esm.min.js +1 -1
- package/dist/zest.it.js +658 -50
- package/dist/zest.it.js.map +1 -1
- package/dist/zest.it.min.js +1 -1
- package/dist/zest.ja.js +658 -50
- package/dist/zest.ja.js.map +1 -1
- package/dist/zest.ja.min.js +1 -1
- package/dist/zest.js +658 -50
- package/dist/zest.js.map +1 -1
- package/dist/zest.min.js +1 -1
- package/dist/zest.nl.js +658 -50
- package/dist/zest.nl.js.map +1 -1
- package/dist/zest.nl.min.js +1 -1
- package/dist/zest.pl.js +658 -50
- package/dist/zest.pl.js.map +1 -1
- package/dist/zest.pl.min.js +1 -1
- package/dist/zest.pt.js +658 -50
- package/dist/zest.pt.js.map +1 -1
- package/dist/zest.pt.min.js +1 -1
- package/dist/zest.ru.js +658 -50
- package/dist/zest.ru.js.map +1 -1
- package/dist/zest.ru.min.js +1 -1
- package/dist/zest.uk.js +658 -50
- package/dist/zest.uk.js.map +1 -1
- package/dist/zest.uk.min.js +1 -1
- package/dist/zest.zh.js +658 -50
- package/dist/zest.zh.js.map +1 -1
- package/dist/zest.zh.min.js +1 -1
- package/package.json +1 -1
- package/src/config/defaults.js +2 -1
- package/src/core/element-interceptor.js +374 -0
- package/src/core/network-interceptor.js +289 -0
- package/src/core-lifecycle.js +20 -1
- package/src/index.js +46 -18
- package/src/types/zest.d.ts +7 -0
- package/src/types/zest.headless.d.ts +7 -0
- package/zest.config.schema.json +26 -0
|
@@ -241,19 +241,19 @@ let originalCookieDescriptor = null;
|
|
|
241
241
|
// Upper bound on the number of queued cookies awaiting consent replay.
|
|
242
242
|
// An unbounded queue is a memory-exhaustion DoS vector — a hostile
|
|
243
243
|
// script could flood it with document.cookie writes.
|
|
244
|
-
const MAX_QUEUE_SIZE$
|
|
244
|
+
const MAX_QUEUE_SIZE$3 = 100;
|
|
245
245
|
|
|
246
246
|
// Queue for blocked cookies
|
|
247
247
|
const cookieQueue = [];
|
|
248
248
|
|
|
249
249
|
// Reference to consent checker function
|
|
250
|
-
let checkConsent$
|
|
250
|
+
let checkConsent$5 = () => false;
|
|
251
251
|
|
|
252
252
|
/**
|
|
253
253
|
* Set the consent checker function
|
|
254
254
|
*/
|
|
255
|
-
function setConsentChecker$
|
|
256
|
-
checkConsent$
|
|
255
|
+
function setConsentChecker$4(fn) {
|
|
256
|
+
checkConsent$5 = fn;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
/**
|
|
@@ -309,10 +309,10 @@ function interceptCookies() {
|
|
|
309
309
|
|
|
310
310
|
const category = getCategoryForName(name);
|
|
311
311
|
|
|
312
|
-
if (checkConsent$
|
|
312
|
+
if (checkConsent$5(category)) {
|
|
313
313
|
// Consent given - set cookie
|
|
314
314
|
originalCookieDescriptor.set.call(document, value);
|
|
315
|
-
} else if (cookieQueue.length < MAX_QUEUE_SIZE$
|
|
315
|
+
} else if (cookieQueue.length < MAX_QUEUE_SIZE$3) {
|
|
316
316
|
// No consent - queue for later (capped to prevent DoS)
|
|
317
317
|
cookieQueue.push({
|
|
318
318
|
value,
|
|
@@ -337,7 +337,7 @@ function interceptCookies() {
|
|
|
337
337
|
|
|
338
338
|
// Upper bound on queued operations awaiting consent replay — unbounded
|
|
339
339
|
// growth would be a memory-exhaustion DoS vector.
|
|
340
|
-
const MAX_QUEUE_SIZE$
|
|
340
|
+
const MAX_QUEUE_SIZE$2 = 200;
|
|
341
341
|
|
|
342
342
|
// Store originals
|
|
343
343
|
let originalLocalStorage = null;
|
|
@@ -348,13 +348,13 @@ const localStorageQueue = [];
|
|
|
348
348
|
const sessionStorageQueue = [];
|
|
349
349
|
|
|
350
350
|
// Reference to consent checker function
|
|
351
|
-
let checkConsent$
|
|
351
|
+
let checkConsent$4 = () => false;
|
|
352
352
|
|
|
353
353
|
/**
|
|
354
354
|
* Set the consent checker function
|
|
355
355
|
*/
|
|
356
|
-
function setConsentChecker$
|
|
357
|
-
checkConsent$
|
|
356
|
+
function setConsentChecker$3(fn) {
|
|
357
|
+
checkConsent$4 = fn;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
360
|
/**
|
|
@@ -367,9 +367,9 @@ function createStorageProxy(storage, queue, storageName) {
|
|
|
367
367
|
return (key, value) => {
|
|
368
368
|
const category = getCategoryForName(key);
|
|
369
369
|
|
|
370
|
-
if (checkConsent$
|
|
370
|
+
if (checkConsent$4(category)) {
|
|
371
371
|
target.setItem(key, value);
|
|
372
|
-
} else if (queue.length < MAX_QUEUE_SIZE$
|
|
372
|
+
} else if (queue.length < MAX_QUEUE_SIZE$2) {
|
|
373
373
|
queue.push({
|
|
374
374
|
key,
|
|
375
375
|
value,
|
|
@@ -652,11 +652,11 @@ function isThirdParty(url) {
|
|
|
652
652
|
|
|
653
653
|
// Categories the author has declared blockable. A script can self-label
|
|
654
654
|
// into one of these, but not into 'essential' (a common bypass).
|
|
655
|
-
const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
|
|
655
|
+
const BLOCKABLE_CATEGORIES$2 = new Set(['functional', 'analytics', 'marketing']);
|
|
656
656
|
|
|
657
657
|
// Upper bound on queued scripts awaiting consent replay — prevents a
|
|
658
658
|
// hostile page from flooding the queue with <script> nodes.
|
|
659
|
-
const MAX_QUEUE_SIZE = 500;
|
|
659
|
+
const MAX_QUEUE_SIZE$1 = 500;
|
|
660
660
|
|
|
661
661
|
// Queue for blocked scripts — the authoritative source for replay,
|
|
662
662
|
// snapshotting src/inline BEFORE any DOM mutation so later tampering
|
|
@@ -667,31 +667,31 @@ const scriptQueue = [];
|
|
|
667
667
|
let observer = null;
|
|
668
668
|
|
|
669
669
|
// Current blocking mode
|
|
670
|
-
let blockingMode = 'safe';
|
|
670
|
+
let blockingMode$2 = 'safe';
|
|
671
671
|
|
|
672
672
|
// Custom blocked domains (user-defined)
|
|
673
|
-
let customBlockedDomains = [];
|
|
673
|
+
let customBlockedDomains$2 = [];
|
|
674
674
|
|
|
675
675
|
// Reference to consent checker function
|
|
676
|
-
let checkConsent$
|
|
676
|
+
let checkConsent$3 = () => false;
|
|
677
677
|
|
|
678
678
|
/**
|
|
679
679
|
* Set the consent checker function
|
|
680
680
|
*/
|
|
681
|
-
function setConsentChecker(fn) {
|
|
682
|
-
checkConsent$
|
|
681
|
+
function setConsentChecker$2(fn) {
|
|
682
|
+
checkConsent$3 = fn;
|
|
683
683
|
}
|
|
684
684
|
|
|
685
685
|
/**
|
|
686
686
|
* Check if script URL matches custom blocked domains
|
|
687
687
|
*/
|
|
688
|
-
function matchesCustomDomains(url) {
|
|
689
|
-
if (!url || customBlockedDomains.length === 0) return null;
|
|
688
|
+
function matchesCustomDomains$2(url) {
|
|
689
|
+
if (!url || customBlockedDomains$2.length === 0) return null;
|
|
690
690
|
|
|
691
691
|
try {
|
|
692
692
|
const hostname = new URL(url).hostname.toLowerCase();
|
|
693
693
|
|
|
694
|
-
for (const entry of customBlockedDomains) {
|
|
694
|
+
for (const entry of customBlockedDomains$2) {
|
|
695
695
|
const domain = typeof entry === 'string' ? entry : entry.domain;
|
|
696
696
|
const category = typeof entry === 'string' ? 'marketing' : (entry.category || 'marketing');
|
|
697
697
|
|
|
@@ -724,7 +724,7 @@ function getScriptBlockCategory(script) {
|
|
|
724
724
|
// Only honor values from the blockable set; 'essential' and unknown
|
|
725
725
|
// values fall through to the other checks.
|
|
726
726
|
const explicitCategory = script.getAttribute('data-consent-category');
|
|
727
|
-
const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
|
|
727
|
+
const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES$2.has(explicitCategory)
|
|
728
728
|
? explicitCategory
|
|
729
729
|
: null;
|
|
730
730
|
|
|
@@ -736,17 +736,17 @@ function getScriptBlockCategory(script) {
|
|
|
736
736
|
}
|
|
737
737
|
|
|
738
738
|
// 2. Check custom blocked domains
|
|
739
|
-
const customCategory = matchesCustomDomains(src);
|
|
739
|
+
const customCategory = matchesCustomDomains$2(src);
|
|
740
740
|
|
|
741
741
|
// 3. Mode-based blocking
|
|
742
742
|
let modeCategory = null;
|
|
743
|
-
switch (blockingMode) {
|
|
743
|
+
switch (blockingMode$2) {
|
|
744
744
|
case 'manual':
|
|
745
745
|
break;
|
|
746
746
|
|
|
747
747
|
case 'safe':
|
|
748
748
|
case 'strict':
|
|
749
|
-
modeCategory = getCategoryForScript(src, blockingMode);
|
|
749
|
+
modeCategory = getCategoryForScript(src, blockingMode$2);
|
|
750
750
|
break;
|
|
751
751
|
|
|
752
752
|
case 'doomsday':
|
|
@@ -780,7 +780,7 @@ function blockScript(script) {
|
|
|
780
780
|
return false;
|
|
781
781
|
}
|
|
782
782
|
|
|
783
|
-
if (checkConsent$
|
|
783
|
+
if (checkConsent$3(category)) {
|
|
784
784
|
// Consent already given - allow script
|
|
785
785
|
script.setAttribute('data-zest-processed', 'allowed');
|
|
786
786
|
return false;
|
|
@@ -814,7 +814,7 @@ function blockScript(script) {
|
|
|
814
814
|
script.removeAttribute('src');
|
|
815
815
|
}
|
|
816
816
|
|
|
817
|
-
if (scriptQueue.length < MAX_QUEUE_SIZE) {
|
|
817
|
+
if (scriptQueue.length < MAX_QUEUE_SIZE$1) {
|
|
818
818
|
scriptQueue.push(scriptInfo);
|
|
819
819
|
}
|
|
820
820
|
return true;
|
|
@@ -895,8 +895,8 @@ function handleMutations(mutations) {
|
|
|
895
895
|
* Start observing for new scripts
|
|
896
896
|
*/
|
|
897
897
|
function startScriptBlocking(mode = 'safe', customDomains = []) {
|
|
898
|
-
blockingMode = mode;
|
|
899
|
-
customBlockedDomains = customDomains;
|
|
898
|
+
blockingMode$2 = mode;
|
|
899
|
+
customBlockedDomains$2 = customDomains;
|
|
900
900
|
|
|
901
901
|
// Process existing scripts
|
|
902
902
|
processExistingScripts();
|
|
@@ -912,6 +912,568 @@ function startScriptBlocking(mode = 'safe', customDomains = []) {
|
|
|
912
912
|
return true;
|
|
913
913
|
}
|
|
914
914
|
|
|
915
|
+
/**
|
|
916
|
+
* Network Interceptor - Intercepts fetch / XHR / sendBeacon calls
|
|
917
|
+
*
|
|
918
|
+
* Why this exists separately from the script blocker:
|
|
919
|
+
*
|
|
920
|
+
* Modern CMSes (HubSpot, Cloudflare Zaraz, server-side GTM, Shopify,
|
|
921
|
+
* Webflow) increasingly proxy their tracker code through the site's own
|
|
922
|
+
* origin to defeat ad-blockers. The <script> tag itself is first-party
|
|
923
|
+
* (e.g. /hs/scriptloader/{id}.js) so a hostname-based script blocker
|
|
924
|
+
* cannot match it. But at RUNTIME that script still phones home to the
|
|
925
|
+
* vendor's analytics endpoint via fetch / XHR / sendBeacon — and THAT
|
|
926
|
+
* URL is third-party.
|
|
927
|
+
*
|
|
928
|
+
* This interceptor sits on the network layer and uses the same
|
|
929
|
+
* customBlockedDomains + mode-based tracker list as the script blocker.
|
|
930
|
+
* Whatever the user told Zest to block (typically generated by an AI
|
|
931
|
+
* audit) gets blocked regardless of which API the tracker uses.
|
|
932
|
+
*
|
|
933
|
+
* No replay: network calls are one-shot and time-sensitive. Replaying a
|
|
934
|
+
* stale beacon after consent would create confusing / duplicated data,
|
|
935
|
+
* so blocked requests are dropped, not queued.
|
|
936
|
+
*/
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
// Originals captured at install time. Stored for restoration tests and
|
|
940
|
+
// for any internal Zest network calls we may add later.
|
|
941
|
+
let originalFetch = null;
|
|
942
|
+
let originalXhrOpen = null;
|
|
943
|
+
let originalXhrSend = null;
|
|
944
|
+
let originalSendBeacon = null;
|
|
945
|
+
|
|
946
|
+
let blockingMode$1 = 'safe';
|
|
947
|
+
let customBlockedDomains$1 = [];
|
|
948
|
+
let installed$1 = false;
|
|
949
|
+
|
|
950
|
+
let checkConsent$2 = () => false;
|
|
951
|
+
|
|
952
|
+
const BLOCKABLE_CATEGORIES$1 = new Set(['functional', 'analytics', 'marketing']);
|
|
953
|
+
|
|
954
|
+
function setConsentChecker$1(fn) {
|
|
955
|
+
checkConsent$2 = fn;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Resolve a Request | URL | string to an absolute URL string. Returns
|
|
960
|
+
* null if the input cannot be parsed — callers treat null as "do not
|
|
961
|
+
* block" (we'd rather let an opaque request through than crash the page).
|
|
962
|
+
*/
|
|
963
|
+
function resolveUrl(input) {
|
|
964
|
+
try {
|
|
965
|
+
if (typeof input === 'string') {
|
|
966
|
+
return new URL(input, location.href).href;
|
|
967
|
+
}
|
|
968
|
+
if (input && typeof input === 'object') {
|
|
969
|
+
if (typeof input.url === 'string') {
|
|
970
|
+
// Request object
|
|
971
|
+
return new URL(input.url, location.href).href;
|
|
972
|
+
}
|
|
973
|
+
if (typeof input.href === 'string') {
|
|
974
|
+
// URL object
|
|
975
|
+
return input.href;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
} catch (e) {
|
|
979
|
+
// fallthrough
|
|
980
|
+
}
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Match a URL against the user's customBlockedDomains list. Mirrors
|
|
986
|
+
* matchesCustomDomains() in script-blocker.js — kept inline rather than
|
|
987
|
+
* shared so each interceptor can be lifted independently.
|
|
988
|
+
*/
|
|
989
|
+
function matchesCustomDomains$1(hostname) {
|
|
990
|
+
if (!hostname || customBlockedDomains$1.length === 0) return null;
|
|
991
|
+
const host = hostname.toLowerCase();
|
|
992
|
+
for (const entry of customBlockedDomains$1) {
|
|
993
|
+
const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
|
|
994
|
+
if (!domain) continue;
|
|
995
|
+
const category = typeof entry === 'string'
|
|
996
|
+
? 'marketing'
|
|
997
|
+
: (BLOCKABLE_CATEGORIES$1.has(entry?.category) ? entry.category : 'marketing');
|
|
998
|
+
if (host === domain || host.endsWith('.' + domain)) {
|
|
999
|
+
return category;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Decide whether a URL should be blocked and return its category, or
|
|
1007
|
+
* null if it should pass through. Priority: customBlockedDomains >
|
|
1008
|
+
* mode-based tracker list (matching script-blocker priority).
|
|
1009
|
+
*/
|
|
1010
|
+
function getBlockCategory$1(url) {
|
|
1011
|
+
if (!url) return null;
|
|
1012
|
+
let hostname;
|
|
1013
|
+
try {
|
|
1014
|
+
hostname = new URL(url, location.href).hostname;
|
|
1015
|
+
} catch (e) {
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const customCategory = matchesCustomDomains$1(hostname);
|
|
1020
|
+
if (customCategory) return customCategory;
|
|
1021
|
+
|
|
1022
|
+
switch (blockingMode$1) {
|
|
1023
|
+
case 'manual':
|
|
1024
|
+
return null;
|
|
1025
|
+
case 'safe':
|
|
1026
|
+
case 'strict':
|
|
1027
|
+
return getCategoryForScript(url, blockingMode$1);
|
|
1028
|
+
case 'doomsday':
|
|
1029
|
+
if (isThirdParty(url)) {
|
|
1030
|
+
return getCategoryForScript(url, 'strict') || 'marketing';
|
|
1031
|
+
}
|
|
1032
|
+
return null;
|
|
1033
|
+
default:
|
|
1034
|
+
return null;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Should the request be blocked right now? Returns the category that
|
|
1040
|
+
* caused the block (for logging / callbacks later) or null.
|
|
1041
|
+
*/
|
|
1042
|
+
function shouldBlock$1(url) {
|
|
1043
|
+
const category = getBlockCategory$1(url);
|
|
1044
|
+
if (!category) return null;
|
|
1045
|
+
if (checkConsent$2(category)) return null;
|
|
1046
|
+
return category;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Construct an empty, successful-looking Response for a blocked fetch.
|
|
1051
|
+
* Status 204 (No Content) is the most honest "we deliberately returned
|
|
1052
|
+
* nothing" signal. Trackers that .then(r => r.json()) will get an empty
|
|
1053
|
+
* body and typically silently move on.
|
|
1054
|
+
*/
|
|
1055
|
+
function blockedResponse() {
|
|
1056
|
+
// Some environments (older browsers, strict CSP) may not have Response
|
|
1057
|
+
// — fall back to a thenable shape the most common tracker code expects.
|
|
1058
|
+
if (typeof Response === 'function') {
|
|
1059
|
+
return new Response(null, { status: 204, statusText: 'Blocked by Zest' });
|
|
1060
|
+
}
|
|
1061
|
+
const fake = {
|
|
1062
|
+
ok: false,
|
|
1063
|
+
status: 204,
|
|
1064
|
+
statusText: 'Blocked by Zest',
|
|
1065
|
+
json: () => Promise.resolve({}),
|
|
1066
|
+
text: () => Promise.resolve(''),
|
|
1067
|
+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
|
|
1068
|
+
};
|
|
1069
|
+
return fake;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Install fetch hook. Captures the original so we can both restore it
|
|
1074
|
+
* later and use it for any internal Zest network calls.
|
|
1075
|
+
*/
|
|
1076
|
+
function patchFetch() {
|
|
1077
|
+
if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
|
|
1078
|
+
originalFetch = window.fetch.bind(window);
|
|
1079
|
+
|
|
1080
|
+
window.fetch = function zestPatchedFetch(input, init) {
|
|
1081
|
+
const url = resolveUrl(input);
|
|
1082
|
+
if (shouldBlock$1(url)) {
|
|
1083
|
+
return Promise.resolve(blockedResponse());
|
|
1084
|
+
}
|
|
1085
|
+
return originalFetch(input, init);
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Install XMLHttpRequest hook. We patch .open() to capture the URL on
|
|
1091
|
+
* the instance, then .send() to decide whether to abort. Using a hidden
|
|
1092
|
+
* symbol on the instance avoids leaking state and survives any wrapping
|
|
1093
|
+
* code that reassigns request properties.
|
|
1094
|
+
*/
|
|
1095
|
+
const URL_KEY = Symbol('zestUrl');
|
|
1096
|
+
|
|
1097
|
+
function patchXhr() {
|
|
1098
|
+
if (typeof XMLHttpRequest === 'undefined') return;
|
|
1099
|
+
const proto = XMLHttpRequest.prototype;
|
|
1100
|
+
originalXhrOpen = proto.open;
|
|
1101
|
+
originalXhrSend = proto.send;
|
|
1102
|
+
|
|
1103
|
+
proto.open = function (method, url, ...rest) {
|
|
1104
|
+
this[URL_KEY] = typeof url === 'string' ? url : (url && url.href) || '';
|
|
1105
|
+
return originalXhrOpen.call(this, method, url, ...rest);
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
proto.send = function (body) {
|
|
1109
|
+
const url = this[URL_KEY];
|
|
1110
|
+
if (shouldBlock$1(url)) {
|
|
1111
|
+
// Mimic the failure mode of a network error: queueMicrotask is
|
|
1112
|
+
// used so consumers that synchronously attach handlers after
|
|
1113
|
+
// .send() still receive the events.
|
|
1114
|
+
const xhr = this;
|
|
1115
|
+
queueMicrotask(() => {
|
|
1116
|
+
try {
|
|
1117
|
+
// Best-effort — readonly props in some environments
|
|
1118
|
+
Object.defineProperty(xhr, 'readyState', { value: 4, configurable: true });
|
|
1119
|
+
Object.defineProperty(xhr, 'status', { value: 0, configurable: true });
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
// ignore
|
|
1122
|
+
}
|
|
1123
|
+
try {
|
|
1124
|
+
xhr.dispatchEvent(new Event('error'));
|
|
1125
|
+
xhr.dispatchEvent(new Event('loadend'));
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
// ignore
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
return originalXhrSend.call(this, body);
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Install navigator.sendBeacon hook. Returning false matches the spec's
|
|
1138
|
+
* "data was not queued" semantics; trackers that check the return value
|
|
1139
|
+
* fall back to fetch (which we also block) or give up.
|
|
1140
|
+
*/
|
|
1141
|
+
function patchSendBeacon() {
|
|
1142
|
+
if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;
|
|
1143
|
+
originalSendBeacon = navigator.sendBeacon.bind(navigator);
|
|
1144
|
+
|
|
1145
|
+
navigator.sendBeacon = function zestPatchedSendBeacon(url, data) {
|
|
1146
|
+
if (shouldBlock$1(typeof url === 'string' ? url : (url && url.href) || '')) {
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
return originalSendBeacon(url, data);
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Install all network hooks. Safe to call multiple times — subsequent
|
|
1155
|
+
* calls just refresh mode + custom domain config without re-wrapping.
|
|
1156
|
+
*/
|
|
1157
|
+
function interceptNetwork(mode = 'safe', customDomains = []) {
|
|
1158
|
+
blockingMode$1 = mode;
|
|
1159
|
+
customBlockedDomains$1 = Array.isArray(customDomains) ? customDomains : [];
|
|
1160
|
+
|
|
1161
|
+
if (installed$1) return true;
|
|
1162
|
+
patchFetch();
|
|
1163
|
+
patchXhr();
|
|
1164
|
+
patchSendBeacon();
|
|
1165
|
+
installed$1 = true;
|
|
1166
|
+
return true;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Element Interceptor - Catches tracker elements BEFORE the browser fetches them.
|
|
1171
|
+
*
|
|
1172
|
+
* The script-blocker uses MutationObserver. That fires asynchronously
|
|
1173
|
+
* (microtask after the DOM mutation), so by the time we can react the
|
|
1174
|
+
* browser has already kicked off the network request for the src/href.
|
|
1175
|
+
* The script may not execute (we flip type to text/plain) but the
|
|
1176
|
+
* fetch already left the building — and to ConsentTheater / a privacy
|
|
1177
|
+
* audit that fetch IS a pre-consent leak.
|
|
1178
|
+
*
|
|
1179
|
+
* This interceptor patches the prototype setters and Element.setAttribute
|
|
1180
|
+
* synchronously, so when code does:
|
|
1181
|
+
*
|
|
1182
|
+
* const s = document.createElement('script');
|
|
1183
|
+
* s.src = 'https://tracker.example/track.js'; // ← intercepted HERE
|
|
1184
|
+
* document.head.appendChild(s); // ← src is already empty,
|
|
1185
|
+
* // no fetch ever fired
|
|
1186
|
+
*
|
|
1187
|
+
* Covers four element types and both ways to set the URL:
|
|
1188
|
+
*
|
|
1189
|
+
* - HTMLScriptElement src
|
|
1190
|
+
* - HTMLLinkElement href (stylesheets, prefetch, preload, dns-prefetch)
|
|
1191
|
+
* - HTMLImageElement src (tracking pixels)
|
|
1192
|
+
* - HTMLIFrameElement src (tracking iframes)
|
|
1193
|
+
*
|
|
1194
|
+
* Plus the global Image() constructor used by classic pixel trackers.
|
|
1195
|
+
*
|
|
1196
|
+
* What this does NOT catch: inline HTML <script src=...> / <link href=...>
|
|
1197
|
+
* tags parsed from the original HTML response. The browser starts those
|
|
1198
|
+
* fetches as soon as it encounters the tag during parsing, BEFORE any
|
|
1199
|
+
* JavaScript runs. The only complete fix for that class is server-side
|
|
1200
|
+
* CSP or template-time removal.
|
|
1201
|
+
*/
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
// Upper bound on queued blocked elements. Unbounded growth would be a
|
|
1205
|
+
// memory-exhaustion vector if a page (or a hostile script) tried to
|
|
1206
|
+
// flood us with src writes.
|
|
1207
|
+
const MAX_QUEUE_SIZE = 500;
|
|
1208
|
+
|
|
1209
|
+
// Queue of blocked element writes. Each entry remembers enough to
|
|
1210
|
+
// re-apply the original URL via the ORIGINAL setter once consent
|
|
1211
|
+
// arrives for its category. Without this queue, blocked scripts /
|
|
1212
|
+
// stylesheets / images would be lost forever and require a page
|
|
1213
|
+
// reload to come back.
|
|
1214
|
+
const elementQueue = [];
|
|
1215
|
+
|
|
1216
|
+
let blockingMode = 'safe';
|
|
1217
|
+
let customBlockedDomains = [];
|
|
1218
|
+
let installed = false;
|
|
1219
|
+
let checkConsent$1 = () => false;
|
|
1220
|
+
|
|
1221
|
+
const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
|
|
1222
|
+
|
|
1223
|
+
// Map of tag name -> attribute name that carries a URL we may want to
|
|
1224
|
+
// block. Lowercased on both sides; setAttribute() gating uses this.
|
|
1225
|
+
const URL_ATTRS = {
|
|
1226
|
+
script: 'src',
|
|
1227
|
+
link: 'href',
|
|
1228
|
+
img: 'src',
|
|
1229
|
+
iframe: 'src'
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
function setConsentChecker(fn) {
|
|
1233
|
+
checkConsent$1 = fn;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function matchesCustomDomains(hostname) {
|
|
1237
|
+
if (!hostname || customBlockedDomains.length === 0) return null;
|
|
1238
|
+
const host = hostname.toLowerCase();
|
|
1239
|
+
for (const entry of customBlockedDomains) {
|
|
1240
|
+
const domain = (typeof entry === 'string' ? entry : entry?.domain || '').toLowerCase();
|
|
1241
|
+
if (!domain) continue;
|
|
1242
|
+
const category = typeof entry === 'string'
|
|
1243
|
+
? 'marketing'
|
|
1244
|
+
: (BLOCKABLE_CATEGORIES.has(entry?.category) ? entry.category : 'marketing');
|
|
1245
|
+
if (host === domain || host.endsWith('.' + domain)) {
|
|
1246
|
+
return category;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function getBlockCategory(url) {
|
|
1253
|
+
if (!url) return null;
|
|
1254
|
+
let hostname;
|
|
1255
|
+
try {
|
|
1256
|
+
hostname = new URL(url, location.href).hostname;
|
|
1257
|
+
} catch (e) {
|
|
1258
|
+
return null;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const customCategory = matchesCustomDomains(hostname);
|
|
1262
|
+
if (customCategory) return customCategory;
|
|
1263
|
+
|
|
1264
|
+
switch (blockingMode) {
|
|
1265
|
+
case 'manual':
|
|
1266
|
+
return null;
|
|
1267
|
+
case 'safe':
|
|
1268
|
+
case 'strict':
|
|
1269
|
+
return getCategoryForScript(url, blockingMode);
|
|
1270
|
+
case 'doomsday':
|
|
1271
|
+
if (isThirdParty(url)) {
|
|
1272
|
+
return getCategoryForScript(url, 'strict') || 'marketing';
|
|
1273
|
+
}
|
|
1274
|
+
return null;
|
|
1275
|
+
default:
|
|
1276
|
+
return null;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function shouldBlock(url) {
|
|
1281
|
+
const category = getBlockCategory(url);
|
|
1282
|
+
if (!category) return null;
|
|
1283
|
+
if (checkConsent$1(category)) return null;
|
|
1284
|
+
return category;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Replace the property setter for `prop` on `ProtoCtor.prototype` with
|
|
1289
|
+
* a gated version. Returns the original descriptor so we can restore.
|
|
1290
|
+
*/
|
|
1291
|
+
function patchUrlSetter(ProtoCtor, prop) {
|
|
1292
|
+
if (typeof ProtoCtor !== 'function' || !ProtoCtor.prototype) return null;
|
|
1293
|
+
const proto = ProtoCtor.prototype;
|
|
1294
|
+
const desc = Object.getOwnPropertyDescriptor(proto, prop);
|
|
1295
|
+
if (!desc || typeof desc.set !== 'function') return null;
|
|
1296
|
+
|
|
1297
|
+
Object.defineProperty(proto, prop, {
|
|
1298
|
+
configurable: true,
|
|
1299
|
+
enumerable: desc.enumerable,
|
|
1300
|
+
get: desc.get,
|
|
1301
|
+
set(value) {
|
|
1302
|
+
if (typeof value === 'string') {
|
|
1303
|
+
const category = shouldBlock(value);
|
|
1304
|
+
if (category) {
|
|
1305
|
+
// Don't pass through to the original setter — the URL never
|
|
1306
|
+
// touches the element. Stash the element + URL + category
|
|
1307
|
+
// + original descriptor in the queue so replayElements()
|
|
1308
|
+
// can reinstate it once consent arrives.
|
|
1309
|
+
if (elementQueue.length < MAX_QUEUE_SIZE) {
|
|
1310
|
+
elementQueue.push({
|
|
1311
|
+
element: this,
|
|
1312
|
+
setter: desc.set,
|
|
1313
|
+
prop,
|
|
1314
|
+
value,
|
|
1315
|
+
category,
|
|
1316
|
+
method: 'property'
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return desc.set.call(this, value);
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
return desc;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function patchSetAttribute() {
|
|
1330
|
+
if (typeof Element === 'undefined' || !Element.prototype) return null;
|
|
1331
|
+
const orig = Element.prototype.setAttribute;
|
|
1332
|
+
|
|
1333
|
+
Element.prototype.setAttribute = function patchedSetAttribute(name, value) {
|
|
1334
|
+
// Fast path: bail out for anything not on our watchlist before doing
|
|
1335
|
+
// any string work. setAttribute is hot — keep this cheap.
|
|
1336
|
+
if (typeof name !== 'string' || typeof value !== 'string' || !this || !this.tagName) {
|
|
1337
|
+
return orig.call(this, name, value);
|
|
1338
|
+
}
|
|
1339
|
+
const tag = this.tagName.toLowerCase();
|
|
1340
|
+
const watched = URL_ATTRS[tag];
|
|
1341
|
+
if (!watched) {
|
|
1342
|
+
return orig.call(this, name, value);
|
|
1343
|
+
}
|
|
1344
|
+
const attr = name.toLowerCase();
|
|
1345
|
+
if (attr !== watched) {
|
|
1346
|
+
return orig.call(this, name, value);
|
|
1347
|
+
}
|
|
1348
|
+
const category = shouldBlock(value);
|
|
1349
|
+
if (category) {
|
|
1350
|
+
// Drop silently and queue for replay. The element keeps any
|
|
1351
|
+
// other attributes you set before / after.
|
|
1352
|
+
if (elementQueue.length < MAX_QUEUE_SIZE) {
|
|
1353
|
+
elementQueue.push({
|
|
1354
|
+
element: this,
|
|
1355
|
+
setter: orig, // setAttribute itself, called like orig.call(el, name, value)
|
|
1356
|
+
prop: name,
|
|
1357
|
+
value,
|
|
1358
|
+
category,
|
|
1359
|
+
method: 'attribute'
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
return orig.call(this, name, value);
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
return orig;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function patchImageConstructor() {
|
|
1371
|
+
if (typeof window === 'undefined' || typeof window.Image !== 'function') return null;
|
|
1372
|
+
const OrigImage = window.Image;
|
|
1373
|
+
|
|
1374
|
+
function PatchedImage(width, height) {
|
|
1375
|
+
const img = arguments.length >= 2
|
|
1376
|
+
? new OrigImage(width, height)
|
|
1377
|
+
: new OrigImage();
|
|
1378
|
+
// No work needed here — the .src setter patch on HTMLImageElement
|
|
1379
|
+
// will catch any later assignment. PatchedImage exists mainly to
|
|
1380
|
+
// expose the .src patch via this path for `new Image()` users.
|
|
1381
|
+
return img;
|
|
1382
|
+
}
|
|
1383
|
+
PatchedImage.prototype = OrigImage.prototype;
|
|
1384
|
+
// Copy any static fields just in case.
|
|
1385
|
+
for (const key of Object.keys(OrigImage)) {
|
|
1386
|
+
try { PatchedImage[key] = OrigImage[key]; } catch (e) { /* ignore */ }
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
try {
|
|
1390
|
+
Object.defineProperty(window, 'Image', {
|
|
1391
|
+
configurable: true,
|
|
1392
|
+
writable: true,
|
|
1393
|
+
value: PatchedImage
|
|
1394
|
+
});
|
|
1395
|
+
} catch (e) {
|
|
1396
|
+
window.Image = PatchedImage;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
return OrigImage;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Replay blocked element writes for newly-allowed categories.
|
|
1404
|
+
*
|
|
1405
|
+
* For each queued entry whose category is in `allowedCategories`:
|
|
1406
|
+
* - If the element is still connected to the DOM, re-apply the
|
|
1407
|
+
* URL via the ORIGINAL setter / setAttribute. The browser starts
|
|
1408
|
+
* the fetch as if nothing had been intercepted.
|
|
1409
|
+
* - If the element has since been removed (no `isConnected`), drop
|
|
1410
|
+
* the entry — calling code lost its reference and we have no
|
|
1411
|
+
* parent to attach to.
|
|
1412
|
+
*
|
|
1413
|
+
* Queue ordering is preserved so that scripts/stylesheets re-execute
|
|
1414
|
+
* in the same order the page originally requested them.
|
|
1415
|
+
*/
|
|
1416
|
+
function replayElements(allowedCategories) {
|
|
1417
|
+
if (!Array.isArray(allowedCategories) || elementQueue.length === 0) return;
|
|
1418
|
+
const remaining = [];
|
|
1419
|
+
|
|
1420
|
+
for (const item of elementQueue) {
|
|
1421
|
+
if (!allowedCategories.includes(item.category)) {
|
|
1422
|
+
remaining.push(item);
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const el = item.element;
|
|
1427
|
+
if (!el || !el.isConnected) {
|
|
1428
|
+
// Element is detached or gone — nothing to re-apply against.
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
try {
|
|
1433
|
+
if (item.method === 'attribute') {
|
|
1434
|
+
item.setter.call(el, item.prop, item.value);
|
|
1435
|
+
} else {
|
|
1436
|
+
item.setter.call(el, item.value);
|
|
1437
|
+
}
|
|
1438
|
+
} catch (e) {
|
|
1439
|
+
// Restoration failed (rare — element might be in a weird state).
|
|
1440
|
+
// Don't requeue; one failure is enough.
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
elementQueue.length = 0;
|
|
1445
|
+
elementQueue.push(...remaining);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Install all element-level interceptors. Idempotent — second call
|
|
1450
|
+
* just refreshes mode + customDomains without rewrapping.
|
|
1451
|
+
*/
|
|
1452
|
+
function interceptElements(mode = 'safe', customDomains = []) {
|
|
1453
|
+
blockingMode = mode;
|
|
1454
|
+
customBlockedDomains = Array.isArray(customDomains) ? customDomains : [];
|
|
1455
|
+
|
|
1456
|
+
if (installed) return true;
|
|
1457
|
+
|
|
1458
|
+
if (typeof HTMLScriptElement !== 'undefined') {
|
|
1459
|
+
patchUrlSetter(HTMLScriptElement, 'src');
|
|
1460
|
+
}
|
|
1461
|
+
if (typeof HTMLLinkElement !== 'undefined') {
|
|
1462
|
+
patchUrlSetter(HTMLLinkElement, 'href');
|
|
1463
|
+
}
|
|
1464
|
+
if (typeof HTMLImageElement !== 'undefined') {
|
|
1465
|
+
patchUrlSetter(HTMLImageElement, 'src');
|
|
1466
|
+
}
|
|
1467
|
+
if (typeof HTMLIFrameElement !== 'undefined') {
|
|
1468
|
+
patchUrlSetter(HTMLIFrameElement, 'src');
|
|
1469
|
+
}
|
|
1470
|
+
patchSetAttribute();
|
|
1471
|
+
patchImageConstructor();
|
|
1472
|
+
|
|
1473
|
+
installed = true;
|
|
1474
|
+
return true;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
915
1477
|
/**
|
|
916
1478
|
* Default consent categories
|
|
917
1479
|
*/
|
|
@@ -1707,7 +2269,8 @@ const DEFAULTS = {
|
|
|
1707
2269
|
intercept: {
|
|
1708
2270
|
cookies: true,
|
|
1709
2271
|
storage: true,
|
|
1710
|
-
scripts: true
|
|
2272
|
+
scripts: true,
|
|
2273
|
+
network: true
|
|
1711
2274
|
},
|
|
1712
2275
|
|
|
1713
2276
|
// Strictly-necessary declarations. Both fields *append* to whatever
|
|
@@ -2138,6 +2701,11 @@ function replayAll(categories) {
|
|
|
2138
2701
|
replayCookies(categories);
|
|
2139
2702
|
replayStorage(categories);
|
|
2140
2703
|
replayScripts(categories);
|
|
2704
|
+
// Element-level replays (script/link/img/iframe URLs that were
|
|
2705
|
+
// dropped at the prototype-setter / setAttribute layer). Network
|
|
2706
|
+
// interceptor (fetch/XHR/sendBeacon) intentionally has no replay
|
|
2707
|
+
// — beacons are one-shot and resending would duplicate analytics.
|
|
2708
|
+
replayElements(categories);
|
|
2141
2709
|
}
|
|
2142
2710
|
|
|
2143
2711
|
/**
|
|
@@ -2182,6 +2750,8 @@ function coreInit(userConfig = {}) {
|
|
|
2182
2750
|
});
|
|
2183
2751
|
}
|
|
2184
2752
|
|
|
2753
|
+
setConsentChecker$4(checkConsent);
|
|
2754
|
+
setConsentChecker$3(checkConsent);
|
|
2185
2755
|
setConsentChecker$2(checkConsent);
|
|
2186
2756
|
setConsentChecker$1(checkConsent);
|
|
2187
2757
|
setConsentChecker(checkConsent);
|
|
@@ -2189,12 +2759,22 @@ function coreInit(userConfig = {}) {
|
|
|
2189
2759
|
// Interceptor toggles. By default everything is intercepted (back-compat
|
|
2190
2760
|
// with v2.0 / v2.1). Consumers that gate scripts and storage themselves
|
|
2191
2761
|
// can opt out per channel via `intercept: { storage: false, … }`.
|
|
2192
|
-
const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true };
|
|
2762
|
+
const intercept = currentConfig.intercept || { cookies: true, storage: true, scripts: true, network: true };
|
|
2193
2763
|
if (intercept.cookies !== false) interceptCookies();
|
|
2194
2764
|
if (intercept.storage !== false) interceptStorage();
|
|
2195
2765
|
if (intercept.scripts !== false) {
|
|
2766
|
+
// Element-level synchronous interception (prototype setters +
|
|
2767
|
+
// setAttribute) installs BEFORE startScriptBlocking so that the
|
|
2768
|
+
// moment any later script does `el.src = "https://tracker..."`,
|
|
2769
|
+
// we drop the URL before the browser fetches. The MutationObserver
|
|
2770
|
+
// inside startScriptBlocking remains as a defence-in-depth net for
|
|
2771
|
+
// anything that slips past (e.g. nodes constructed via cloneNode).
|
|
2772
|
+
interceptElements(currentConfig.mode, currentConfig.blockedDomains);
|
|
2196
2773
|
startScriptBlocking(currentConfig.mode, currentConfig.blockedDomains);
|
|
2197
2774
|
}
|
|
2775
|
+
if (intercept.network !== false) {
|
|
2776
|
+
interceptNetwork(currentConfig.mode, currentConfig.blockedDomains);
|
|
2777
|
+
}
|
|
2198
2778
|
|
|
2199
2779
|
const consent = loadConsent();
|
|
2200
2780
|
initialized = true;
|