@emplugins/emprivacy 3.1.0 → 3.2.1
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 +2 -2
- package/dist/index.d.mts +8 -2
- package/dist/index.mjs +192 -74
- package/docs/DEVELOPMENT.md +2 -2
- package/docs/PLUGIN_SETTINGS.md +12 -10
- package/docs/RELEASES.md +8 -5
- package/docs/RELEASE_NOTES.md +22 -0
- package/docs/TESTING.md +1 -1
- package/package.json +4 -4
- package/src/astro/embed-resolve.ts +4 -16
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
## Requirements
|
|
29
29
|
|
|
30
|
-
- **EmDash** `^1.0.1` (**tested with `1.0.1`**). EmDash 0.x is not supported.
|
|
30
|
+
- **EmDash** `^1.0.1` (**CI-tested with `1.0.1` and `1.4.0`**). EmDash 0.x is not supported.
|
|
31
31
|
- **Node.js** `>= 22.16` (matches EmDash’s engine requirement).
|
|
32
32
|
- Register as a **native** plugin in **`astro.config`** via `plugins: []` (not `sandboxed: []`). EmPrivacy uses `page:fragments` for the public banner and `componentsEntry` for embed placeholders. See [Page fragments](https://docs.emdashcms.com/plugins/creating-native-plugins/page-fragments/) and [Plugin overview](https://docs.emdashcms.com/plugins/overview/).
|
|
33
33
|
|
|
@@ -114,7 +114,7 @@ stop();
|
|
|
114
114
|
|--------|-------------|
|
|
115
115
|
| Categories | Essential (always on), **Functional**, **Analytics**, **Marketing** |
|
|
116
116
|
| Privacy / cookie links | EmDash **Page** path or `https://` URL; resolved with `ctx.url()` |
|
|
117
|
-
| Analytics presets | Cloudflare Web Analytics, Plausible, Fathom, Umami, Simple Analytics, GA4, **GTM (Marketing)**, **Microsoft Clarity (Analytics)**, **Microsoft UET (Marketing)**, None, Custom `https` URLs (
|
|
117
|
+
| Analytics presets | Cloudflare Web Analytics, Plausible, Fathom, Umami, Simple Analytics, GA4, **GTM (Marketing)**, **Microsoft Clarity (Analytics)**, **Microsoft UET (Marketing)**, None, Custom `https` URLs (SRI and script host allowlist required) |
|
|
118
118
|
| Marketing scripts | Only **https** URLs you list, injected as `<script src>` after marketing consent |
|
|
119
119
|
| Official embeds | Optional placeholders for `@emdash-cms/plugin-embeds` block types; YouTube/Vimeo use allowlisted player URLs (`youtube-nocookie`, `player.vimeo.com`); client re-checks URLs before mount |
|
|
120
120
|
| Re-open preferences | Cookie button (bottom-left); saving reloads so script loading matches the latest choice |
|
package/dist/index.d.mts
CHANGED
|
@@ -171,10 +171,15 @@ interface EmprivacyConfig {
|
|
|
171
171
|
/** Optional SRI hashes keyed by script src (sha256/384/512-…) */
|
|
172
172
|
scriptIntegrity: Record<string, string>;
|
|
173
173
|
/**
|
|
174
|
-
*
|
|
175
|
-
* Preset CDN hosts
|
|
174
|
+
* Hostnames allowed for Custom analytics, Umami, and marketing scripts.
|
|
175
|
+
* An empty list denies those URLs. Preset CDN hosts stay allowed for built-in providers.
|
|
176
176
|
*/
|
|
177
177
|
scriptHostAllowlist: string[];
|
|
178
|
+
/**
|
|
179
|
+
* Hosts that may become https links for Mastodon and link previews.
|
|
180
|
+
* X, Bluesky, Gist, YouTube, and Vimeo do not use this list.
|
|
181
|
+
*/
|
|
182
|
+
embedHostAllowlist: string[];
|
|
178
183
|
/** Consent cookie Max-Age in days (1–365, default 180) */
|
|
179
184
|
cookieMaxAgeDays: number;
|
|
180
185
|
/** Emit Google Consent Mode v2 defaults (denied) in head; updates after choice */
|
|
@@ -257,6 +262,7 @@ interface EmprivacyPublicRuntimeConfig {
|
|
|
257
262
|
integrity: string | null;
|
|
258
263
|
}[];
|
|
259
264
|
scriptHostAllowlist: string[];
|
|
265
|
+
embedHostAllowlist: string[];
|
|
260
266
|
scriptIntegrity: Record<string, string>;
|
|
261
267
|
cookieMaxAge: number;
|
|
262
268
|
vendors: VendorPublicRow[];
|
package/dist/index.mjs
CHANGED
|
@@ -271,6 +271,15 @@ function isUetId(s) {
|
|
|
271
271
|
}
|
|
272
272
|
//#endregion
|
|
273
273
|
//#region src/security.ts
|
|
274
|
+
/**
|
|
275
|
+
* Shared URL / request guards used by admin validation and (via mirrored
|
|
276
|
+
* logic) the public bootstrap. Keep client checks in sync with these helpers.
|
|
277
|
+
*/
|
|
278
|
+
const YT_IFRAME = /^https:\/\/(?:www\.)?youtube-nocookie\.com\/embed\/[A-Za-z0-9_-]{11}\/?$/;
|
|
279
|
+
const VIMEO_IFRAME = /^https:\/\/player\.vimeo\.com\/video\/\d{6,12}\/?$/;
|
|
280
|
+
/** Sandbox and feature policy for YouTube/Vimeo iframes. Kept beside the src regexes. */
|
|
281
|
+
const EMBED_IFRAME_SANDBOX = "allow-scripts allow-same-origin allow-presentation";
|
|
282
|
+
const EMBED_IFRAME_ALLOW = "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; web-share";
|
|
274
283
|
/** Hosts EmPrivacy may load as <script src> for built-in presets. */
|
|
275
284
|
const PRESET_SCRIPT_HOSTS = /* @__PURE__ */ new Set([
|
|
276
285
|
"static.cloudflareinsights.com",
|
|
@@ -302,6 +311,85 @@ function cookieMaxAgeSeconds(days) {
|
|
|
302
311
|
function hasUnsafeUrlChars$1(s) {
|
|
303
312
|
return /[\u0000-\u001F\u007F\s]/.test(s);
|
|
304
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Browser copy of the non-public host check. `parseStrictHttpsUrl` calls the
|
|
316
|
+
* same source so the inline bootstrap cannot drift from the server.
|
|
317
|
+
* Hostnames are whatever `new URL` already normalized (decimal, hex, and
|
|
318
|
+
* short IPv4 forms become dotted quads; IPv6 keeps brackets).
|
|
319
|
+
*/
|
|
320
|
+
function hostnameBlockScript() {
|
|
321
|
+
return `function hostBlocked(raw){
|
|
322
|
+
var host=String(raw||"").trim().toLowerCase().replace(/^\\[|\\]$/g,"");
|
|
323
|
+
if(!host)return true;
|
|
324
|
+
if(host==="localhost"||endsWith(host,".localhost")||endsWith(host,".local")||endsWith(host,".internal")||endsWith(host,".arpa"))return true;
|
|
325
|
+
if(host.indexOf(":")>=0)return ipv6Blocked(host);
|
|
326
|
+
if(/^[0-9.]+$/.test(host)){
|
|
327
|
+
var v4=parseV4(host);
|
|
328
|
+
if(v4===null)return true;
|
|
329
|
+
return v4Blocked(v4);
|
|
330
|
+
}
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
function endsWith(h,s){return h.length>=s.length&&h.slice(h.length-s.length)===s;}
|
|
334
|
+
function parseV4(host){
|
|
335
|
+
var p=host.split(".");
|
|
336
|
+
if(p.length!==4)return null;
|
|
337
|
+
var n=0;
|
|
338
|
+
for(var i=0;i<p.length;i++){
|
|
339
|
+
var part=p[i];
|
|
340
|
+
if(!/^\\d{1,3}$/.test(part))return null;
|
|
341
|
+
if(part.length>1&&part.charAt(0)==="0")return null;
|
|
342
|
+
var v=+part;
|
|
343
|
+
if(v>255)return null;
|
|
344
|
+
n=n*256+v;
|
|
345
|
+
}
|
|
346
|
+
return n>>>0;
|
|
347
|
+
}
|
|
348
|
+
function v4Blocked(n){
|
|
349
|
+
var a=n>>>24;
|
|
350
|
+
var b=(n>>>16)&255;
|
|
351
|
+
if(a===0||a===10||a===127)return true;
|
|
352
|
+
if(a===100&&b>=64&&b<=127)return true;
|
|
353
|
+
if(a===169&&b===254)return true;
|
|
354
|
+
if(a===172&&b>=16&&b<=31)return true;
|
|
355
|
+
if(a===192&&b===168)return true;
|
|
356
|
+
if(a>=224)return true;
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
function ipv6Blocked(host){
|
|
360
|
+
if(host==="::"||host==="::1")return true;
|
|
361
|
+
var dotted=/^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/.exec(host);
|
|
362
|
+
if(dotted){
|
|
363
|
+
var d4=parseV4(dotted[1]);
|
|
364
|
+
return d4===null||v4Blocked(d4);
|
|
365
|
+
}
|
|
366
|
+
var mapped=/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host);
|
|
367
|
+
if(mapped){
|
|
368
|
+
var hi=parseInt(mapped[1],16);
|
|
369
|
+
var lo=parseInt(mapped[2],16);
|
|
370
|
+
if(hi>65535||lo>65535)return true;
|
|
371
|
+
return v4Blocked(((hi<<16)|lo)>>>0);
|
|
372
|
+
}
|
|
373
|
+
if(/\\d{1,3}(?:\\.\\d{1,3}){3}$/.test(host))return true;
|
|
374
|
+
var first;
|
|
375
|
+
if(host.slice(0,2)==="::")first=0;
|
|
376
|
+
else{
|
|
377
|
+
var head=host.split(":")[0];
|
|
378
|
+
if(!/^[0-9a-f]{1,4}$/.test(head))return true;
|
|
379
|
+
first=parseInt(head,16);
|
|
380
|
+
}
|
|
381
|
+
if((first&0xfe00)===0xfc00)return true;
|
|
382
|
+
if((first&0xffc0)===0xfe80)return true;
|
|
383
|
+
if((first&0xffc0)===0xfec0)return true;
|
|
384
|
+
if((first&0xff00)===0xff00)return true;
|
|
385
|
+
return false;
|
|
386
|
+
}`;
|
|
387
|
+
}
|
|
388
|
+
const hostBlockedImpl = new Function(`${hostnameBlockScript()}\nreturn hostBlocked;`)();
|
|
389
|
+
/** True for loopback, private, link-local, shared (CGNAT), and non-global addresses. */
|
|
390
|
+
function isBlockedHostname(hostname) {
|
|
391
|
+
return hostBlockedImpl(hostname);
|
|
392
|
+
}
|
|
305
393
|
function parseStrictHttpsUrl(raw) {
|
|
306
394
|
const t = raw.trim();
|
|
307
395
|
if (!t || t.length > 2048 || hasUnsafeUrlChars$1(t)) return null;
|
|
@@ -310,9 +398,7 @@ function parseStrictHttpsUrl(raw) {
|
|
|
310
398
|
const u = new URL(t);
|
|
311
399
|
if (u.protocol !== "https:") return null;
|
|
312
400
|
if (u.username || u.password) return null;
|
|
313
|
-
|
|
314
|
-
if (host === "localhost" || host.endsWith(".localhost")) return null;
|
|
315
|
-
if (host === "127.0.0.1" || host === "::1" || host.endsWith(".local")) return null;
|
|
401
|
+
if (isBlockedHostname(u.hostname)) return null;
|
|
316
402
|
return u;
|
|
317
403
|
} catch {
|
|
318
404
|
return null;
|
|
@@ -345,9 +431,18 @@ function isScriptHostAllowed(raw, opts) {
|
|
|
345
431
|
if (!u) return false;
|
|
346
432
|
const host = u.hostname.toLowerCase();
|
|
347
433
|
if (opts.allowPresets && PRESET_SCRIPT_HOSTS.has(host)) return true;
|
|
348
|
-
if (opts.allowlist.length === 0) return true;
|
|
349
434
|
return opts.allowlist.includes(host);
|
|
350
435
|
}
|
|
436
|
+
/** SRI required before a custom or marketing script may load. Empty is not valid. */
|
|
437
|
+
function hasSri(s) {
|
|
438
|
+
return typeof s === "string" && /^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(s);
|
|
439
|
+
}
|
|
440
|
+
function isPublishableCustomScript(src, integrity, allowlist) {
|
|
441
|
+
return hasSri(integrity) && isScriptHostAllowed(src, {
|
|
442
|
+
allowlist: [...allowlist],
|
|
443
|
+
allowPresets: false
|
|
444
|
+
});
|
|
445
|
+
}
|
|
351
446
|
/** Optional SRI: `https://cdn.example/a.js sha384-...` or `... integrity=sha384-...`. */
|
|
352
447
|
function parseScriptUrlWithIntegrity(line) {
|
|
353
448
|
const t = line.trim();
|
|
@@ -387,6 +482,29 @@ function assertSameOriginMutation(request, expectedOrigin) {
|
|
|
387
482
|
if ((request.headers.get("sec-fetch-site") ?? "").toLowerCase() === "same-origin") return null;
|
|
388
483
|
return new Response("forbidden", { status: 403 });
|
|
389
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* Compare-and-set increment. Returns false when the bucket is full, the store
|
|
487
|
+
* errors, or eight conflicts in a row fail to land a write.
|
|
488
|
+
*/
|
|
489
|
+
async function tryConsumeRateSlot(kv, key, limit) {
|
|
490
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
491
|
+
let current;
|
|
492
|
+
try {
|
|
493
|
+
current = await kv.getVersioned(key);
|
|
494
|
+
} catch {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
const raw = current?.value;
|
|
498
|
+
const n = typeof raw === "string" && /^\d{1,6}$/.test(raw) ? Number(raw) : 0;
|
|
499
|
+
if (n >= limit) return false;
|
|
500
|
+
try {
|
|
501
|
+
if ((await kv.compareAndSet(key, current ? current.revision : null, String(n + 1))).applied) return true;
|
|
502
|
+
} catch {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
390
508
|
/** Coarse hour bucket for rate limiting (UTC). */
|
|
391
509
|
function rateHourKey(now = /* @__PURE__ */ new Date()) {
|
|
392
510
|
return `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(now.getUTCDate()).padStart(2, "0")}${String(now.getUTCHours()).padStart(2, "0")}`;
|
|
@@ -512,6 +630,7 @@ const DEFAULT_CONFIG = {
|
|
|
512
630
|
marketingScriptUrls: [],
|
|
513
631
|
scriptIntegrity: {},
|
|
514
632
|
scriptHostAllowlist: [],
|
|
633
|
+
embedHostAllowlist: [],
|
|
515
634
|
cookieMaxAgeDays: 180,
|
|
516
635
|
googleConsentMode: false,
|
|
517
636
|
logConsentToServer: false,
|
|
@@ -656,6 +775,7 @@ function normalizeConfig(raw) {
|
|
|
656
775
|
if (Object.keys(scriptIntegrity).length >= 100) break;
|
|
657
776
|
}
|
|
658
777
|
const scriptHostAllowlist = (Array.isArray(o.scriptHostAllowlist) ? o.scriptHostAllowlist.filter((x) => typeof x === "string") : []).map((h) => h.trim().toLowerCase()).filter((h) => isHostname$1(h)).slice(0, 50);
|
|
778
|
+
const embedHostAllowlist = (Array.isArray(o.embedHostAllowlist) ? o.embedHostAllowlist.filter((x) => typeof x === "string") : []).map((h) => h.trim().toLowerCase()).filter((h) => isHostname$1(h)).slice(0, 50);
|
|
659
779
|
return {
|
|
660
780
|
bannerTitle: typeof o.bannerTitle === "string" ? o.bannerTitle : DEFAULT_CONFIG.bannerTitle,
|
|
661
781
|
bannerMessage: typeof o.bannerMessage === "string" ? o.bannerMessage : DEFAULT_CONFIG.bannerMessage,
|
|
@@ -671,6 +791,7 @@ function normalizeConfig(raw) {
|
|
|
671
791
|
marketingScriptUrls: marketing,
|
|
672
792
|
scriptIntegrity,
|
|
673
793
|
scriptHostAllowlist,
|
|
794
|
+
embedHostAllowlist,
|
|
674
795
|
cookieMaxAgeDays: normalizeCookieMaxAgeDays(o.cookieMaxAgeDays, DEFAULT_CONFIG.cookieMaxAgeDays),
|
|
675
796
|
googleConsentMode: typeof o.googleConsentMode === "boolean" ? o.googleConsentMode : DEFAULT_CONFIG.googleConsentMode,
|
|
676
797
|
logConsentToServer: typeof o.logConsentToServer === "boolean" ? o.logConsentToServer : DEFAULT_CONFIG.logConsentToServer,
|
|
@@ -701,7 +822,7 @@ function assertAnalyticsId(provider, id, umamiScriptUrl) {
|
|
|
701
822
|
}
|
|
702
823
|
if (provider === "umami") {
|
|
703
824
|
if (!isUmamiId(t)) throw new Error("Umami website ID looks invalid.");
|
|
704
|
-
if (!
|
|
825
|
+
if (!isSafeHttpsScriptUrl(umamiScriptUrl)) throw new Error("Umami requires an https script URL on a public host (Cloud or your self-hosted tracker).");
|
|
705
826
|
return;
|
|
706
827
|
}
|
|
707
828
|
if (provider === "ga4") {
|
|
@@ -739,22 +860,23 @@ function assertValidSavedConfig(input) {
|
|
|
739
860
|
const tokenTrim = input.cloudflareToken.trim();
|
|
740
861
|
if (ap === "cloudflare") assertValidCloudflareToken(tokenTrim);
|
|
741
862
|
const umamiScriptUrl = input.umamiScriptUrl.trim();
|
|
742
|
-
if (ap === "umami" && umamiScriptUrl && !
|
|
863
|
+
if (ap === "umami" && umamiScriptUrl && !isSafeHttpsScriptUrl(umamiScriptUrl)) throw new Error(invalidScriptUrlError("umami", umamiScriptUrl));
|
|
743
864
|
if (ap !== "none" && ap !== "custom" && ap !== "cloudflare" && ap !== "simpleanalytics" && ap !== "gtm") assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
|
|
744
865
|
else if (ap === "simpleanalytics" && input.analyticsId.trim()) assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
|
|
745
866
|
else if (ap === "gtm") assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
|
|
746
867
|
const scriptHostAllowlist = parseHostnameAllowlist(input.scriptHostAllowlistText);
|
|
868
|
+
const embedHostAllowlist = parseHostnameAllowlist(input.embedHostAllowlistText);
|
|
747
869
|
const cookieMaxAgeDays = parseCookieMaxAgeDays(input.cookieMaxAgeDays, 180);
|
|
748
870
|
const scriptIntegrity = {};
|
|
749
871
|
const assertScriptLine = (kind, line) => {
|
|
750
872
|
const { src, integrity } = parseScriptUrlWithIntegrity(line);
|
|
751
873
|
if (!src || src.length > 2048) throw new Error(invalidScriptUrlError(kind, line));
|
|
752
874
|
if (!isSafeHttpsScriptUrl(src)) throw new Error(invalidScriptUrlError(kind, src));
|
|
753
|
-
if (
|
|
875
|
+
if (!hasSri(integrity)) throw new Error(`${kind} scripts require Subresource Integrity (sha256-, sha384-, or sha512-) after the URL.`);
|
|
754
876
|
if (!isScriptHostAllowed(src, {
|
|
755
877
|
allowlist: scriptHostAllowlist,
|
|
756
878
|
allowPresets: false
|
|
757
|
-
})) throw new Error(`${kind} script host is not on the allowlist. Add the hostname
|
|
879
|
+
})) throw new Error(`${kind} script host is not on the allowlist. Add the hostname under Script host allowlist.`);
|
|
758
880
|
if (integrity) scriptIntegrity[src] = integrity;
|
|
759
881
|
return src;
|
|
760
882
|
};
|
|
@@ -762,7 +884,7 @@ function assertValidSavedConfig(input) {
|
|
|
762
884
|
if (!isScriptHostAllowed(umamiScriptUrl, {
|
|
763
885
|
allowlist: scriptHostAllowlist,
|
|
764
886
|
allowPresets: false
|
|
765
|
-
})) throw new Error("Umami script host is not on the allowlist. Add the hostname
|
|
887
|
+
})) throw new Error("Umami script host is not on the allowlist. Add the hostname under Script host allowlist.");
|
|
766
888
|
}
|
|
767
889
|
const analyticsScriptUrls = [];
|
|
768
890
|
if (ap === "custom") {
|
|
@@ -809,6 +931,7 @@ function assertValidSavedConfig(input) {
|
|
|
809
931
|
marketingScriptUrls,
|
|
810
932
|
scriptIntegrity,
|
|
811
933
|
scriptHostAllowlist,
|
|
934
|
+
embedHostAllowlist,
|
|
812
935
|
cookieMaxAgeDays,
|
|
813
936
|
googleConsentMode: input.googleConsentMode,
|
|
814
937
|
logConsentToServer: input.logConsentToServer,
|
|
@@ -1015,9 +1138,10 @@ var C=${jsonLiteral};
|
|
|
1015
1138
|
var CN="${COOKIE_NAME}";
|
|
1016
1139
|
var PURGE=${purgeLiteral};
|
|
1017
1140
|
var listeners=[];
|
|
1018
|
-
var PRESET_HOSTS
|
|
1019
|
-
var YT_IFRAME
|
|
1020
|
-
var VIMEO_IFRAME
|
|
1141
|
+
var PRESET_HOSTS=${jsonForHtmlScript(Object.fromEntries([...PRESET_SCRIPT_HOSTS].map((host) => [host, 1])))};
|
|
1142
|
+
var YT_IFRAME=${YT_IFRAME.toString()};
|
|
1143
|
+
var VIMEO_IFRAME=${VIMEO_IFRAME.toString()};
|
|
1144
|
+
${hostnameBlockScript()}
|
|
1021
1145
|
function readCookie(){
|
|
1022
1146
|
try{
|
|
1023
1147
|
var m=document.cookie.match(new RegExp("(?:^|;\\\\s*)"+CN+"=([^;]*)"));
|
|
@@ -1116,8 +1240,7 @@ if(/%0d|%0a|%09|%0b|%0c|%20/i.test(raw))return null;
|
|
|
1116
1240
|
var u=new URL(raw);
|
|
1117
1241
|
if(u.protocol!=="https:")return null;
|
|
1118
1242
|
if(u.username||u.password)return null;
|
|
1119
|
-
|
|
1120
|
-
if(h==="localhost"||h.endsWith(".localhost")||h==="127.0.0.1"||h==="::1")return null;
|
|
1243
|
+
if(hostBlocked(u.hostname))return null;
|
|
1121
1244
|
return u;
|
|
1122
1245
|
}catch(e){return null;}
|
|
1123
1246
|
}
|
|
@@ -1125,7 +1248,6 @@ function hostAllowed(u,allowPresets){
|
|
|
1125
1248
|
var h=u.hostname.toLowerCase();
|
|
1126
1249
|
if(allowPresets&&PRESET_HOSTS[h])return true;
|
|
1127
1250
|
var list=C.scriptHostAllowlist||[];
|
|
1128
|
-
if(!list.length)return true;
|
|
1129
1251
|
return list.indexOf(h)>=0;
|
|
1130
1252
|
}
|
|
1131
1253
|
function safeScriptUrl(raw,allowPresets){
|
|
@@ -1135,7 +1257,7 @@ if(!hostAllowed(u,!!allowPresets))return null;
|
|
|
1135
1257
|
return u.href;
|
|
1136
1258
|
}
|
|
1137
1259
|
function allowedIframe(src){return YT_IFRAME.test(src)||VIMEO_IFRAME.test(src);}
|
|
1138
|
-
function allowedLink(src
|
|
1260
|
+
function allowedLink(src){
|
|
1139
1261
|
var u=parseHttps(src);
|
|
1140
1262
|
if(!u)return false;
|
|
1141
1263
|
var h=u.hostname.toLowerCase();
|
|
@@ -1148,20 +1270,22 @@ return /^\\/profile\\/[^/]+\\/post\\/[^/]+\\/?$/.test(u.pathname);
|
|
|
1148
1270
|
if(h==="gist.github.com"){
|
|
1149
1271
|
return /^\\/[A-Za-z0-9-]{1,39}\\/[a-f0-9]{8,64}\\/?$/i.test(u.pathname);
|
|
1150
1272
|
}
|
|
1151
|
-
|
|
1152
|
-
return
|
|
1273
|
+
var list=C.embedHostAllowlist||[];
|
|
1274
|
+
return list.indexOf(h)>=0;
|
|
1153
1275
|
}
|
|
1154
1276
|
function alreadyScriptSrc(u){
|
|
1155
1277
|
try{
|
|
1156
1278
|
return Array.prototype.some.call(document.getElementsByTagName("script"),function(s){return s.src===u;});
|
|
1157
1279
|
}catch(e){return false;}
|
|
1158
1280
|
}
|
|
1159
|
-
function loadScript(src, attrs, integrity, allowPresets){
|
|
1281
|
+
function loadScript(src, attrs, integrity, allowPresets, requireIntegrity){
|
|
1160
1282
|
var safe=safeScriptUrl(src,allowPresets);
|
|
1161
1283
|
if(!safe||alreadyScriptSrc(safe))return;
|
|
1284
|
+
var sriOk=integrity&&/^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(integrity);
|
|
1285
|
+
if(requireIntegrity&&!sriOk)return;
|
|
1162
1286
|
var e=document.createElement("script");
|
|
1163
1287
|
e.src=safe;e.async=true;e.referrerPolicy="no-referrer-when-downgrade";
|
|
1164
|
-
if(
|
|
1288
|
+
if(sriOk){
|
|
1165
1289
|
e.integrity=integrity;
|
|
1166
1290
|
e.crossOrigin="anonymous";
|
|
1167
1291
|
}
|
|
@@ -1186,7 +1310,7 @@ document.head.appendChild(e);
|
|
|
1186
1310
|
function applyAnalytics(st){
|
|
1187
1311
|
var L=C.loader||{type:"none"};
|
|
1188
1312
|
if(L.type==="cloudflare") loadCloudflareBeacon(L.token);
|
|
1189
|
-
else if(L.type==="custom") (L.scripts||[]).forEach(function(s){loadScript(s.src,null,s.integrity,false);});
|
|
1313
|
+
else if(L.type==="custom") (L.scripts||[]).forEach(function(s){loadScript(s.src,null,s.integrity,false,true);});
|
|
1190
1314
|
else if(L.type==="plausible") loadScript(L.src,{"data-domain":L.domain},null,true);
|
|
1191
1315
|
else if(L.type==="fathom") loadScript(L.src,{"data-site":L.siteId},null,true);
|
|
1192
1316
|
else if(L.type==="umami") loadScript(L.src,{"data-website-id":L.websiteId},null,false);
|
|
@@ -1208,7 +1332,7 @@ loadScript("https://www.clarity.ms/tag/"+encodeURIComponent(id),null,null,true);
|
|
|
1208
1332
|
}
|
|
1209
1333
|
function applyMarketing(){
|
|
1210
1334
|
var urls=C.marketingScripts||[];
|
|
1211
|
-
urls.forEach(function(s){loadScript(s.src,null,s.integrity,false);});
|
|
1335
|
+
urls.forEach(function(s){loadScript(s.src,null,s.integrity,false,true);});
|
|
1212
1336
|
var L=C.loader||{type:"none"};
|
|
1213
1337
|
if(L.type==="gtm"){
|
|
1214
1338
|
window.dataLayer=window.dataLayer||[];
|
|
@@ -1274,17 +1398,17 @@ var f=document.createElement("iframe");
|
|
|
1274
1398
|
f.src=src;
|
|
1275
1399
|
f.setAttribute("loading","lazy");
|
|
1276
1400
|
f.setAttribute("referrerpolicy","strict-origin-when-cross-origin");
|
|
1277
|
-
f.setAttribute("allow"
|
|
1401
|
+
f.setAttribute("allow",${JSON.stringify(EMBED_IFRAME_ALLOW)});
|
|
1278
1402
|
f.setAttribute("allowfullscreen","");
|
|
1279
|
-
f.setAttribute("sandbox"
|
|
1403
|
+
f.setAttribute("sandbox",${JSON.stringify(EMBED_IFRAME_SANDBOX)});
|
|
1280
1404
|
f.setAttribute("title",box.getAttribute("data-label")||"Embed");
|
|
1281
1405
|
f.style.width="100%";
|
|
1282
1406
|
f.style.aspectRatio="16/9";
|
|
1283
1407
|
f.style.border="0";
|
|
1284
1408
|
box.replaceWith(f);
|
|
1285
1409
|
}
|
|
1286
|
-
function mountLink(box,src,label
|
|
1287
|
-
if(!allowedLink(src
|
|
1410
|
+
function mountLink(box,src,label){
|
|
1411
|
+
if(!allowedLink(src))return;
|
|
1288
1412
|
var a=document.createElement("a");
|
|
1289
1413
|
a.href=src;
|
|
1290
1414
|
a.rel="noopener noreferrer nofollow";
|
|
@@ -1303,9 +1427,8 @@ if(loadBtn) loadBtn.textContent=C.ui.loadEmbed;
|
|
|
1303
1427
|
if(!embedAllowed(st))return;
|
|
1304
1428
|
var src=box.getAttribute("data-src")||"";
|
|
1305
1429
|
var mode=box.getAttribute("data-mode")||"";
|
|
1306
|
-
var kind=box.getAttribute("data-emprivacy-embed")||"";
|
|
1307
1430
|
if(mode==="iframe") mountIframe(box,src);
|
|
1308
|
-
else mountLink(box,src,box.getAttribute("data-label")||""
|
|
1431
|
+
else mountLink(box,src,box.getAttribute("data-label")||"");
|
|
1309
1432
|
});
|
|
1310
1433
|
}
|
|
1311
1434
|
function persist(a,m,f,opts){
|
|
@@ -1641,13 +1764,16 @@ function buildAnalyticsLoader(cfg) {
|
|
|
1641
1764
|
type: "cloudflare",
|
|
1642
1765
|
token: cfg.cloudflareWebAnalyticsToken
|
|
1643
1766
|
} : { type: "none" };
|
|
1644
|
-
case "custom":
|
|
1645
|
-
|
|
1646
|
-
scripts: cfg.analyticsScriptUrls.map((src) => ({
|
|
1767
|
+
case "custom": {
|
|
1768
|
+
const scripts = cfg.analyticsScriptUrls.filter((src) => isPublishableCustomScript(src, cfg.scriptIntegrity[src], cfg.scriptHostAllowlist)).map((src) => ({
|
|
1647
1769
|
src,
|
|
1648
1770
|
integrity: cfg.scriptIntegrity[src] ?? null
|
|
1649
|
-
}))
|
|
1650
|
-
|
|
1771
|
+
}));
|
|
1772
|
+
return scripts.length ? {
|
|
1773
|
+
type: "custom",
|
|
1774
|
+
scripts
|
|
1775
|
+
} : { type: "none" };
|
|
1776
|
+
}
|
|
1651
1777
|
case "plausible": return isHostname$1(cfg.analyticsId) ? {
|
|
1652
1778
|
type: "plausible",
|
|
1653
1779
|
src: PLAUSIBLE_SCRIPT,
|
|
@@ -1658,7 +1784,10 @@ function buildAnalyticsLoader(cfg) {
|
|
|
1658
1784
|
src: FATHOM_SCRIPT,
|
|
1659
1785
|
siteId: cfg.analyticsId.trim()
|
|
1660
1786
|
} : { type: "none" };
|
|
1661
|
-
case "umami": return isUmamiId(cfg.analyticsId) && cfg.umamiScriptUrl
|
|
1787
|
+
case "umami": return isUmamiId(cfg.analyticsId) && cfg.umamiScriptUrl && isScriptHostAllowed(cfg.umamiScriptUrl, {
|
|
1788
|
+
allowlist: cfg.scriptHostAllowlist,
|
|
1789
|
+
allowPresets: false
|
|
1790
|
+
}) ? {
|
|
1662
1791
|
type: "umami",
|
|
1663
1792
|
src: cfg.umamiScriptUrl,
|
|
1664
1793
|
websiteId: cfg.analyticsId.trim()
|
|
@@ -1798,11 +1927,13 @@ function buildVendorList(cfg) {
|
|
|
1798
1927
|
}
|
|
1799
1928
|
//#endregion
|
|
1800
1929
|
//#region src/version.ts
|
|
1801
|
-
const VERSION = "3.1
|
|
1930
|
+
const VERSION = "3.2.1";
|
|
1802
1931
|
//#endregion
|
|
1803
1932
|
//#region src/runtime.ts
|
|
1804
|
-
/** Soft cap for anonymous consent POSTs per client
|
|
1933
|
+
/** Soft cap for anonymous consent POSTs per trusted client address per UTC hour. */
|
|
1805
1934
|
const RECORD_RATE_LIMIT = 30;
|
|
1935
|
+
/** Site-wide cap so one client cannot fill the consent log in a single hour. */
|
|
1936
|
+
const RECORD_GLOBAL_LIMIT = 60;
|
|
1806
1937
|
const RECORD_LOG_CAP = 500;
|
|
1807
1938
|
const ADMIN_SETTINGS_PATH = "/settings";
|
|
1808
1939
|
const SAVE_ACTION_ID = "emprivacy-save";
|
|
@@ -1839,28 +1970,17 @@ function absolutePolicyHref(stored, ctx) {
|
|
|
1839
1970
|
return null;
|
|
1840
1971
|
}
|
|
1841
1972
|
}
|
|
1842
|
-
function clientFingerKey(request) {
|
|
1843
|
-
const cf = request.headers.get("cf-connecting-ip")?.trim();
|
|
1844
|
-
const xff = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
1845
|
-
const ua = (request.headers.get("user-agent") ?? "").slice(0, 120);
|
|
1846
|
-
return `${cf || xff || "unknown"}|${ua}`;
|
|
1847
|
-
}
|
|
1848
1973
|
async function hashFinger(s) {
|
|
1849
1974
|
const data = new TextEncoder().encode(`emprivacy-rl:${s}`);
|
|
1850
1975
|
const dig = await crypto.subtle.digest("SHA-256", data);
|
|
1851
1976
|
return Array.from(new Uint8Array(dig)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
|
|
1852
1977
|
}
|
|
1853
|
-
async function allowRecordWrite(ctx
|
|
1854
|
-
const
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
await ctx.kv.set(key, String(n + 1));
|
|
1860
|
-
return true;
|
|
1861
|
-
} catch {
|
|
1862
|
-
return false;
|
|
1863
|
-
}
|
|
1978
|
+
async function allowRecordWrite(ctx) {
|
|
1979
|
+
const hour = rateHourKey();
|
|
1980
|
+
const ip = ctx.requestMeta?.ip?.trim() ?? "";
|
|
1981
|
+
const bucket = ip ? `ip:${await hashFinger(ip)}` : "untrusted";
|
|
1982
|
+
if (!await tryConsumeRateSlot(ctx.kv, `consent:rl:${hour}:global`, RECORD_GLOBAL_LIMIT)) return false;
|
|
1983
|
+
return tryConsumeRateSlot(ctx.kv, `consent:rl:${hour}:${bucket}`, RECORD_RATE_LIMIT);
|
|
1864
1984
|
}
|
|
1865
1985
|
function publicRuntime(cfg, ctx, page) {
|
|
1866
1986
|
const copy = resolvePublicCopy(cfg, page.locale);
|
|
@@ -1882,11 +2002,12 @@ function publicRuntime(cfg, ctx, page) {
|
|
|
1882
2002
|
theme: cfg.theme,
|
|
1883
2003
|
ui: copy.ui,
|
|
1884
2004
|
loader: buildAnalyticsLoader(cfg),
|
|
1885
|
-
marketingScripts: cfg.marketingScriptUrls.map((src) => ({
|
|
2005
|
+
marketingScripts: cfg.marketingScriptUrls.filter((src) => isPublishableCustomScript(src, cfg.scriptIntegrity[src], cfg.scriptHostAllowlist)).map((src) => ({
|
|
1886
2006
|
src,
|
|
1887
2007
|
integrity: cfg.scriptIntegrity[src] ?? null
|
|
1888
2008
|
})),
|
|
1889
2009
|
scriptHostAllowlist: cfg.scriptHostAllowlist,
|
|
2010
|
+
embedHostAllowlist: cfg.embedHostAllowlist,
|
|
1890
2011
|
scriptIntegrity: cfg.scriptIntegrity,
|
|
1891
2012
|
cookieMaxAge: cookieMaxAgeSeconds(cfg.cookieMaxAgeDays),
|
|
1892
2013
|
vendors: buildVendorList(cfg)
|
|
@@ -1999,6 +2120,7 @@ function createPlugin() {
|
|
|
1999
2120
|
},
|
|
2000
2121
|
routes: {
|
|
2001
2122
|
admin: { handler: async (ctx) => {
|
|
2123
|
+
if (!ctx.user) return new Response("unauthorized", { status: 401 });
|
|
2002
2124
|
const interaction = ctx.input;
|
|
2003
2125
|
if (interaction?.type === "page_load" && interaction.page === ADMIN_SETTINGS_PATH) return buildSettingsPage(ctx);
|
|
2004
2126
|
if (interaction?.type === "form_submit" && interaction.action_id === SAVE_ACTION_ID) {
|
|
@@ -2024,6 +2146,7 @@ function createPlugin() {
|
|
|
2024
2146
|
analyticsUrlsText: String(v.analytics_urls ?? ""),
|
|
2025
2147
|
marketingUrlsText: String(v.marketing_urls ?? ""),
|
|
2026
2148
|
scriptHostAllowlistText: String(v.script_host_allowlist ?? ""),
|
|
2149
|
+
embedHostAllowlistText: String(v.embed_host_allowlist ?? ""),
|
|
2027
2150
|
cookieMaxAgeDays: String(v.cookie_max_age_days ?? "180"),
|
|
2028
2151
|
googleConsentMode: asBool(v.google_cm),
|
|
2029
2152
|
logConsentToServer: asBool(v.log_server),
|
|
@@ -2081,7 +2204,7 @@ function createPlugin() {
|
|
|
2081
2204
|
};
|
|
2082
2205
|
const input = ctx.input;
|
|
2083
2206
|
if (input.policyVersion !== cfg.policyVersion) return new Response("policy_version_mismatch", { status: 400 });
|
|
2084
|
-
if (!await allowRecordWrite(ctx
|
|
2207
|
+
if (!await allowRecordWrite(ctx)) return new Response("rate_limited", { status: 429 });
|
|
2085
2208
|
const gpc = input.gpc === true;
|
|
2086
2209
|
const row = {
|
|
2087
2210
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -2144,7 +2267,7 @@ function createPlugin() {
|
|
|
2144
2267
|
}
|
|
2145
2268
|
async function buildSettingsPage(ctx) {
|
|
2146
2269
|
const [cfg, latestPublished] = await Promise.all([loadConfig(ctx), fetchLatestPublishedVersion({ fetch: ctx.http?.fetch ?? globalThis.fetch })]);
|
|
2147
|
-
const installedVersion = ctx.plugin?.version ?? "3.1
|
|
2270
|
+
const installedVersion = ctx.plugin?.version ?? "3.2.1";
|
|
2148
2271
|
const analyticsText = cfg.analyticsScriptUrls.map((src) => {
|
|
2149
2272
|
const i = cfg.scriptIntegrity[src];
|
|
2150
2273
|
return i ? `${src} ${i}` : src;
|
|
@@ -2365,7 +2488,7 @@ async function buildSettingsPage(ctx) {
|
|
|
2365
2488
|
{
|
|
2366
2489
|
type: "text_input",
|
|
2367
2490
|
action_id: "analytics_urls",
|
|
2368
|
-
label: "Custom analytics script URLs (one https URL per line
|
|
2491
|
+
label: "Custom analytics script URLs (one https URL per line, SRI required: … sha384-…)",
|
|
2369
2492
|
placeholder: "https://cdn.example/a.js sha384-…",
|
|
2370
2493
|
multiline: true,
|
|
2371
2494
|
initial_value: analyticsText,
|
|
@@ -2377,7 +2500,7 @@ async function buildSettingsPage(ctx) {
|
|
|
2377
2500
|
{
|
|
2378
2501
|
type: "text_input",
|
|
2379
2502
|
action_id: "marketing_urls",
|
|
2380
|
-
label: "Marketing script URLs (one https URL per line
|
|
2503
|
+
label: "Marketing script URLs (one https URL per line, SRI required: … sha384-…)",
|
|
2381
2504
|
placeholder: "https://… (one per line)",
|
|
2382
2505
|
multiline: true,
|
|
2383
2506
|
initial_value: marketingText
|
|
@@ -2385,11 +2508,19 @@ async function buildSettingsPage(ctx) {
|
|
|
2385
2508
|
{
|
|
2386
2509
|
type: "text_input",
|
|
2387
2510
|
action_id: "script_host_allowlist",
|
|
2388
|
-
label: "Script host allowlist (
|
|
2511
|
+
label: "Script host allowlist (required for Custom, Umami, and marketing URLs; one hostname per line)",
|
|
2389
2512
|
placeholder: "cdn.example.com\njs.stripe.com",
|
|
2390
2513
|
multiline: true,
|
|
2391
2514
|
initial_value: cfg.scriptHostAllowlist.join("\n")
|
|
2392
2515
|
},
|
|
2516
|
+
{
|
|
2517
|
+
type: "text_input",
|
|
2518
|
+
action_id: "embed_host_allowlist",
|
|
2519
|
+
label: "Embed link hosts (one hostname per line). Mastodon and link previews become links only when listed. X, Bluesky, Gist, YouTube, and Vimeo stay built in.",
|
|
2520
|
+
placeholder: "mastodon.social\nexample.com",
|
|
2521
|
+
multiline: true,
|
|
2522
|
+
initial_value: cfg.embedHostAllowlist.join("\n")
|
|
2523
|
+
},
|
|
2393
2524
|
{
|
|
2394
2525
|
type: "text_input",
|
|
2395
2526
|
action_id: "cookie_max_age_days",
|
|
@@ -2589,22 +2720,9 @@ const POSTER_HOSTS = /* @__PURE__ */ new Set([
|
|
|
2589
2720
|
"img.youtube.com",
|
|
2590
2721
|
"i.vimeocdn.com"
|
|
2591
2722
|
]);
|
|
2592
|
-
|
|
2593
|
-
return /[\u0000-\u001F\u007F\s]/.test(s);
|
|
2594
|
-
}
|
|
2723
|
+
/** Same non-public host rules as script URLs. */
|
|
2595
2724
|
function parseHttpsUrl(raw) {
|
|
2596
|
-
|
|
2597
|
-
if (!t || t.length > 2048 || hasUnsafeChars(t)) return null;
|
|
2598
|
-
if (/%0d|%0a|%09|%0b|%0c|%20/i.test(t)) return null;
|
|
2599
|
-
try {
|
|
2600
|
-
const u = new URL(t);
|
|
2601
|
-
if (u.protocol !== "https:") return null;
|
|
2602
|
-
if (u.username || u.password) return null;
|
|
2603
|
-
if (u.hostname === "localhost" || u.hostname.endsWith(".localhost")) return null;
|
|
2604
|
-
return u;
|
|
2605
|
-
} catch {
|
|
2606
|
-
return null;
|
|
2607
|
-
}
|
|
2725
|
+
return parseStrictHttpsUrl(raw);
|
|
2608
2726
|
}
|
|
2609
2727
|
function hostOf(u) {
|
|
2610
2728
|
return u.hostname.toLowerCase();
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -21,11 +21,11 @@ pnpm run build
|
|
|
21
21
|
|
|
22
22
|
## `pnpm audit` and the Kysely override
|
|
23
23
|
|
|
24
|
-
`pnpm run audit` runs **`pnpm audit --prod`**, so it reflects **published runtime** dependencies only (currently `zod` 4.6.
|
|
24
|
+
`pnpm run audit` runs **`pnpm audit --prod`**, so it reflects **published runtime** dependencies only (currently `zod` 4.6.7).
|
|
25
25
|
|
|
26
26
|
The full install (including the `emdash` **dev** tree) can report transitive advisories from EmDash’s tooling stack. Those are owned by the site’s EmDash upgrade path, not by EmPrivacy’s published tarball.
|
|
27
27
|
|
|
28
|
-
**This repository pins Kysely** via `pnpm.overrides` in `package.json` (currently `0.29.6
|
|
28
|
+
**This repository pins Kysely** via `pnpm.overrides` in `package.json` (currently `0.29.6`). EmDash 1.x declares Kysely as `^0.29.0`. Regenerate the lockfile with `pnpm install` after changing overrides.
|
|
29
29
|
|
|
30
30
|
## Deprecation warnings you may still see
|
|
31
31
|
|
package/docs/PLUGIN_SETTINGS.md
CHANGED
|
@@ -100,30 +100,30 @@ Scripts for **Analytics** presets load only after **Analytics** consent — exce
|
|
|
100
100
|
| Microsoft Clarity | Project ID, 7–20 letters or digits | `https://www.clarity.ms/tag/…` after **Analytics** consent. A denied `consentv2` signal is queued in `<head>` first. `ad_Storage` stays denied until Marketing is also allowed. |
|
|
101
101
|
| Microsoft UET | Tag ID, 6–12 digits | `https://bat.bing.com/bat.js` after **Marketing** consent. `ad_storage` defaults to denied in `<head>`. The tag is not injected before that consent. Automatic SPA tracking is off. |
|
|
102
102
|
| None | — | No analytics scripts |
|
|
103
|
-
| Custom | One https script `src` per line (
|
|
103
|
+
| Custom | One https script `src` per line, plus SRI (`sha256-`, `sha384-`, or `sha512-`) | Those URLs only, and only if the host is on the script host allowlist |
|
|
104
104
|
|
|
105
105
|
IDs and URLs are validated on save and again in the browser before inject. Tokens are never shown in the public vendor list.
|
|
106
106
|
|
|
107
|
-
### Script host allowlist
|
|
107
|
+
### Script host allowlist
|
|
108
108
|
|
|
109
|
-
One hostname per line (e.g. `cdn.example.com`).
|
|
109
|
+
One hostname per line (e.g. `cdn.example.com`). **Custom** analytics, **Umami**, and **Marketing** script hosts must appear on the list. An empty list does not load those scripts. Built-in preset CDNs (Plausible, Fathom, GTM, Clarity, UET, and so on) stay allowed for their presets.
|
|
110
110
|
|
|
111
|
-
###
|
|
111
|
+
### Subresource Integrity (SRI)
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
Custom and marketing lines require a hash after the URL:
|
|
114
114
|
|
|
115
115
|
```
|
|
116
116
|
https://cdn.example/a.js sha384-…
|
|
117
117
|
https://cdn.example/b.js integrity=sha256-…
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
The browser sets `integrity` + `crossorigin=anonymous`
|
|
120
|
+
The browser sets `integrity` + `crossorigin=anonymous`. A line without a `sha256-`, `sha384-`, or `sha512-` hash is rejected on save and is not injected. Preset CDNs are not pinned this way; many do not publish stable hashes. Umami needs a hostname on the allowlist and does not require SRI.
|
|
121
121
|
|
|
122
122
|
---
|
|
123
123
|
|
|
124
124
|
## Marketing scripts
|
|
125
125
|
|
|
126
|
-
One **https** URL per line,
|
|
126
|
+
One **https** URL per line, with SRI as above, and the host on the script host allowlist. Loaded after **Marketing** consent. Max 50 lines, 2048 characters each. No HTML.
|
|
127
127
|
|
|
128
128
|
---
|
|
129
129
|
|
|
@@ -141,7 +141,7 @@ Put EmPrivacy **first** in `plugins` if other plugins also inject Google tags.
|
|
|
141
141
|
|
|
142
142
|
When on (default), EmPrivacy’s Portable Text components replace YouTube, Vimeo, tweet, Bluesky, Mastodon, Gist, and link-preview blocks with a placeholder until the selected category is allowed.
|
|
143
143
|
|
|
144
|
-
YouTube / Vimeo become allowlisted iframes (`youtube-nocookie.com`, `player.vimeo.com`).
|
|
144
|
+
YouTube / Vimeo become allowlisted iframes (`youtube-nocookie.com`, `player.vimeo.com`). X, Bluesky, and GitHub Gist posts become **https links** when the URL matches those hosts. Mastodon and link previews become links only when the hostname is on **Embed link hosts**. An empty list leaves those placeholders in place. EmPrivacy will not iframe a Mastodon instance or an unknown host. A `data-emprivacy-embed` value in the page is not enough to allow an arbitrary URL.
|
|
145
145
|
|
|
146
146
|
This does **not** rewrite raw HTML `<iframe>` tags you paste in the theme or in an HTML block.
|
|
147
147
|
|
|
@@ -198,7 +198,9 @@ When Analytics or Marketing is denied (including the first visit, before a choic
|
|
|
198
198
|
|
|
199
199
|
## Server-side logging
|
|
200
200
|
|
|
201
|
-
When enabled, each save POSTs `{ policyVersion, functional, analytics, marketing, gpc }` to `/_emdash/api/plugins/emprivacy/record` (same origin, JSON). The handler requires a matching `Origin` **or** `Sec-Fetch-Site: same-origin`, fails closed if storage capacity cannot be checked, and rate-limits
|
|
201
|
+
When enabled, each save POSTs `{ policyVersion, functional, analytics, marketing, gpc }` to `/_emdash/api/plugins/emprivacy/record` (same origin, JSON). The handler requires a matching `Origin` **or** `Sec-Fetch-Site: same-origin`, fails closed if storage capacity cannot be checked, and rate-limits writes. The limit uses EmDash’s platform client address (`requestMeta.ip`), about 30 writes per address per UTC hour, plus about 60 writes per hour for the whole site. Requests with no platform address share one 30/hour bucket. Counters use compare-and-set. No IP is stored in the consent row. If `gpc` is true, the stored `marketing` flag is false. The page shows up to 15 recent rows. Logging stops at 500 rows.
|
|
202
|
+
|
|
203
|
+
Saving settings requires a signed-in admin. A settings request with no user is rejected.
|
|
202
204
|
|
|
203
205
|
Signed-in admins (the plugin settings permission) can download those rows as CSV from `/_emdash/api/plugins/emprivacy/consent-export`. The file is `Cache-Control: private, no-store`. It has no IP, user agent, or rate-limit fingerprint. A cell that starts with `=`, `+`, `-`, or `@` is prefixed with `'` so a spreadsheet does not run it as a formula. The route is not public. A request without a signed-in user is rejected.
|
|
204
206
|
|
|
@@ -211,7 +213,7 @@ EmPrivacy injects an **inline** bootstrap via EmDash `page:fragments`. Sites wit
|
|
|
211
213
|
- `script-src 'unsafe-inline'` (or a hash/nonce of the emitted bootstrap if your host supports it), and
|
|
212
214
|
- `script-src` / `connect-src` / `frame-src` entries for any presets and gated embeds you enable (e.g. `https://plausible.io`, `https://www.clarity.ms`, `https://bat.bing.com`, `https://www.youtube-nocookie.com`, `https://player.vimeo.com`).
|
|
213
215
|
|
|
214
|
-
YouTube/Vimeo placeholders use a tight iframe `sandbox` (`allow-scripts allow-same-origin allow-presentation`) after consent. Client hydration
|
|
216
|
+
YouTube/Vimeo placeholders use a tight iframe `sandbox` (`allow-scripts allow-same-origin allow-presentation`) after consent. The iframe `allow` list does not include clipboard access. Client hydration re-checks `data-src` against the same YouTube, Vimeo, X, Bluesky, and Gist rules, and against **Embed link hosts** for every other link. A planted URL cannot become an iframe or an off-list link.
|
|
215
217
|
|
|
216
218
|
A future hashed static bootstrap (no `'unsafe-inline'`) depends on EmDash fragment APIs exposing nonces or external script URLs.
|
|
217
219
|
|
package/docs/RELEASES.md
CHANGED
|
@@ -46,13 +46,16 @@ update to latest emdash release
|
|
|
46
46
|
|
|
47
47
|
Uses [`.cursor/skills/emdash-release/`](../.cursor/skills/emdash-release/SKILL.md) and [`.cursor/emdash-release.json`](../.cursor/emdash-release.json): conformance → compat PR → Version Packages merge → `pnpm release:publish` (npm + GitHub Release). Details: [maintainer-release.md](./maintainer-release.md).
|
|
48
48
|
|
|
49
|
-
###
|
|
49
|
+
### Feature, minor, or patch release (agent-driven)
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
2. Merge to `main`. GitHub Actions opens a **Version Packages** PR when changesets are pending.
|
|
53
|
-
3. Merge the Version Packages PR. CI runs `pnpm release:publish` and creates a GitHub Release.
|
|
51
|
+
In Cursor, ask to publish a minor (or patch) release. The agent follows [`.cursor/skills/feature-release/`](../.cursor/skills/feature-release/SKILL.md):
|
|
54
52
|
|
|
55
|
-
|
|
53
|
+
1. Commit the work plus **one** `.changeset/*.md` file. Leave `package.json` `"version"`, `src/version.ts`, and `CHANGELOG.md` unchanged. Write the maintainer notes in [RELEASE_NOTES.md](./RELEASE_NOTES.md) for the version Changesets will create.
|
|
54
|
+
2. Open a feature PR, squash-merge when its CI is green.
|
|
55
|
+
3. Merge the **Version Packages** PR as soon as it opens. Its own CI often sits at `action_required` (the Release workflow opened it with `GITHUB_TOKEN`). `pnpm release:publish` on `main` is the publish-time test gate.
|
|
56
|
+
4. Treat `+ @emplugins/emprivacy@<version>` in the Release log as a successful publish. The npm registry can 404 that version for several minutes while it scans the tarball. Poll `https://registry.npmjs.org/@emplugins/emprivacy/<version>` for up to 15 minutes. Do not publish again during that window.
|
|
57
|
+
|
|
58
|
+
Release publishes with npm trusted publishing from `release.yml`. Details: [maintainer-release.md](./maintainer-release.md).
|
|
56
59
|
|
|
57
60
|
### Local (fallback)
|
|
58
61
|
|
package/docs/RELEASE_NOTES.md
CHANGED
|
@@ -6,6 +6,28 @@
|
|
|
6
6
|
|
|
7
7
|
No unreleased changes.
|
|
8
8
|
|
|
9
|
+
# emprivacy v3.2.1
|
|
10
|
+
|
|
11
|
+
## Changes
|
|
12
|
+
|
|
13
|
+
- **EmDash 1.4.0** — CI-tested with EmDash `1.0.1` and `1.4.0`. Peer dependency stays `emdash@^1.0.1`. No plugin API change.
|
|
14
|
+
- **zod 4.6.7** — Runtime dependency updated from 4.6.5 so production audit clears GHSA-mrhx-c7xc-m3w5 (oversized constrained collections).
|
|
15
|
+
- **Policy links on EmDash 1.2+** — Root-relative privacy and cookie policy paths resolve with `ctx.url()`, which follows the configured site origin (site URL setting, then the address the site was set up on). A site that moves domain sees the new origin in those links after the server restarts.
|
|
16
|
+
- **Install hook** — EmDash 1.2+ runs `plugin:install` once for a plugin registered in `plugins: []`. EmPrivacy seeds default settings only when none are stored.
|
|
17
|
+
|
|
18
|
+
# emprivacy v3.2.0
|
|
19
|
+
|
|
20
|
+
## Changes
|
|
21
|
+
|
|
22
|
+
- **Script host allowlist** — Custom, Umami, and marketing script hosts must be listed. An empty list does not load those scripts. Built-in presets (Cloudflare, Plausible, Fathom, Simple Analytics, GA4, GTM, Clarity, UET) are unchanged. Sites that already saved custom or marketing URLs need each hostname on the list before those scripts load again.
|
|
23
|
+
- **Subresource Integrity** — Custom and marketing lines require a `sha256-`, `sha384-`, or `sha512-` hash. A line without one is rejected on save and is not injected. Umami needs an allowlisted host and does not require a hash.
|
|
24
|
+
- **Embed link hosts** — Mastodon and link previews become links only when the hostname is listed. YouTube, Vimeo, X, Bluesky, and GitHub Gist keep their built-in checks. A `data-emprivacy-embed` attribute on the page is not enough to allow an arbitrary URL.
|
|
25
|
+
- **Non-public hosts** — Loopback, private, link-local, and other non-public addresses are rejected for script and embed URLs.
|
|
26
|
+
- **Settings** — Opening or saving EmPrivacy settings requires a signed-in admin.
|
|
27
|
+
- **Consent log** — Anonymous writes use EmDash’s platform client address: about 30 per address per UTC hour, and about 60 per hour for the whole site. Requests with no platform address share one 30/hour bucket. No IP is stored in the consent row.
|
|
28
|
+
- **Embed iframes** — YouTube and Vimeo `sandbox` is unchanged. The iframe `allow` list no longer includes clipboard access.
|
|
29
|
+
- **Publish** — npm credentials are written to a temporary user config outside the repo. A project `.npmrc` is refused at publish time.
|
|
30
|
+
|
|
9
31
|
# emprivacy v3.1.0
|
|
10
32
|
|
|
11
33
|
## Changes
|
package/docs/TESTING.md
CHANGED
|
@@ -23,7 +23,7 @@ Use a **staging** EmDash Astro app on **`emdash@^1.0.1`** with `emprivacyPlugin(
|
|
|
23
23
|
1. **Banner** — Incognito: banner appears with no `emprivacy_cc` cookie, when **Policy / consent version** does not match `v`, or when a legacy cookie lacks `f`. **Banner position** Bottom keeps it on the bottom edge; Top pins it to the top. The reopen button stays bottom-left.
|
|
24
24
|
2. **Categories** — Customize shows Essential (locked on), Functional, Analytics, Marketing. Reject non-essential: no configured analytics/marketing scripts in Network; embeds stay placeholders.
|
|
25
25
|
3. **Re-open** — Cookie button (bottom-left). Save reloads. Turning a category off removes those scripts after reload and expires the known first-party cookie names for that category (`_ga` / `_ga_*` for Analytics; `_clck`, `_uetsid`, and the other fixed names for Marketing). `emprivacy_cc` remains.
|
|
26
|
-
4. **Presets** — For the platform you ship, confirm the expected host loads only after the correct category (Analytics for most presets, including Clarity; **Marketing** for GTM and UET). Custom
|
|
26
|
+
4. **Presets** — For the platform you ship, confirm the expected host loads only after the correct category (Analytics for most presets, including Clarity; **Marketing** for GTM and UET). Custom and marketing scripts load only with SRI and a script host allowlist entry. Umami loads only when its host is listed. Clarity and UET must not request their hosts before that consent. Mastodon and link previews stay placeholders until the host is on Embed link hosts.
|
|
27
27
|
5. **Evil embed plant** — In DevTools, add a placeholder with `data-src="https://evil.example/x"` and grant marketing; assert no iframe is created.
|
|
28
28
|
6. **Embed unlock** — “Allow embeds and load” opens preferences; it must not silently flip categories.
|
|
29
29
|
7. **Embeds** — With `@emdash-cms/plugin-embeds` and EmPrivacy last (or site-level `blockComponents`): a YouTube block is a placeholder first; after allowing the embed category, the iframe `src` is `https://www.youtube-nocookie.com/embed/…`. A junk/javascript URL never becomes an iframe.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "SPDX-License-Identifier: MIT",
|
|
3
3
|
"name": "@emplugins/emprivacy",
|
|
4
|
-
"version": "3.1
|
|
4
|
+
"version": "3.2.1",
|
|
5
5
|
"description": "Cookie consent, gated embeds, and privacy notices for EmDash (GDPR, CCPA/CIPA-oriented configuration)",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.mjs",
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
"emdash": "^1.0.1"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"zod": "4.6.
|
|
45
|
+
"zod": "4.6.7"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@changesets/cli": "^3.0.3",
|
|
49
|
-
"emdash": "^1.0
|
|
49
|
+
"emdash": "^1.4.0",
|
|
50
50
|
"tsdown": "^0.23.0",
|
|
51
51
|
"typescript": "^5.9.3",
|
|
52
52
|
"vitest": "^5.0.1"
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"pnpm": {
|
|
67
67
|
"overrides": {
|
|
68
68
|
"kysely": "0.29.6",
|
|
69
|
-
"zod": "4.6.
|
|
69
|
+
"zod": "4.6.7"
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
72
|
"scripts": {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
|
|
3
|
+
import { parseStrictHttpsUrl } from "../security.js";
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Validate official EmDash embed block payloads and produce a first-party
|
|
5
7
|
* placeholder plan. Never returns a URL we did not construct or allowlist.
|
|
@@ -43,23 +45,9 @@ const BSKY_HOSTS = new Set(["bsky.app", "www.bsky.app"]);
|
|
|
43
45
|
const GIST_HOSTS = new Set(["gist.github.com"]);
|
|
44
46
|
const POSTER_HOSTS = new Set(["i.ytimg.com", "img.youtube.com", "i.vimeocdn.com"]);
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
return /[\u0000-\u001F\u007F\s]/.test(s);
|
|
48
|
-
}
|
|
49
|
-
|
|
48
|
+
/** Same non-public host rules as script URLs. */
|
|
50
49
|
export function parseHttpsUrl(raw: string): URL | null {
|
|
51
|
-
|
|
52
|
-
if (!t || t.length > 2048 || hasUnsafeChars(t)) return null;
|
|
53
|
-
if (/%0d|%0a|%09|%0b|%0c|%20/i.test(t)) return null;
|
|
54
|
-
try {
|
|
55
|
-
const u = new URL(t);
|
|
56
|
-
if (u.protocol !== "https:") return null;
|
|
57
|
-
if (u.username || u.password) return null;
|
|
58
|
-
if (u.hostname === "localhost" || u.hostname.endsWith(".localhost")) return null;
|
|
59
|
-
return u;
|
|
60
|
-
} catch {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
50
|
+
return parseStrictHttpsUrl(raw);
|
|
63
51
|
}
|
|
64
52
|
|
|
65
53
|
function hostOf(u: URL): string {
|