@freshjuice/zest 0.1.0 → 2.0.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/README.md +216 -70
- package/dist/zest.de.js +776 -286
- package/dist/zest.de.js.map +1 -1
- package/dist/zest.de.min.js +1 -1
- package/dist/zest.en.js +776 -286
- package/dist/zest.en.js.map +1 -1
- package/dist/zest.en.min.js +1 -1
- package/dist/zest.es.js +776 -286
- package/dist/zest.es.js.map +1 -1
- package/dist/zest.es.min.js +1 -1
- package/dist/zest.esm.js +776 -286
- package/dist/zest.esm.js.map +1 -1
- package/dist/zest.esm.min.js +1 -1
- package/dist/zest.fr.js +776 -286
- package/dist/zest.fr.js.map +1 -1
- package/dist/zest.fr.min.js +1 -1
- package/dist/zest.headless.esm.js +2299 -0
- package/dist/zest.headless.esm.js.map +1 -0
- package/dist/zest.headless.esm.min.js +1 -0
- package/dist/zest.it.js +776 -286
- package/dist/zest.it.js.map +1 -1
- package/dist/zest.it.min.js +1 -1
- package/dist/zest.ja.js +776 -286
- package/dist/zest.ja.js.map +1 -1
- package/dist/zest.ja.min.js +1 -1
- package/dist/zest.js +776 -286
- package/dist/zest.js.map +1 -1
- package/dist/zest.min.js +1 -1
- package/dist/zest.nl.js +776 -286
- package/dist/zest.nl.js.map +1 -1
- package/dist/zest.nl.min.js +1 -1
- package/dist/zest.pl.js +776 -286
- package/dist/zest.pl.js.map +1 -1
- package/dist/zest.pl.min.js +1 -1
- package/dist/zest.pt.js +776 -286
- package/dist/zest.pt.js.map +1 -1
- package/dist/zest.pt.min.js +1 -1
- package/dist/zest.ru.js +776 -286
- package/dist/zest.ru.js.map +1 -1
- package/dist/zest.ru.min.js +1 -1
- package/dist/zest.uk.js +776 -286
- package/dist/zest.uk.js.map +1 -1
- package/dist/zest.uk.min.js +1 -1
- package/dist/zest.zh.js +776 -286
- package/dist/zest.zh.js.map +1 -1
- package/dist/zest.zh.min.js +1 -1
- package/package.json +17 -4
- package/src/api/public-api.js +97 -0
- package/src/config/defaults.js +150 -0
- package/src/config/parser.js +104 -0
- package/src/core/categories.js +52 -0
- package/src/core/cookie-interceptor.js +131 -0
- package/src/core/dnt.js +56 -0
- package/src/core/known-trackers.js +195 -0
- package/src/core/pattern-matcher.js +111 -0
- package/src/core/script-blocker.js +314 -0
- package/src/core/security.js +204 -0
- package/src/core/storage-interceptor.js +173 -0
- package/src/core-lifecycle.js +192 -0
- package/src/headless.js +133 -0
- package/src/i18n/lang-en.js +54 -0
- package/src/i18n/single/lang-de.js +55 -0
- package/src/i18n/single/lang-en.js +55 -0
- package/src/i18n/single/lang-es.js +55 -0
- package/src/i18n/single/lang-fr.js +55 -0
- package/src/i18n/single/lang-it.js +55 -0
- package/src/i18n/single/lang-ja.js +55 -0
- package/src/i18n/single/lang-nl.js +55 -0
- package/src/i18n/single/lang-pl.js +55 -0
- package/src/i18n/single/lang-pt.js +55 -0
- package/src/i18n/single/lang-ru.js +55 -0
- package/src/i18n/single/lang-uk.js +55 -0
- package/src/i18n/single/lang-zh.js +55 -0
- package/src/i18n/translations.js +546 -0
- package/src/index.js +266 -0
- package/src/integrations/consent-signals.js +71 -0
- package/src/storage/consent-store.js +201 -0
- package/src/storage/events.js +84 -0
- package/src/ui/banner.js +134 -0
- package/src/ui/modal.js +215 -0
- package/src/ui/styles.js +519 -0
- package/src/ui/widget.js +105 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Script Blocker - Blocks and manages consent-gated scripts
|
|
3
|
+
*
|
|
4
|
+
* Modes:
|
|
5
|
+
* - manual: Only blocks scripts with data-consent-category attribute
|
|
6
|
+
* - safe: Manual + known major trackers (Google, Facebook, etc.)
|
|
7
|
+
* - strict: Safe + extended tracker list (Hotjar, Mixpanel, etc.)
|
|
8
|
+
* - doomsday: Block ALL third-party scripts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getCategoryForScript, isThirdParty } from './known-trackers.js';
|
|
12
|
+
|
|
13
|
+
// Categories the author has declared blockable. A script can self-label
|
|
14
|
+
// into one of these, but not into 'essential' (a common bypass).
|
|
15
|
+
const BLOCKABLE_CATEGORIES = new Set(['functional', 'analytics', 'marketing']);
|
|
16
|
+
|
|
17
|
+
// Upper bound on queued scripts awaiting consent replay — prevents a
|
|
18
|
+
// hostile page from flooding the queue with <script> nodes.
|
|
19
|
+
const MAX_QUEUE_SIZE = 500;
|
|
20
|
+
|
|
21
|
+
// Queue for blocked scripts — the authoritative source for replay,
|
|
22
|
+
// snapshotting src/inline BEFORE any DOM mutation so later tampering
|
|
23
|
+
// cannot hijack what gets executed.
|
|
24
|
+
const scriptQueue = [];
|
|
25
|
+
|
|
26
|
+
// MutationObserver instance
|
|
27
|
+
let observer = null;
|
|
28
|
+
|
|
29
|
+
// Current blocking mode
|
|
30
|
+
let blockingMode = 'safe';
|
|
31
|
+
|
|
32
|
+
// Custom blocked domains (user-defined)
|
|
33
|
+
let customBlockedDomains = [];
|
|
34
|
+
|
|
35
|
+
// Reference to consent checker function
|
|
36
|
+
let checkConsent = () => false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Set the consent checker function
|
|
40
|
+
*/
|
|
41
|
+
export function setConsentChecker(fn) {
|
|
42
|
+
checkConsent = fn;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Set blocking mode
|
|
47
|
+
*/
|
|
48
|
+
export function setBlockingMode(mode) {
|
|
49
|
+
blockingMode = mode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Set custom blocked domains
|
|
54
|
+
*/
|
|
55
|
+
export function setCustomBlockedDomains(domains) {
|
|
56
|
+
customBlockedDomains = domains || [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get queued scripts
|
|
61
|
+
*/
|
|
62
|
+
export function getScriptQueue() {
|
|
63
|
+
return [...scriptQueue];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Clear the script queue
|
|
68
|
+
*/
|
|
69
|
+
export function clearScriptQueue() {
|
|
70
|
+
scriptQueue.length = 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if script URL matches custom blocked domains
|
|
75
|
+
*/
|
|
76
|
+
function matchesCustomDomains(url) {
|
|
77
|
+
if (!url || customBlockedDomains.length === 0) return null;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const hostname = new URL(url).hostname.toLowerCase();
|
|
81
|
+
|
|
82
|
+
for (const entry of customBlockedDomains) {
|
|
83
|
+
const domain = typeof entry === 'string' ? entry : entry.domain;
|
|
84
|
+
const category = typeof entry === 'string' ? 'marketing' : (entry.category || 'marketing');
|
|
85
|
+
|
|
86
|
+
if (hostname === domain || hostname.endsWith('.' + domain)) {
|
|
87
|
+
return category;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// Invalid URL
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Determine if a script should be blocked and get its category.
|
|
99
|
+
*
|
|
100
|
+
* A self-applied 'essential' label is ignored — only explicit blockable
|
|
101
|
+
* categories are accepted. That prevents a third-party script from
|
|
102
|
+
* stamping itself with data-consent-category="essential" to slip past
|
|
103
|
+
* mode-based blocking.
|
|
104
|
+
*/
|
|
105
|
+
function getScriptBlockCategory(script) {
|
|
106
|
+
// Skip if script has data-zest-allow attribute (opt-out)
|
|
107
|
+
if (script.hasAttribute('data-zest-allow')) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 1. Check for explicit data-consent-category attribute.
|
|
112
|
+
// Only honor values from the blockable set; 'essential' and unknown
|
|
113
|
+
// values fall through to the other checks.
|
|
114
|
+
const explicitCategory = script.getAttribute('data-consent-category');
|
|
115
|
+
const explicitBlockable = explicitCategory && BLOCKABLE_CATEGORIES.has(explicitCategory)
|
|
116
|
+
? explicitCategory
|
|
117
|
+
: null;
|
|
118
|
+
|
|
119
|
+
const src = script.src;
|
|
120
|
+
|
|
121
|
+
// No src = inline script, only block if explicitly tagged (blockable only)
|
|
122
|
+
if (!src) {
|
|
123
|
+
return explicitBlockable;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 2. Check custom blocked domains
|
|
127
|
+
const customCategory = matchesCustomDomains(src);
|
|
128
|
+
|
|
129
|
+
// 3. Mode-based blocking
|
|
130
|
+
let modeCategory = null;
|
|
131
|
+
switch (blockingMode) {
|
|
132
|
+
case 'manual':
|
|
133
|
+
break;
|
|
134
|
+
|
|
135
|
+
case 'safe':
|
|
136
|
+
case 'strict':
|
|
137
|
+
modeCategory = getCategoryForScript(src, blockingMode);
|
|
138
|
+
break;
|
|
139
|
+
|
|
140
|
+
case 'doomsday':
|
|
141
|
+
if (isThirdParty(src)) {
|
|
142
|
+
modeCategory = getCategoryForScript(src, 'strict') || 'marketing';
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
|
|
146
|
+
default:
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Use the strictest category among explicit/custom/mode decisions.
|
|
151
|
+
// We collect all categories the script matches and pick the first
|
|
152
|
+
// that appears in the blockable set (any match wins — but we prefer
|
|
153
|
+
// the mode-assigned one since it's authoritative for third-party
|
|
154
|
+
// trackers that try to self-label as 'functional').
|
|
155
|
+
return modeCategory || customCategory || explicitBlockable;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Block a script element
|
|
160
|
+
*/
|
|
161
|
+
function blockScript(script) {
|
|
162
|
+
// Skip already processed scripts
|
|
163
|
+
if (script.hasAttribute('data-zest-processed')) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const category = getScriptBlockCategory(script);
|
|
168
|
+
|
|
169
|
+
if (!category) {
|
|
170
|
+
script.setAttribute('data-zest-processed', 'allowed');
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (checkConsent(category)) {
|
|
175
|
+
// Consent already given - allow script
|
|
176
|
+
script.setAttribute('data-zest-processed', 'allowed');
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Store script info for later execution. Snapshot the src/text BEFORE
|
|
181
|
+
// mutating the DOM — this snapshot is the authoritative replay source
|
|
182
|
+
// so later DOM tampering cannot hijack the replayed script URL.
|
|
183
|
+
const scriptInfo = {
|
|
184
|
+
category,
|
|
185
|
+
src: script.src || '',
|
|
186
|
+
inline: script.textContent,
|
|
187
|
+
type: script.type,
|
|
188
|
+
async: script.async,
|
|
189
|
+
defer: script.defer,
|
|
190
|
+
element: script,
|
|
191
|
+
timestamp: Date.now()
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Mark as processed
|
|
195
|
+
script.setAttribute('data-zest-processed', 'blocked');
|
|
196
|
+
script.setAttribute('data-consent-category', category);
|
|
197
|
+
|
|
198
|
+
// Disable the script
|
|
199
|
+
script.type = 'text/plain';
|
|
200
|
+
|
|
201
|
+
// Remove src to prevent loading. We no longer stash it on the element
|
|
202
|
+
// (data-blocked-src was a tampering vector); scriptQueue is the single
|
|
203
|
+
// source of truth for replay.
|
|
204
|
+
if (script.src) {
|
|
205
|
+
script.removeAttribute('src');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (scriptQueue.length < MAX_QUEUE_SIZE) {
|
|
209
|
+
scriptQueue.push(scriptInfo);
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Replay queued scripts for allowed categories.
|
|
216
|
+
*
|
|
217
|
+
* scriptQueue is the single source of truth for src and inline body —
|
|
218
|
+
* we never re-read data-* attributes from the DOM (which an attacker
|
|
219
|
+
* could have rewritten in the intervening time).
|
|
220
|
+
*/
|
|
221
|
+
export function replayScripts(allowedCategories) {
|
|
222
|
+
const remaining = [];
|
|
223
|
+
|
|
224
|
+
for (const scriptInfo of scriptQueue) {
|
|
225
|
+
if (!allowedCategories.includes(scriptInfo.category)) {
|
|
226
|
+
remaining.push(scriptInfo);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const newScript = document.createElement('script');
|
|
231
|
+
if (scriptInfo.src) {
|
|
232
|
+
newScript.src = scriptInfo.src;
|
|
233
|
+
} else if (scriptInfo.inline) {
|
|
234
|
+
newScript.textContent = scriptInfo.inline;
|
|
235
|
+
}
|
|
236
|
+
if (scriptInfo.async) newScript.async = true;
|
|
237
|
+
if (scriptInfo.defer) newScript.defer = true;
|
|
238
|
+
if (scriptInfo.type && scriptInfo.type !== 'text/plain') {
|
|
239
|
+
newScript.type = scriptInfo.type;
|
|
240
|
+
}
|
|
241
|
+
newScript.setAttribute('data-zest-processed', 'executed');
|
|
242
|
+
newScript.setAttribute('data-consent-executed', 'true');
|
|
243
|
+
|
|
244
|
+
// If the original element is still in the DOM, replace it in place
|
|
245
|
+
// so execution order is preserved. Otherwise append to <head>.
|
|
246
|
+
const original = scriptInfo.element;
|
|
247
|
+
if (original && original.isConnected && original.parentNode) {
|
|
248
|
+
original.parentNode.replaceChild(newScript, original);
|
|
249
|
+
} else {
|
|
250
|
+
document.head.appendChild(newScript);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
scriptQueue.length = 0;
|
|
255
|
+
scriptQueue.push(...remaining);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Process existing scripts in the DOM
|
|
260
|
+
*/
|
|
261
|
+
function processExistingScripts() {
|
|
262
|
+
const scripts = document.querySelectorAll('script:not([data-zest-processed])');
|
|
263
|
+
scripts.forEach(blockScript);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Handle mutations (new scripts added to DOM)
|
|
268
|
+
*/
|
|
269
|
+
function handleMutations(mutations) {
|
|
270
|
+
for (const mutation of mutations) {
|
|
271
|
+
for (const node of mutation.addedNodes) {
|
|
272
|
+
if (node.nodeName === 'SCRIPT' && !node.hasAttribute('data-zest-processed')) {
|
|
273
|
+
blockScript(node);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Check child scripts
|
|
277
|
+
if (node.querySelectorAll) {
|
|
278
|
+
const scripts = node.querySelectorAll('script:not([data-zest-processed])');
|
|
279
|
+
scripts.forEach(blockScript);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Start observing for new scripts
|
|
287
|
+
*/
|
|
288
|
+
export function startScriptBlocking(mode = 'safe', customDomains = []) {
|
|
289
|
+
blockingMode = mode;
|
|
290
|
+
customBlockedDomains = customDomains;
|
|
291
|
+
|
|
292
|
+
// Process existing scripts
|
|
293
|
+
processExistingScripts();
|
|
294
|
+
|
|
295
|
+
// Watch for new scripts
|
|
296
|
+
observer = new MutationObserver(handleMutations);
|
|
297
|
+
|
|
298
|
+
observer.observe(document.documentElement, {
|
|
299
|
+
childList: true,
|
|
300
|
+
subtree: true
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Stop observing for new scripts
|
|
308
|
+
*/
|
|
309
|
+
export function stopScriptBlocking() {
|
|
310
|
+
if (observer) {
|
|
311
|
+
observer.disconnect();
|
|
312
|
+
observer = null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security utilities - escaping, validation, and safe parsing helpers
|
|
3
|
+
*
|
|
4
|
+
* These helpers are used across UI components, URL/CSS validation, and
|
|
5
|
+
* consent-cookie parsing to provide defense-in-depth against untrusted
|
|
6
|
+
* config (CMS-driven, i18n-loaded, or attacker-supplied).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const HTML_ESCAPE_MAP = {
|
|
10
|
+
'&': '&',
|
|
11
|
+
'<': '<',
|
|
12
|
+
'>': '>',
|
|
13
|
+
'"': '"',
|
|
14
|
+
"'": ''',
|
|
15
|
+
'`': '`'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Escape a value for safe embedding in HTML text nodes and attribute values.
|
|
20
|
+
* Accepts any value — non-strings are stringified first. null/undefined -> ''.
|
|
21
|
+
*/
|
|
22
|
+
export function escapeHTML(value) {
|
|
23
|
+
if (value === null || value === undefined) return '';
|
|
24
|
+
const str = typeof value === 'string' ? value : String(value);
|
|
25
|
+
return str.replace(/[&<>"'`]/g, (ch) => HTML_ESCAPE_MAP[ch]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate a URL — return the URL if it uses http:/https:/mailto:/tel:,
|
|
30
|
+
* otherwise return null. Blocks javascript:, data:, vbscript:, file:, etc.
|
|
31
|
+
*/
|
|
32
|
+
export function safeUrl(url) {
|
|
33
|
+
if (typeof url !== 'string' || url.length === 0) return null;
|
|
34
|
+
const trimmed = url.trim();
|
|
35
|
+
if (trimmed.length === 0) return null;
|
|
36
|
+
|
|
37
|
+
// Relative URLs (no protocol) are safe — treat as same-origin path
|
|
38
|
+
if (/^[/?#]/.test(trimmed)) return trimmed;
|
|
39
|
+
|
|
40
|
+
// Check protocol explicitly, do NOT rely on URL parsing alone —
|
|
41
|
+
// attackers may use whitespace/control characters to confuse parsers.
|
|
42
|
+
const match = trimmed.match(/^([a-z][a-z0-9+.-]*):/i);
|
|
43
|
+
if (!match) {
|
|
44
|
+
// No protocol — treat as relative
|
|
45
|
+
return trimmed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const protocol = match[1].toLowerCase();
|
|
49
|
+
if (protocol === 'http' || protocol === 'https' || protocol === 'mailto' || protocol === 'tel') {
|
|
50
|
+
return trimmed;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate a CSS color value. Accepts #rgb/#rrggbb/#rrggbbaa and a
|
|
57
|
+
* small allowlist of CSS named colors and rgb()/rgba()/hsl()/hsla()
|
|
58
|
+
* functional forms with numeric-only arguments.
|
|
59
|
+
*/
|
|
60
|
+
const NAMED_COLORS = new Set([
|
|
61
|
+
'transparent', 'black', 'white', 'red', 'green', 'blue', 'yellow',
|
|
62
|
+
'orange', 'purple', 'pink', 'gray', 'grey', 'brown', 'cyan', 'magenta',
|
|
63
|
+
'silver', 'gold', 'navy', 'teal', 'maroon', 'olive', 'lime', 'aqua',
|
|
64
|
+
'fuchsia', 'indigo', 'violet', 'crimson', 'coral', 'salmon', 'tomato'
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
export function safeColor(color) {
|
|
68
|
+
if (typeof color !== 'string') return null;
|
|
69
|
+
const trimmed = color.trim();
|
|
70
|
+
|
|
71
|
+
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
|
72
|
+
if (NAMED_COLORS.has(trimmed.toLowerCase())) return trimmed;
|
|
73
|
+
|
|
74
|
+
// Functional notations: only digits, dots, commas, %, whitespace between parens
|
|
75
|
+
if (/^(rgb|rgba|hsl|hsla)\(\s*[\d.,%\s/]+\s*\)$/i.test(trimmed)) return trimmed;
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validate a regex pattern string. Rejects patterns that contain known
|
|
82
|
+
* catastrophic-backtracking shapes (nested quantifiers). Compiles with
|
|
83
|
+
* try/catch.
|
|
84
|
+
*
|
|
85
|
+
* Returns a RegExp on success, null on failure.
|
|
86
|
+
*/
|
|
87
|
+
const REDOS_PATTERNS = [
|
|
88
|
+
/(\([^)]*[+*][^)]*\)|\[[^\]]*\]|\\w|\\d|\\s)\s*[+*]/, // nested quantifier
|
|
89
|
+
/\(\?[=!][^)]*[+*][^)]*\)[+*]/, // lookahead with quantifier, then quantifier
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export function safeRegExp(pattern, flags) {
|
|
93
|
+
if (pattern instanceof RegExp) return pattern;
|
|
94
|
+
if (typeof pattern !== 'string') return null;
|
|
95
|
+
|
|
96
|
+
// Cap pattern length to limit compiled-regex state
|
|
97
|
+
if (pattern.length > 500) return null;
|
|
98
|
+
|
|
99
|
+
// Heuristic: reject obviously dangerous patterns
|
|
100
|
+
for (const bad of REDOS_PATTERNS) {
|
|
101
|
+
if (bad.test(pattern)) return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
return new RegExp(pattern, flags);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Sanitize a consent-cookie payload. Only known category keys with
|
|
113
|
+
* boolean values survive; prototype-polluting keys are stripped.
|
|
114
|
+
*/
|
|
115
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
116
|
+
|
|
117
|
+
export function sanitizeConsentPayload(raw, knownCategoryIds) {
|
|
118
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
119
|
+
|
|
120
|
+
const result = {
|
|
121
|
+
version: typeof raw.version === 'string' ? raw.version : null,
|
|
122
|
+
timestamp: typeof raw.timestamp === 'number' && Number.isFinite(raw.timestamp) ? raw.timestamp : null,
|
|
123
|
+
categories: {}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const cats = raw.categories;
|
|
127
|
+
if (!cats || typeof cats !== 'object' || Array.isArray(cats)) return null;
|
|
128
|
+
|
|
129
|
+
for (const key of knownCategoryIds) {
|
|
130
|
+
if (FORBIDDEN_KEYS.has(key)) continue;
|
|
131
|
+
if (Object.prototype.hasOwnProperty.call(cats, key)) {
|
|
132
|
+
result.categories[key] = cats[key] === true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// essential is always true regardless of stored value
|
|
137
|
+
if (knownCategoryIds.includes('essential')) {
|
|
138
|
+
result.categories.essential = true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Invoke a user-supplied callback, swallowing and logging exceptions so
|
|
146
|
+
* a misbehaving callback can't break the consent flow.
|
|
147
|
+
*/
|
|
148
|
+
export function safeInvoke(fn, ...args) {
|
|
149
|
+
if (typeof fn !== 'function') return undefined;
|
|
150
|
+
try {
|
|
151
|
+
return fn(...args);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
try {
|
|
154
|
+
console.error('[Zest] User callback threw:', e);
|
|
155
|
+
} catch (_) {
|
|
156
|
+
/* no-op */
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Strip comments and selector-level content from a customStyles string
|
|
164
|
+
* while still allowing property/value declarations scoped under the
|
|
165
|
+
* component selectors the author is targeting. We cannot fully sandbox
|
|
166
|
+
* CSS without a parser, but we can at least neutralise the most
|
|
167
|
+
* dangerous clickjacking vector (rules targeting Zest's own buttons).
|
|
168
|
+
*
|
|
169
|
+
* Returns the sanitized CSS string (possibly empty).
|
|
170
|
+
*/
|
|
171
|
+
export function sanitizeCustomStyles(css) {
|
|
172
|
+
if (typeof css !== 'string' || css.length === 0) return '';
|
|
173
|
+
|
|
174
|
+
// Hard cap on size to avoid runaway payloads
|
|
175
|
+
if (css.length > 20000) return '';
|
|
176
|
+
|
|
177
|
+
// Remove CSS comments (can hide payloads)
|
|
178
|
+
let out = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
179
|
+
|
|
180
|
+
// Block at-rules that can load external resources or alter behavior
|
|
181
|
+
out = out.replace(/@import\s+[^;]+;?/gi, '');
|
|
182
|
+
out = out.replace(/@charset\s+[^;]+;?/gi, '');
|
|
183
|
+
|
|
184
|
+
// Block url() values pointing outside of data: or https:
|
|
185
|
+
out = out.replace(/url\(\s*(['"]?)([^)'"]+)\1\s*\)/gi, (match, quote, value) => {
|
|
186
|
+
const v = value.trim().toLowerCase();
|
|
187
|
+
if (v.startsWith('https:') || v.startsWith('data:image/') || v.startsWith('/') || v.startsWith('#')) {
|
|
188
|
+
return match;
|
|
189
|
+
}
|
|
190
|
+
return 'url(#)';
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Block selectors that target the built-in reject button, which could
|
|
194
|
+
// be used to hide it for clickjacking consent bypass.
|
|
195
|
+
out = out.replace(/\.zest-btn--secondary\s*\{[^}]*\}/gi, '');
|
|
196
|
+
out = out.replace(/\[data-action\s*=\s*["']reject-all["']\]\s*\{[^}]*\}/gi, '');
|
|
197
|
+
out = out.replace(/\[data-action\s*=\s*["']accept-all["']\]\s*\{[^}]*\}/gi, '');
|
|
198
|
+
|
|
199
|
+
// Block expression() (ancient IE) and -moz-binding (ancient FF)
|
|
200
|
+
out = out.replace(/expression\s*\([^)]*\)/gi, '');
|
|
201
|
+
out = out.replace(/-moz-binding\s*:[^;}]*/gi, '');
|
|
202
|
+
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Interceptor - Intercepts localStorage and sessionStorage operations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getCategoryForName } from './pattern-matcher.js';
|
|
6
|
+
|
|
7
|
+
// Upper bound on queued operations awaiting consent replay — unbounded
|
|
8
|
+
// growth would be a memory-exhaustion DoS vector.
|
|
9
|
+
const MAX_QUEUE_SIZE = 200;
|
|
10
|
+
|
|
11
|
+
// Store originals
|
|
12
|
+
let originalLocalStorage = null;
|
|
13
|
+
let originalSessionStorage = null;
|
|
14
|
+
|
|
15
|
+
// Queues for blocked operations
|
|
16
|
+
const localStorageQueue = [];
|
|
17
|
+
const sessionStorageQueue = [];
|
|
18
|
+
|
|
19
|
+
// Reference to consent checker function
|
|
20
|
+
let checkConsent = () => false;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Set the consent checker function
|
|
24
|
+
*/
|
|
25
|
+
export function setConsentChecker(fn) {
|
|
26
|
+
checkConsent = fn;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get original localStorage
|
|
31
|
+
*/
|
|
32
|
+
export function getOriginalLocalStorage() {
|
|
33
|
+
return originalLocalStorage;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get original sessionStorage
|
|
38
|
+
*/
|
|
39
|
+
export function getOriginalSessionStorage() {
|
|
40
|
+
return originalSessionStorage;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get queued localStorage operations
|
|
45
|
+
*/
|
|
46
|
+
export function getLocalStorageQueue() {
|
|
47
|
+
return [...localStorageQueue];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get queued sessionStorage operations
|
|
52
|
+
*/
|
|
53
|
+
export function getSessionStorageQueue() {
|
|
54
|
+
return [...sessionStorageQueue];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Clear storage queues
|
|
59
|
+
*/
|
|
60
|
+
export function clearStorageQueues() {
|
|
61
|
+
localStorageQueue.length = 0;
|
|
62
|
+
sessionStorageQueue.length = 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a proxy for storage API
|
|
67
|
+
*/
|
|
68
|
+
function createStorageProxy(storage, queue, storageName) {
|
|
69
|
+
return new Proxy(storage, {
|
|
70
|
+
get(target, prop) {
|
|
71
|
+
if (prop === 'setItem') {
|
|
72
|
+
return (key, value) => {
|
|
73
|
+
const category = getCategoryForName(key);
|
|
74
|
+
|
|
75
|
+
if (checkConsent(category)) {
|
|
76
|
+
target.setItem(key, value);
|
|
77
|
+
} else if (queue.length < MAX_QUEUE_SIZE) {
|
|
78
|
+
queue.push({
|
|
79
|
+
key,
|
|
80
|
+
value,
|
|
81
|
+
category,
|
|
82
|
+
timestamp: Date.now()
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Allow all other operations
|
|
89
|
+
const val = target[prop];
|
|
90
|
+
return typeof val === 'function' ? val.bind(target) : val;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Replay queued storage operations for allowed categories
|
|
97
|
+
*/
|
|
98
|
+
export function replayStorage(allowedCategories) {
|
|
99
|
+
// Replay localStorage
|
|
100
|
+
const remainingLocal = [];
|
|
101
|
+
for (const item of localStorageQueue) {
|
|
102
|
+
if (allowedCategories.includes(item.category)) {
|
|
103
|
+
originalLocalStorage?.setItem(item.key, item.value);
|
|
104
|
+
} else {
|
|
105
|
+
remainingLocal.push(item);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
localStorageQueue.length = 0;
|
|
109
|
+
localStorageQueue.push(...remainingLocal);
|
|
110
|
+
|
|
111
|
+
// Replay sessionStorage
|
|
112
|
+
const remainingSession = [];
|
|
113
|
+
for (const item of sessionStorageQueue) {
|
|
114
|
+
if (allowedCategories.includes(item.category)) {
|
|
115
|
+
originalSessionStorage?.setItem(item.key, item.value);
|
|
116
|
+
} else {
|
|
117
|
+
remainingSession.push(item);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
sessionStorageQueue.length = 0;
|
|
121
|
+
sessionStorageQueue.push(...remainingSession);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Start intercepting storage APIs
|
|
126
|
+
*/
|
|
127
|
+
export function interceptStorage() {
|
|
128
|
+
try {
|
|
129
|
+
originalLocalStorage = window.localStorage;
|
|
130
|
+
originalSessionStorage = window.sessionStorage;
|
|
131
|
+
|
|
132
|
+
Object.defineProperty(window, 'localStorage', {
|
|
133
|
+
value: createStorageProxy(originalLocalStorage, localStorageQueue, 'localStorage'),
|
|
134
|
+
configurable: true,
|
|
135
|
+
writable: false
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
Object.defineProperty(window, 'sessionStorage', {
|
|
139
|
+
value: createStorageProxy(originalSessionStorage, sessionStorageQueue, 'sessionStorage'),
|
|
140
|
+
configurable: true,
|
|
141
|
+
writable: false
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return true;
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.warn('[Zest] Could not intercept storage APIs:', e);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Restore original storage APIs
|
|
153
|
+
*/
|
|
154
|
+
export function restoreStorage() {
|
|
155
|
+
try {
|
|
156
|
+
if (originalLocalStorage) {
|
|
157
|
+
Object.defineProperty(window, 'localStorage', {
|
|
158
|
+
value: originalLocalStorage,
|
|
159
|
+
configurable: true,
|
|
160
|
+
writable: false
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (originalSessionStorage) {
|
|
164
|
+
Object.defineProperty(window, 'sessionStorage', {
|
|
165
|
+
value: originalSessionStorage,
|
|
166
|
+
configurable: true,
|
|
167
|
+
writable: false
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} catch (e) {
|
|
171
|
+
console.warn('[Zest] Could not restore storage APIs:', e);
|
|
172
|
+
}
|
|
173
|
+
}
|