@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 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 (+ optional SRI / host allowlist) |
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
- * When non-empty, Custom analytics and marketing script hosts must be on this list.
175
- * Preset CDN hosts remain allowed for built-in providers.
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
- const host = u.hostname.toLowerCase();
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 (!isHttpsUrl(umamiScriptUrl)) throw new Error("Umami requires the https script URL (Cloud or your self-hosted tracker).");
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 && !isHttpsUrl(umamiScriptUrl)) throw new Error(invalidScriptUrlError("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 (integrity && !isValidIntegrity(integrity)) throw new Error(`Invalid SRI for ${kind} script (expected sha256-|sha384-|sha512-…).`);
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 or clear the allowlist.`);
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 or clear the allowlist.");
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={"static.cloudflareinsights.com":1,"plausible.io":1,"cdn.usefathom.com":1,"scripts.simpleanalyticscdn.com":1,"www.googletagmanager.com":1,"googletagmanager.com":1,"www.clarity.ms":1,"clarity.ms":1,"bat.bing.com":1};
1019
- var YT_IFRAME=/^https:\\/\\/(?:www\\.)?youtube-nocookie\\.com\\/embed\\/[A-Za-z0-9_-]{11}\\/?$/;
1020
- var VIMEO_IFRAME=/^https:\\/\\/player\\.vimeo\\.com\\/video\\/\\d{6,12}\\/?$/;
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
- var h=u.hostname.toLowerCase();
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,kind){
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
- if(/^\\/@[^/]+\\/\\d+\\/?$/.test(u.pathname)||/^\\/users\\/[^/]+\\/statuses\\/\\d+\\/?$/.test(u.pathname))return true;
1152
- return kind==="linkPreview";
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(integrity&&/^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(integrity)){
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","accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share");
1401
+ f.setAttribute("allow",${JSON.stringify(EMBED_IFRAME_ALLOW)});
1278
1402
  f.setAttribute("allowfullscreen","");
1279
- f.setAttribute("sandbox","allow-scripts allow-same-origin allow-presentation");
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,kind){
1287
- if(!allowedLink(src,kind))return;
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")||"",kind);
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": return cfg.analyticsScriptUrls.length ? {
1645
- type: "custom",
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
- } : { type: "none" };
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.0";
1930
+ const VERSION = "3.2.1";
1802
1931
  //#endregion
1803
1932
  //#region src/runtime.ts
1804
- /** Soft cap for anonymous consent POSTs per client fingerprint per UTC hour. */
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, request) {
1854
- const key = `consent:rl:${rateHourKey()}:${await hashFinger(clientFingerKey(request))}`;
1855
- try {
1856
- const raw = await ctx.kv.get(key);
1857
- const n = raw && /^\d{1,6}$/.test(raw) ? Number(raw) : 0;
1858
- if (n >= RECORD_RATE_LIMIT) return false;
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, ctx.request)) return new Response("rate_limited", { status: 429 });
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.0";
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; optional trailing SRI: … sha384-…)",
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; optional trailing SRI: … sha384-…)",
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 (optional; one hostname per line). When set, Custom analytics and marketing URLs must match.",
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
- function hasUnsafeChars(s) {
2593
- return /[\u0000-\u001F\u007F\s]/.test(s);
2594
- }
2723
+ /** Same non-public host rules as script URLs. */
2595
2724
  function parseHttpsUrl(raw) {
2596
- const t = raw.trim();
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();
@@ -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.5).
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`, aligned with EmDash 1.0.1). Regenerate the lockfile with `pnpm install` after changing overrides.
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
 
@@ -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 (optional trailing SRI) | Those URLs only |
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 (optional)
107
+ ### Script host allowlist
108
108
 
109
- One hostname per line (e.g. `cdn.example.com`). When non-empty, **Custom** analytics, **Umami**, and **Marketing** script hosts must appear on the list. Built-in preset CDNs (Plausible, Fathom, GTM, Clarity, UET, and so on) stay allowed for their presets.
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
- ### Optional Subresource Integrity (SRI)
111
+ ### Subresource Integrity (SRI)
112
112
 
113
- Append a hash after the URL on Custom or Marketing lines:
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` when the hash is valid. Prefer pinning Custom scripts; many preset CDNs do not publish stable hashes.
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, `src` only (optional SRI as above). Loaded after **Marketing** consent. Max 50 lines, 2048 characters each. No HTML.
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`). Social posts, Gists, and link previews become **https links** — EmPrivacy will not iframe an arbitrary Mastodon or unknown host.
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 anonymous clients (~30/hour fingerprint). 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.
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 also re-checks allowlisted `data-src` values so a planted evil URL cannot become an iframe.
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
- ### Automated feature release (Changesets)
49
+ ### Feature, minor, or patch release (agent-driven)
50
50
 
51
- 1. On a feature PR, run `pnpm changeset` and commit the generated file under `.changeset/`.
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
- Requires **`NPM_TOKEN`** secret on the repo (publish rights for `@emplugins/emprivacy` on the [emplugins](https://www.npmjs.com/org/emplugins) org).
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
 
@@ -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/marketing scripts must respect any host allowlist. Clarity and UET must not request their hosts before that consent.
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.0",
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.5"
45
+ "zod": "4.6.7"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@changesets/cli": "^3.0.3",
49
- "emdash": "^1.0.1",
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.5"
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
- function hasUnsafeChars(s: string): boolean {
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
- const t = raw.trim();
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 {