@freshjuice/zest 1.0.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 +178 -78
- package/dist/zest.de.js +692 -305
- package/dist/zest.de.js.map +1 -1
- package/dist/zest.de.min.js +1 -1
- package/dist/zest.en.js +692 -305
- package/dist/zest.en.js.map +1 -1
- package/dist/zest.en.min.js +1 -1
- package/dist/zest.es.js +692 -305
- package/dist/zest.es.js.map +1 -1
- package/dist/zest.es.min.js +1 -1
- package/dist/zest.esm.js +692 -305
- package/dist/zest.esm.js.map +1 -1
- package/dist/zest.esm.min.js +1 -1
- package/dist/zest.fr.js +692 -305
- 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 +692 -305
- package/dist/zest.it.js.map +1 -1
- package/dist/zest.it.min.js +1 -1
- package/dist/zest.ja.js +692 -305
- package/dist/zest.ja.js.map +1 -1
- package/dist/zest.ja.min.js +1 -1
- package/dist/zest.js +692 -305
- package/dist/zest.js.map +1 -1
- package/dist/zest.min.js +1 -1
- package/dist/zest.nl.js +692 -305
- package/dist/zest.nl.js.map +1 -1
- package/dist/zest.nl.min.js +1 -1
- package/dist/zest.pl.js +692 -305
- package/dist/zest.pl.js.map +1 -1
- package/dist/zest.pl.min.js +1 -1
- package/dist/zest.pt.js +692 -305
- package/dist/zest.pt.js.map +1 -1
- package/dist/zest.pt.min.js +1 -1
- package/dist/zest.ru.js +692 -305
- package/dist/zest.ru.js.map +1 -1
- package/dist/zest.ru.min.js +1 -1
- package/dist/zest.uk.js +692 -305
- package/dist/zest.uk.js.map +1 -1
- package/dist/zest.uk.min.js +1 -1
- package/dist/zest.zh.js +692 -305
- package/dist/zest.zh.js.map +1 -1
- package/dist/zest.zh.min.js +1 -1
- package/package.json +16 -4
- package/src/core/cookie-interceptor.js +20 -5
- package/src/core/known-trackers.js +41 -14
- package/src/core/pattern-matcher.js +20 -5
- package/src/core/script-blocker.js +85 -79
- package/src/core/security.js +204 -0
- package/src/core/storage-interceptor.js +5 -1
- package/src/core-lifecycle.js +192 -0
- package/src/headless.js +133 -0
- package/src/index.js +73 -184
- package/src/storage/consent-store.js +32 -8
- package/src/ui/banner.js +11 -7
- package/src/ui/modal.js +16 -12
- package/src/ui/styles.js +25 -4
- package/src/ui/widget.js +3 -1
package/src/index.js
CHANGED
|
@@ -1,145 +1,88 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Zest - Lightweight Cookie Consent Toolkit
|
|
3
|
-
* Main entry
|
|
3
|
+
* Main entry (full build: logic + UI).
|
|
4
|
+
*
|
|
5
|
+
* For a logic-only build without any CSS / Shadow DOM mounting, import
|
|
6
|
+
* from `@freshjuice/zest/headless` instead.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
|
-
// Core
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
import { getConfig, setConfig, getCurrentConfig } from './config/parser.js';
|
|
19
|
-
|
|
20
|
-
// Storage
|
|
9
|
+
// Core lifecycle (UI-agnostic)
|
|
10
|
+
import {
|
|
11
|
+
coreInit,
|
|
12
|
+
coreAcceptAll,
|
|
13
|
+
coreRejectAll,
|
|
14
|
+
coreUpdateConsent,
|
|
15
|
+
coreReset,
|
|
16
|
+
isInitialized,
|
|
17
|
+
getActiveConfig
|
|
18
|
+
} from './core-lifecycle.js';
|
|
19
|
+
|
|
20
|
+
// Consent store + events
|
|
21
21
|
import {
|
|
22
|
-
loadConsent,
|
|
23
22
|
getConsent,
|
|
24
23
|
hasConsent,
|
|
25
|
-
updateConsent,
|
|
26
|
-
acceptAll as storeAcceptAll,
|
|
27
|
-
rejectAll as storeRejectAll,
|
|
28
|
-
resetConsent,
|
|
29
24
|
hasConsentDecision,
|
|
30
25
|
getConsentProof
|
|
31
26
|
} from './storage/consent-store.js';
|
|
32
|
-
import {
|
|
27
|
+
import { emitShow, emitHide, EVENTS, on, once } from './storage/events.js';
|
|
28
|
+
|
|
29
|
+
// DNT introspection
|
|
30
|
+
import { isDoNotTrackEnabled, getDNTDetails } from './core/dnt.js';
|
|
31
|
+
|
|
32
|
+
// Config getters
|
|
33
|
+
import { getConfig, getCurrentConfig } from './config/parser.js';
|
|
33
34
|
|
|
34
35
|
// UI
|
|
35
36
|
import { showBanner, hideBanner, isBannerVisible } from './ui/banner.js';
|
|
36
37
|
import { showModal, hideModal, isModalVisible } from './ui/modal.js';
|
|
37
38
|
import { showWidget, hideWidget, removeWidget, isWidgetVisible } from './ui/widget.js';
|
|
38
39
|
|
|
39
|
-
// State
|
|
40
|
-
let initialized = false;
|
|
41
|
-
let config = null;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Consent checker function shared across interceptors
|
|
45
|
-
*/
|
|
46
|
-
function checkConsent(category) {
|
|
47
|
-
return hasConsent(category);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Replay all queued items for newly allowed categories
|
|
52
|
-
*/
|
|
53
|
-
function replayAll(allowedCategories) {
|
|
54
|
-
replayCookies(allowedCategories);
|
|
55
|
-
replayStorage(allowedCategories);
|
|
56
|
-
replayScripts(allowedCategories);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
40
|
/**
|
|
60
|
-
* Handle accept all
|
|
41
|
+
* Handle accept all — delegates consent logic to core, handles UI swap.
|
|
61
42
|
*/
|
|
62
43
|
function handleAcceptAll() {
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
applyConsentSignals(result.current, config, false);
|
|
44
|
+
coreAcceptAll();
|
|
45
|
+
const config = getActiveConfig();
|
|
67
46
|
|
|
68
47
|
hideBanner();
|
|
69
48
|
hideModal();
|
|
70
49
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (config.showWidget) {
|
|
50
|
+
if (config?.showWidget) {
|
|
74
51
|
showWidget({ onClick: handleShowSettings });
|
|
75
52
|
}
|
|
76
|
-
|
|
77
|
-
emitConsent(result.current, result.previous);
|
|
78
|
-
emitChange(result.current, result.previous);
|
|
79
|
-
config.callbacks?.onAccept?.(result.current);
|
|
80
|
-
config.callbacks?.onChange?.(result.current);
|
|
81
53
|
}
|
|
82
54
|
|
|
83
55
|
/**
|
|
84
|
-
* Handle reject all
|
|
56
|
+
* Handle reject all.
|
|
85
57
|
*/
|
|
86
58
|
function handleRejectAll() {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
applyConsentSignals(result.current, config, false);
|
|
59
|
+
coreRejectAll();
|
|
60
|
+
const config = getActiveConfig();
|
|
90
61
|
|
|
91
62
|
hideBanner();
|
|
92
63
|
hideModal();
|
|
93
64
|
|
|
94
|
-
if (config
|
|
65
|
+
if (config?.showWidget) {
|
|
95
66
|
showWidget({ onClick: handleShowSettings });
|
|
96
67
|
}
|
|
97
|
-
|
|
98
|
-
emitReject(result.current);
|
|
99
|
-
emitChange(result.current, result.previous);
|
|
100
|
-
config.callbacks?.onReject?.();
|
|
101
|
-
config.callbacks?.onChange?.(result.current);
|
|
102
68
|
}
|
|
103
69
|
|
|
104
70
|
/**
|
|
105
|
-
* Handle save preferences from modal
|
|
71
|
+
* Handle save preferences from modal.
|
|
106
72
|
*/
|
|
107
73
|
function handleSavePreferences(selections) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
applyConsentSignals(result.current, config, false);
|
|
111
|
-
|
|
112
|
-
// Find newly allowed categories
|
|
113
|
-
const newlyAllowed = Object.keys(result.current).filter(
|
|
114
|
-
cat => result.current[cat] && !result.previous[cat]
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
if (newlyAllowed.length > 0) {
|
|
118
|
-
replayAll(newlyAllowed);
|
|
119
|
-
}
|
|
74
|
+
coreUpdateConsent(selections);
|
|
75
|
+
const config = getActiveConfig();
|
|
120
76
|
|
|
121
77
|
hideModal();
|
|
122
78
|
|
|
123
|
-
if (config
|
|
79
|
+
if (config?.showWidget) {
|
|
124
80
|
showWidget({ onClick: handleShowSettings });
|
|
125
81
|
}
|
|
126
|
-
|
|
127
|
-
// Determine if this was acceptance or rejection based on selections
|
|
128
|
-
const hasNonEssential = Object.entries(selections)
|
|
129
|
-
.some(([cat, val]) => cat !== 'essential' && val);
|
|
130
|
-
|
|
131
|
-
if (hasNonEssential) {
|
|
132
|
-
emitConsent(result.current, result.previous);
|
|
133
|
-
} else {
|
|
134
|
-
emitReject(result.current);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
emitChange(result.current, result.previous);
|
|
138
|
-
config.callbacks?.onChange?.(result.current);
|
|
139
82
|
}
|
|
140
83
|
|
|
141
84
|
/**
|
|
142
|
-
*
|
|
85
|
+
* Open the settings modal.
|
|
143
86
|
*/
|
|
144
87
|
function handleShowSettings() {
|
|
145
88
|
hideBanner();
|
|
@@ -156,17 +99,17 @@ function handleShowSettings() {
|
|
|
156
99
|
}
|
|
157
100
|
|
|
158
101
|
/**
|
|
159
|
-
*
|
|
102
|
+
* Close the modal — either bring the widget back (decision made) or
|
|
103
|
+
* fall back to the banner (no decision yet).
|
|
160
104
|
*/
|
|
161
105
|
function handleCloseModal() {
|
|
162
106
|
hideModal();
|
|
163
107
|
emitHide('modal');
|
|
164
108
|
|
|
165
|
-
|
|
166
|
-
if (hasConsentDecision() && config
|
|
109
|
+
const config = getActiveConfig();
|
|
110
|
+
if (hasConsentDecision() && config?.showWidget) {
|
|
167
111
|
showWidget({ onClick: handleShowSettings });
|
|
168
112
|
} else {
|
|
169
|
-
// Show banner again if no decision made
|
|
170
113
|
showBanner({
|
|
171
114
|
onAcceptAll: handleAcceptAll,
|
|
172
115
|
onRejectAll: handleRejectAll,
|
|
@@ -176,103 +119,37 @@ function handleCloseModal() {
|
|
|
176
119
|
}
|
|
177
120
|
|
|
178
121
|
/**
|
|
179
|
-
* Initialize Zest
|
|
122
|
+
* Initialize Zest with UI.
|
|
180
123
|
*/
|
|
181
124
|
function init(userConfig = {}) {
|
|
182
|
-
|
|
125
|
+
const { alreadyInitialized, consent, hasDecision, dntApplied } = coreInit(userConfig);
|
|
126
|
+
if (alreadyInitialized) {
|
|
183
127
|
console.warn('[Zest] Already initialized');
|
|
184
128
|
return Zest;
|
|
185
129
|
}
|
|
186
130
|
|
|
187
|
-
|
|
188
|
-
config = setConfig(userConfig);
|
|
189
|
-
|
|
190
|
-
// Push default denied state to vendor consent mode APIs (must happen before scripts load)
|
|
191
|
-
applyConsentSignals(
|
|
192
|
-
{ essential: true, functional: false, analytics: false, marketing: false },
|
|
193
|
-
config,
|
|
194
|
-
true
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
// Set patterns if provided
|
|
198
|
-
if (config.patterns) {
|
|
199
|
-
setPatterns(config.patterns);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Set up consent checkers
|
|
203
|
-
setCookieChecker(checkConsent);
|
|
204
|
-
setStorageChecker(checkConsent);
|
|
205
|
-
setScriptChecker(checkConsent);
|
|
206
|
-
|
|
207
|
-
// Start interception
|
|
208
|
-
interceptCookies();
|
|
209
|
-
interceptStorage();
|
|
210
|
-
startScriptBlocking(config.mode, config.blockedDomains);
|
|
211
|
-
|
|
212
|
-
// Load saved consent
|
|
213
|
-
const consent = loadConsent();
|
|
214
|
-
|
|
215
|
-
initialized = true;
|
|
216
|
-
|
|
217
|
-
// Push update for returning visitors with saved consent
|
|
218
|
-
if (hasConsentDecision()) {
|
|
219
|
-
applyConsentSignals(consent, config, false);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Check Do Not Track / Global Privacy Control
|
|
223
|
-
const dntEnabled = isDoNotTrackEnabled();
|
|
224
|
-
let dntApplied = false;
|
|
225
|
-
|
|
226
|
-
if (dntEnabled && config.respectDNT && config.dntBehavior !== 'ignore') {
|
|
227
|
-
if (config.dntBehavior === 'reject' && !hasConsentDecision()) {
|
|
228
|
-
// Auto-reject non-essential cookies silently
|
|
229
|
-
const result = storeRejectAll(config.expiration);
|
|
230
|
-
dntApplied = true;
|
|
131
|
+
const config = getActiveConfig();
|
|
231
132
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// Emit events
|
|
235
|
-
emitReject(result.current);
|
|
236
|
-
emitChange(result.current, result.previous);
|
|
237
|
-
config.callbacks?.onReject?.();
|
|
238
|
-
config.callbacks?.onChange?.(result.current);
|
|
239
|
-
}
|
|
240
|
-
// 'preselect' behavior is handled by default (banner shows with defaults off)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Emit ready event
|
|
244
|
-
emitReady(consent);
|
|
245
|
-
config.callbacks?.onReady?.(consent);
|
|
246
|
-
|
|
247
|
-
// Show UI based on consent state
|
|
248
|
-
if (!hasConsentDecision() && !dntApplied) {
|
|
249
|
-
// No consent decision yet - show banner
|
|
133
|
+
if (!hasDecision && !dntApplied) {
|
|
250
134
|
showBanner({
|
|
251
135
|
onAcceptAll: handleAcceptAll,
|
|
252
136
|
onRejectAll: handleRejectAll,
|
|
253
137
|
onSettings: handleShowSettings
|
|
254
138
|
});
|
|
255
139
|
emitShow('banner');
|
|
256
|
-
} else {
|
|
257
|
-
|
|
258
|
-
if (config.showWidget) {
|
|
259
|
-
showWidget({ onClick: handleShowSettings });
|
|
260
|
-
}
|
|
140
|
+
} else if (config?.showWidget) {
|
|
141
|
+
showWidget({ onClick: handleShowSettings });
|
|
261
142
|
}
|
|
262
143
|
|
|
263
144
|
return Zest;
|
|
264
145
|
}
|
|
265
146
|
|
|
266
|
-
/**
|
|
267
|
-
* Public API
|
|
268
|
-
*/
|
|
269
147
|
const Zest = {
|
|
270
|
-
// Initialization
|
|
271
148
|
init,
|
|
272
149
|
|
|
273
150
|
// Banner control
|
|
274
151
|
show() {
|
|
275
|
-
if (!
|
|
152
|
+
if (!isInitialized()) {
|
|
276
153
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
277
154
|
return;
|
|
278
155
|
}
|
|
@@ -293,7 +170,7 @@ const Zest = {
|
|
|
293
170
|
|
|
294
171
|
// Settings modal
|
|
295
172
|
showSettings() {
|
|
296
|
-
if (!
|
|
173
|
+
if (!isInitialized()) {
|
|
297
174
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
298
175
|
return;
|
|
299
176
|
}
|
|
@@ -305,19 +182,19 @@ const Zest = {
|
|
|
305
182
|
emitHide('modal');
|
|
306
183
|
},
|
|
307
184
|
|
|
308
|
-
// Consent
|
|
185
|
+
// Consent state
|
|
309
186
|
getConsent,
|
|
310
187
|
hasConsent,
|
|
311
188
|
hasConsentDecision,
|
|
312
189
|
getConsentProof,
|
|
313
190
|
|
|
314
|
-
// DNT
|
|
191
|
+
// DNT
|
|
315
192
|
isDoNotTrackEnabled,
|
|
316
193
|
getDNTDetails,
|
|
317
194
|
|
|
318
|
-
//
|
|
195
|
+
// Programmatic accept / reject
|
|
319
196
|
acceptAll() {
|
|
320
|
-
if (!
|
|
197
|
+
if (!isInitialized()) {
|
|
321
198
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
322
199
|
return;
|
|
323
200
|
}
|
|
@@ -325,20 +202,19 @@ const Zest = {
|
|
|
325
202
|
},
|
|
326
203
|
|
|
327
204
|
rejectAll() {
|
|
328
|
-
if (!
|
|
205
|
+
if (!isInitialized()) {
|
|
329
206
|
console.warn('[Zest] Not initialized. Call Zest.init() first.');
|
|
330
207
|
return;
|
|
331
208
|
}
|
|
332
209
|
handleRejectAll();
|
|
333
210
|
},
|
|
334
211
|
|
|
335
|
-
// Reset and
|
|
212
|
+
// Reset everything and reshow the banner
|
|
336
213
|
reset() {
|
|
337
|
-
|
|
214
|
+
coreReset();
|
|
338
215
|
hideModal();
|
|
339
216
|
removeWidget();
|
|
340
|
-
|
|
341
|
-
if (initialized) {
|
|
217
|
+
if (isInitialized()) {
|
|
342
218
|
showBanner({
|
|
343
219
|
onAcceptAll: handleAcceptAll,
|
|
344
220
|
onRejectAll: handleRejectAll,
|
|
@@ -348,17 +224,30 @@ const Zest = {
|
|
|
348
224
|
}
|
|
349
225
|
},
|
|
350
226
|
|
|
351
|
-
// Config
|
|
227
|
+
// Config introspection
|
|
352
228
|
getConfig: getCurrentConfig,
|
|
353
229
|
|
|
354
230
|
// Events
|
|
231
|
+
on,
|
|
232
|
+
once,
|
|
355
233
|
EVENTS
|
|
356
234
|
};
|
|
357
235
|
|
|
358
236
|
// Auto-init if config present
|
|
359
237
|
if (typeof window !== 'undefined') {
|
|
360
|
-
// Make Zest available globally
|
|
361
|
-
|
|
238
|
+
// Make Zest available globally. defineProperty with writable:false +
|
|
239
|
+
// configurable:false stops a later-loaded script from replacing the
|
|
240
|
+
// global with a trojanned stand-in.
|
|
241
|
+
try {
|
|
242
|
+
Object.defineProperty(window, 'Zest', {
|
|
243
|
+
value: Object.freeze(Zest),
|
|
244
|
+
writable: false,
|
|
245
|
+
configurable: false,
|
|
246
|
+
enumerable: true
|
|
247
|
+
});
|
|
248
|
+
} catch (e) {
|
|
249
|
+
window.Zest = Zest;
|
|
250
|
+
}
|
|
362
251
|
|
|
363
252
|
const autoInit = () => {
|
|
364
253
|
const cfg = getConfig();
|
|
@@ -2,12 +2,27 @@
|
|
|
2
2
|
* Consent Store - Manages consent state persistence
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { getDefaultConsent } from '../core/categories.js';
|
|
5
|
+
import { getDefaultConsent, getCategoryIds } from '../core/categories.js';
|
|
6
6
|
import { getOriginalCookieDescriptor } from '../core/cookie-interceptor.js';
|
|
7
|
+
import { sanitizeConsentPayload } from '../core/security.js';
|
|
7
8
|
|
|
8
9
|
const COOKIE_NAME = 'zest_consent';
|
|
9
10
|
const CONSENT_VERSION = '1.0';
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Return the Secure flag fragment when running over HTTPS, empty otherwise.
|
|
14
|
+
* On HTTPS sites, omitting Secure lets the cookie leak over plain HTTP.
|
|
15
|
+
*/
|
|
16
|
+
function secureAttribute() {
|
|
17
|
+
try {
|
|
18
|
+
return typeof location !== 'undefined' && location.protocol === 'https:'
|
|
19
|
+
? '; Secure'
|
|
20
|
+
: '';
|
|
21
|
+
} catch (_) {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
11
26
|
// Current consent state
|
|
12
27
|
let consent = null;
|
|
13
28
|
|
|
@@ -36,7 +51,12 @@ function getRawCookie() {
|
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
/**
|
|
39
|
-
* Load consent from cookie
|
|
54
|
+
* Load consent from cookie.
|
|
55
|
+
*
|
|
56
|
+
* The parsed cookie is validated against the expected schema via
|
|
57
|
+
* sanitizeConsentPayload — only known category keys with boolean values
|
|
58
|
+
* survive, so a tampered cookie can't inject prototype-polluting props
|
|
59
|
+
* or unexpected category shapes.
|
|
40
60
|
*/
|
|
41
61
|
export function loadConsent() {
|
|
42
62
|
try {
|
|
@@ -44,9 +64,12 @@ export function loadConsent() {
|
|
|
44
64
|
const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
45
65
|
|
|
46
66
|
if (match) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
const raw = JSON.parse(decodeURIComponent(match[1]));
|
|
68
|
+
const clean = sanitizeConsentPayload(raw, getCategoryIds());
|
|
69
|
+
if (clean && clean.categories) {
|
|
70
|
+
consent = { ...getDefaultConsent(), ...clean.categories };
|
|
71
|
+
return { ...consent };
|
|
72
|
+
}
|
|
50
73
|
}
|
|
51
74
|
} catch (e) {
|
|
52
75
|
// Invalid or missing cookie
|
|
@@ -71,7 +94,7 @@ export function saveConsent(expirationDays = 365) {
|
|
|
71
94
|
};
|
|
72
95
|
|
|
73
96
|
const expires = new Date(Date.now() + expirationDays * 24 * 60 * 60 * 1000).toUTCString();
|
|
74
|
-
const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax`;
|
|
97
|
+
const cookieValue = `${COOKIE_NAME}=${encodeURIComponent(JSON.stringify(data))}; expires=${expires}; path=/; SameSite=Lax${secureAttribute()}`;
|
|
75
98
|
|
|
76
99
|
setRawCookie(cookieValue);
|
|
77
100
|
}
|
|
@@ -142,7 +165,7 @@ export function rejectAll(expirationDays = 365) {
|
|
|
142
165
|
* Reset consent (clear cookie)
|
|
143
166
|
*/
|
|
144
167
|
export function resetConsent() {
|
|
145
|
-
setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path
|
|
168
|
+
setRawCookie(`${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax${secureAttribute()}`);
|
|
146
169
|
consent = null;
|
|
147
170
|
}
|
|
148
171
|
|
|
@@ -167,7 +190,8 @@ export function getConsentProof() {
|
|
|
167
190
|
const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
168
191
|
|
|
169
192
|
if (match) {
|
|
170
|
-
|
|
193
|
+
const raw = JSON.parse(decodeURIComponent(match[1]));
|
|
194
|
+
return sanitizeConsentPayload(raw, getCategoryIds());
|
|
171
195
|
}
|
|
172
196
|
} catch (e) {
|
|
173
197
|
// Invalid cookie
|
package/src/ui/banner.js
CHANGED
|
@@ -4,30 +4,34 @@
|
|
|
4
4
|
|
|
5
5
|
import { generateStyles } from './styles.js';
|
|
6
6
|
import { getCurrentConfig } from '../config/parser.js';
|
|
7
|
+
import { escapeHTML } from '../core/security.js';
|
|
7
8
|
|
|
8
9
|
let bannerElement = null;
|
|
9
10
|
let shadowRoot = null;
|
|
10
11
|
|
|
12
|
+
const SAFE_POSITIONS = new Set(['bottom', 'bottom-left', 'bottom-right', 'top']);
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* Create the banner HTML
|
|
13
16
|
*/
|
|
14
17
|
function createBannerHTML(config) {
|
|
15
18
|
const labels = config.labels.banner;
|
|
16
|
-
const
|
|
19
|
+
const rawPosition = config.position || 'bottom';
|
|
20
|
+
const position = SAFE_POSITIONS.has(rawPosition) ? rawPosition : 'bottom';
|
|
17
21
|
|
|
18
22
|
return `
|
|
19
|
-
<div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${labels.title}">
|
|
20
|
-
<h2 class="zest-banner__title">${labels.title}</h2>
|
|
21
|
-
<p class="zest-banner__description">${labels.description}</p>
|
|
23
|
+
<div class="zest-banner zest-banner--${position}" role="dialog" aria-modal="false" aria-label="${escapeHTML(labels.title)}">
|
|
24
|
+
<h2 class="zest-banner__title">${escapeHTML(labels.title)}</h2>
|
|
25
|
+
<p class="zest-banner__description">${escapeHTML(labels.description)}</p>
|
|
22
26
|
<div class="zest-banner__buttons">
|
|
23
27
|
<button type="button" class="zest-btn zest-btn--primary" data-action="accept-all">
|
|
24
|
-
${labels.acceptAll}
|
|
28
|
+
${escapeHTML(labels.acceptAll)}
|
|
25
29
|
</button>
|
|
26
30
|
<button type="button" class="zest-btn zest-btn--secondary" data-action="reject-all">
|
|
27
|
-
${labels.rejectAll}
|
|
31
|
+
${escapeHTML(labels.rejectAll)}
|
|
28
32
|
</button>
|
|
29
33
|
<button type="button" class="zest-btn zest-btn--ghost" data-action="settings">
|
|
30
|
-
${labels.settings}
|
|
34
|
+
${escapeHTML(labels.settings)}
|
|
31
35
|
</button>
|
|
32
36
|
</div>
|
|
33
37
|
</div>
|
package/src/ui/modal.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { generateStyles } from './styles.js';
|
|
6
6
|
import { getCurrentConfig } from '../config/parser.js';
|
|
7
7
|
import { DEFAULT_CATEGORIES } from '../core/categories.js';
|
|
8
|
+
import { escapeHTML, safeUrl } from '../core/security.js';
|
|
8
9
|
|
|
9
10
|
let modalElement = null;
|
|
10
11
|
let shadowRoot = null;
|
|
@@ -16,22 +17,24 @@ let currentSelections = {};
|
|
|
16
17
|
function createCategoryHTML(category, isChecked, isRequired) {
|
|
17
18
|
const disabled = isRequired ? 'disabled' : '';
|
|
18
19
|
const checked = isChecked ? 'checked' : '';
|
|
20
|
+
const safeId = escapeHTML(category.id);
|
|
21
|
+
const safeLabel = escapeHTML(category.label);
|
|
19
22
|
|
|
20
23
|
return `
|
|
21
24
|
<div class="zest-category">
|
|
22
25
|
<div class="zest-category__header">
|
|
23
26
|
<div class="zest-category__info">
|
|
24
|
-
<span class="zest-category__label">${
|
|
25
|
-
<p class="zest-category__description">${category.description}</p>
|
|
27
|
+
<span class="zest-category__label">${safeLabel}</span>
|
|
28
|
+
<p class="zest-category__description">${escapeHTML(category.description)}</p>
|
|
26
29
|
</div>
|
|
27
30
|
<label class="zest-toggle">
|
|
28
31
|
<input
|
|
29
32
|
type="checkbox"
|
|
30
33
|
class="zest-toggle__input"
|
|
31
|
-
data-category="${
|
|
34
|
+
data-category="${safeId}"
|
|
32
35
|
${checked}
|
|
33
36
|
${disabled}
|
|
34
|
-
aria-label="${
|
|
37
|
+
aria-label="${safeLabel}"
|
|
35
38
|
>
|
|
36
39
|
<span class="zest-toggle__slider"></span>
|
|
37
40
|
</label>
|
|
@@ -55,29 +58,30 @@ function createModalHTML(config, consent) {
|
|
|
55
58
|
))
|
|
56
59
|
.join('');
|
|
57
60
|
|
|
58
|
-
const
|
|
59
|
-
|
|
61
|
+
const validatedPolicyUrl = config.policyUrl ? safeUrl(config.policyUrl) : null;
|
|
62
|
+
const policyLink = validatedPolicyUrl
|
|
63
|
+
? `<a href="${escapeHTML(validatedPolicyUrl)}" class="zest-link" target="_blank" rel="noopener noreferrer">Privacy Policy</a>`
|
|
60
64
|
: '';
|
|
61
65
|
|
|
62
66
|
return `
|
|
63
|
-
<div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${labels.title}">
|
|
67
|
+
<div class="zest-modal-overlay" role="dialog" aria-modal="true" aria-label="${escapeHTML(labels.title)}">
|
|
64
68
|
<div class="zest-modal">
|
|
65
69
|
<div class="zest-modal__header">
|
|
66
|
-
<h2 class="zest-modal__title">${labels.title}</h2>
|
|
67
|
-
<p class="zest-modal__description">${labels.description} ${policyLink}</p>
|
|
70
|
+
<h2 class="zest-modal__title">${escapeHTML(labels.title)}</h2>
|
|
71
|
+
<p class="zest-modal__description">${escapeHTML(labels.description)} ${policyLink}</p>
|
|
68
72
|
</div>
|
|
69
73
|
<div class="zest-modal__body">
|
|
70
74
|
${categoriesHTML}
|
|
71
75
|
</div>
|
|
72
76
|
<div class="zest-modal__footer">
|
|
73
77
|
<button type="button" class="zest-btn zest-btn--primary" data-action="save">
|
|
74
|
-
${labels.save}
|
|
78
|
+
${escapeHTML(labels.save)}
|
|
75
79
|
</button>
|
|
76
80
|
<button type="button" class="zest-btn zest-btn--secondary" data-action="accept-all">
|
|
77
|
-
${labels.acceptAll}
|
|
81
|
+
${escapeHTML(labels.acceptAll)}
|
|
78
82
|
</button>
|
|
79
83
|
<button type="button" class="zest-btn zest-btn--ghost" data-action="reject-all">
|
|
80
|
-
${labels.rejectAll}
|
|
84
|
+
${escapeHTML(labels.rejectAll)}
|
|
81
85
|
</button>
|
|
82
86
|
</div>
|
|
83
87
|
</div>
|
package/src/ui/styles.js
CHANGED
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
* Styles - Shadow DOM encapsulated CSS with theming
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { safeColor, sanitizeCustomStyles } from '../core/security.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_ACCENT = '#4F46E5';
|
|
8
|
+
|
|
5
9
|
/**
|
|
6
10
|
* Generate CSS with custom properties
|
|
7
11
|
*/
|
|
8
12
|
export function generateStyles(config) {
|
|
9
|
-
|
|
13
|
+
// Only accept colors that pass strict validation — an unvalidated
|
|
14
|
+
// value is a CSS-injection vector (e.g. `red; } * { display:none; /*`).
|
|
15
|
+
const accentColor = safeColor(config.accentColor) || DEFAULT_ACCENT;
|
|
16
|
+
const customCss = sanitizeCustomStyles(config.customStyles);
|
|
10
17
|
|
|
11
18
|
return `
|
|
12
19
|
:host {
|
|
@@ -476,15 +483,29 @@ export function generateStyles(config) {
|
|
|
476
483
|
.zest-hidden {
|
|
477
484
|
display: none !important;
|
|
478
485
|
}
|
|
479
|
-
${
|
|
486
|
+
${customCss}
|
|
480
487
|
`;
|
|
481
488
|
}
|
|
482
489
|
|
|
483
490
|
/**
|
|
484
|
-
* Adjust color brightness
|
|
491
|
+
* Adjust color brightness. Falls back to the default accent if the input
|
|
492
|
+
* cannot be parsed as a hex color (non-hex inputs pass safeColor but
|
|
493
|
+
* can't be brightness-shifted mathematically).
|
|
485
494
|
*/
|
|
486
495
|
function adjustColor(hex, percent) {
|
|
487
|
-
|
|
496
|
+
if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{3,8}$/.test(hex.trim())) {
|
|
497
|
+
hex = DEFAULT_ACCENT;
|
|
498
|
+
}
|
|
499
|
+
let clean = hex.trim().replace('#', '');
|
|
500
|
+
// Expand 3-digit form to 6
|
|
501
|
+
if (clean.length === 3) {
|
|
502
|
+
clean = clean.split('').map(c => c + c).join('');
|
|
503
|
+
}
|
|
504
|
+
// Strip alpha if present
|
|
505
|
+
if (clean.length === 8) clean = clean.slice(0, 6);
|
|
506
|
+
if (clean.length !== 6) clean = DEFAULT_ACCENT.slice(1);
|
|
507
|
+
|
|
508
|
+
const num = parseInt(clean, 16);
|
|
488
509
|
const amt = Math.round(2.55 * percent);
|
|
489
510
|
const R = Math.min(255, Math.max(0, (num >> 16) + amt));
|
|
490
511
|
const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt));
|
package/src/ui/widget.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { generateStyles, COOKIE_ICON } from './styles.js';
|
|
6
6
|
import { getCurrentConfig } from '../config/parser.js';
|
|
7
|
+
import { escapeHTML } from '../core/security.js';
|
|
7
8
|
|
|
8
9
|
let widgetElement = null;
|
|
9
10
|
let shadowRoot = null;
|
|
@@ -13,10 +14,11 @@ let shadowRoot = null;
|
|
|
13
14
|
*/
|
|
14
15
|
function createWidgetHTML(config) {
|
|
15
16
|
const labels = config.labels.widget;
|
|
17
|
+
const safeLabel = escapeHTML(labels.label);
|
|
16
18
|
|
|
17
19
|
return `
|
|
18
20
|
<div class="zest-widget">
|
|
19
|
-
<button type="button" class="zest-widget__btn" aria-label="${
|
|
21
|
+
<button type="button" class="zest-widget__btn" aria-label="${safeLabel}" title="${safeLabel}">
|
|
20
22
|
<span class="zest-widget__icon">${COOKIE_ICON}</span>
|
|
21
23
|
</button>
|
|
22
24
|
</div>
|