@blamejs/core 0.8.52 → 0.8.58

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/index.js +8 -0
  3. package/lib/audit.js +4 -0
  4. package/lib/auth/fido-mds3.js +624 -0
  5. package/lib/auth/passkey.js +214 -2
  6. package/lib/auth-bot-challenge.js +1 -1
  7. package/lib/credential-hash.js +2 -2
  8. package/lib/db-collection.js +290 -0
  9. package/lib/db-query.js +245 -0
  10. package/lib/db.js +173 -67
  11. package/lib/framework-error.js +55 -0
  12. package/lib/guard-cidr.js +2 -1
  13. package/lib/guard-jwt.js +2 -2
  14. package/lib/guard-oauth.js +2 -2
  15. package/lib/http-client-cache.js +916 -0
  16. package/lib/http-client.js +242 -0
  17. package/lib/mail-arf.js +343 -0
  18. package/lib/mail-auth.js +265 -40
  19. package/lib/mail-bimi.js +948 -33
  20. package/lib/mail-bounce.js +386 -4
  21. package/lib/mail-mdn.js +424 -0
  22. package/lib/mail-unsubscribe.js +265 -25
  23. package/lib/mail.js +403 -21
  24. package/lib/middleware/bearer-auth.js +1 -1
  25. package/lib/middleware/clear-site-data.js +122 -0
  26. package/lib/middleware/dpop.js +1 -1
  27. package/lib/middleware/index.js +9 -0
  28. package/lib/middleware/nel.js +214 -0
  29. package/lib/middleware/security-headers.js +56 -4
  30. package/lib/middleware/speculation-rules.js +323 -0
  31. package/lib/mime-parse.js +198 -0
  32. package/lib/mtls-ca.js +15 -5
  33. package/lib/network-dns.js +890 -27
  34. package/lib/network-tls.js +745 -0
  35. package/lib/object-store/sigv4.js +54 -0
  36. package/lib/public-suffix.js +414 -0
  37. package/lib/safe-buffer.js +7 -0
  38. package/lib/safe-json.js +1 -1
  39. package/lib/static.js +120 -0
  40. package/lib/storage.js +11 -0
  41. package/lib/vendor/MANIFEST.json +33 -0
  42. package/lib/vendor/bimi-trust-anchors.pem +33 -0
  43. package/lib/vendor/public-suffix-list.dat +16376 -0
  44. package/package.json +1 -1
  45. package/sbom.cyclonedx.json +6 -6
@@ -673,6 +673,21 @@ function create(config) {
673
673
  );
674
674
  }
675
675
 
676
+ // S3 response-* override query parameters per AWS S3 GetObject docs:
677
+ // when present on a presigned GET, the named response headers are
678
+ // overridden by these values. The signing math is identical — the
679
+ // params just need to be in url.searchParams before canonicalRequest
680
+ // runs (canonicalQueryString sorts + URL-encodes them deterministically).
681
+ // Map operator-friendly camelCase to the wire-format query keys.
682
+ var RESPONSE_HEADER_QUERY_KEYS = {
683
+ contentDisposition: "response-content-disposition",
684
+ contentType: "response-content-type",
685
+ contentLanguage: "response-content-language",
686
+ contentEncoding: "response-content-encoding",
687
+ cacheControl: "response-cache-control",
688
+ expires: "response-expires",
689
+ };
690
+
676
691
  function _presign(method, opts) {
677
692
  opts = opts || {};
678
693
  if (!opts.key || typeof opts.key !== "string") {
@@ -691,6 +706,37 @@ function create(config) {
691
706
  " (7 days, SigV4 hard cap)", true);
692
707
  }
693
708
 
709
+ // Validate opts.responseHeaders shape — operators pass camelCase
710
+ // keys; refuse unknown keys at config-time so a typo surfaces at
711
+ // boot. Reject CR/LF/NUL in any value as defense in depth (the
712
+ // values flow into URL query params + the signed canonical request,
713
+ // and a CR/LF could smuggle into a downstream proxy log).
714
+ var responseHeaders = opts.responseHeaders;
715
+ if (responseHeaders !== undefined && responseHeaders !== null) {
716
+ if (typeof responseHeaders !== "object") {
717
+ throw _err("INVALID_RESPONSE_HEADERS",
718
+ "presigned URL: responseHeaders must be an object", true);
719
+ }
720
+ var rhKeys = Object.keys(responseHeaders);
721
+ for (var rhi = 0; rhi < rhKeys.length; rhi += 1) {
722
+ var rhk = rhKeys[rhi];
723
+ if (!Object.prototype.hasOwnProperty.call(RESPONSE_HEADER_QUERY_KEYS, rhk)) {
724
+ throw _err("INVALID_RESPONSE_HEADERS",
725
+ "presigned URL: responseHeaders.'" + rhk + "' is not recognised " +
726
+ "(allowed: " + Object.keys(RESPONSE_HEADER_QUERY_KEYS).join(", ") + ")", true);
727
+ }
728
+ var rhv = responseHeaders[rhk];
729
+ if (typeof rhv !== "string" || rhv.length === 0) {
730
+ throw _err("INVALID_RESPONSE_HEADERS",
731
+ "presigned URL: responseHeaders.'" + rhk + "' must be a non-empty string", true);
732
+ }
733
+ if (/[\r\n\0]/.test(rhv)) {
734
+ throw _err("INVALID_RESPONSE_HEADERS",
735
+ "presigned URL: responseHeaders.'" + rhk + "' contains CR/LF/NUL — refused as a header-injection vector", true);
736
+ }
737
+ }
738
+ }
739
+
694
740
  var url = _keyToUrl(opts.key);
695
741
  var date = opts.date || new Date();
696
742
  var amzDate = _formatAmzDate(date);
@@ -721,6 +767,14 @@ function create(config) {
721
767
  if (config.sessionToken) {
722
768
  url.searchParams.set("X-Amz-Security-Token", config.sessionToken);
723
769
  }
770
+ // Response-header overrides — set BEFORE canonicalRequest so they
771
+ // become part of the signed query string.
772
+ if (responseHeaders) {
773
+ for (var rhk2 = 0; rhk2 < rhKeys.length; rhk2 += 1) {
774
+ var camel = rhKeys[rhk2];
775
+ url.searchParams.set(RESPONSE_HEADER_QUERY_KEYS[camel], responseHeaders[camel]);
776
+ }
777
+ }
724
778
 
725
779
  // Payload hash for query-string presigning is the literal string
726
780
  // "UNSIGNED-PAYLOAD" — the body is not part of the signature.
@@ -0,0 +1,414 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.publicSuffix
4
+ * @nav Validation
5
+ * @title Public Suffix
6
+ * @order 140
7
+ * @card Mozilla Public Suffix List substrate — exposes
8
+ * `b.publicSuffix.publicSuffix(domain)` /
9
+ * `b.publicSuffix.organizationalDomain(domain)` /
10
+ * `b.publicSuffix.isPublicSuffix(domain)` for the
11
+ * "registrable domain" derivation that DMARCbis,
12
+ * BIMI, cookie-scope, and same-site policies all need.
13
+ *
14
+ * @intro
15
+ * The Public Suffix List (PSL) is Mozilla's published catalog of
16
+ * "effective top-level domains" — labels under which independent
17
+ * parties can register names (`com`, `co.uk`, `s3.amazonaws.com`,
18
+ * …). It is the canonical reference for deriving the
19
+ * "organizational domain" of a hostname: the registrable label one
20
+ * level below its public suffix. Several upstream specs lean on it
21
+ * directly:
22
+ *
23
+ * - DMARCbis (IETF DMARC WG) replaces RFC 7489's heuristic
24
+ * organizational-domain derivation with a PSL lookup, including
25
+ * new `psd=` (public-suffix-domain policy) and `np=`
26
+ * (non-public-suffix policy) tags
27
+ * - BIMI (RFC 9669 + draft) uses the same organizational-domain
28
+ * logic to scope brand indicators
29
+ * - Same-site cookie scoping (RFC 6265bis) refers to the PSL when
30
+ * deciding whether `Domain=co.uk` is a "public suffix" attempt
31
+ *
32
+ * This module ships the PSL as a vendored data file
33
+ * (`lib/vendor/public-suffix-list.dat`) and parses it once at
34
+ * module-load. The algorithm is the canonical one published at
35
+ * https://publicsuffix.org/list/ (exact > exception > wildcard).
36
+ *
37
+ * Surface:
38
+ *
39
+ * b.publicSuffix.publicSuffix("example.co.uk")
40
+ * // → "co.uk"
41
+ *
42
+ * b.publicSuffix.organizationalDomain("foo.bar.example.co.uk")
43
+ * // → "example.co.uk"
44
+ *
45
+ * b.publicSuffix.isPublicSuffix("co.uk")
46
+ * // → true
47
+ *
48
+ * b.publicSuffix.lookupSource()
49
+ * // → { vendoredAt: "2026-05-09", entries: <n>, sha256: "..." }
50
+ *
51
+ * IDN inputs are punycode-normalized via Node's `url.domainToASCII`
52
+ * before lookup. Bad inputs throw `PublicSuffixError`.
53
+ */
54
+
55
+ var fs = require("node:fs");
56
+ var nodePath = require("node:path");
57
+ var nodeCrypto = require("node:crypto");
58
+ var nodeUrl = require("node:url");
59
+ var { PublicSuffixError } = require("./framework-error");
60
+
61
+ // Vendored PSL data file. Per the framework's vendoring policy this
62
+ // is checked in alongside the bundled npm-deps and tracked in
63
+ // lib/vendor/MANIFEST.json. Loaded synchronously at module-init —
64
+ // missing file is a packaging break operators must catch at boot,
65
+ // not on first lookup at request time.
66
+ var PSL_PATH = nodePath.join(__dirname, "vendor", "public-suffix-list.dat");
67
+
68
+ function _err(code, message) {
69
+ return new PublicSuffixError(code, message);
70
+ }
71
+
72
+ // _normalizeInput — lowercase + IDN-normalize a candidate domain.
73
+ // Returns a plain ASCII (punycode) string with no leading/trailing
74
+ // dots and no empty labels. Throws PublicSuffixError on bad shape so
75
+ // callers see a single error class for every reject path.
76
+ function _normalizeInput(domain) {
77
+ if (typeof domain !== "string") {
78
+ throw _err("public-suffix/invalid-domain",
79
+ "publicSuffix: domain must be a string");
80
+ }
81
+ if (domain.length === 0) {
82
+ throw _err("public-suffix/invalid-domain",
83
+ "publicSuffix: domain must not be empty");
84
+ }
85
+ if (domain.length > 253) {
86
+ // RFC 1035 §2.3.4 — 253 octets max for the wire form (255 minus
87
+ // length-byte + null). Anything longer is structurally invalid.
88
+ throw _err("public-suffix/invalid-domain",
89
+ "publicSuffix: domain exceeds 253-octet RFC 1035 limit");
90
+ }
91
+ // Strip a single trailing dot (FQDN form). Multiple trailing dots,
92
+ // leading dots, or embedded empty labels remain rejected below.
93
+ var s = domain.toLowerCase();
94
+ if (s.charCodeAt(s.length - 1) === 46 /* "." */) {
95
+ s = s.slice(0, -1);
96
+ if (s.length === 0) {
97
+ throw _err("public-suffix/invalid-domain",
98
+ "publicSuffix: domain must not be a bare dot");
99
+ }
100
+ }
101
+ // Reject control / null / whitespace bytes outright. domainToASCII
102
+ // would silently rewrite some of them; we want hostile inputs to
103
+ // throw, not be coerced.
104
+ for (var i = 0; i < s.length; i += 1) {
105
+ var cp = s.charCodeAt(i);
106
+ if (cp < 0x21 || cp === 0x7f) {
107
+ throw _err("public-suffix/invalid-domain",
108
+ "publicSuffix: domain contains control / whitespace byte");
109
+ }
110
+ }
111
+ // IDN-normalize — non-ASCII labels become xn--… via Node's UTS #46
112
+ // implementation. Empty string back means the input was malformed
113
+ // beyond what UTS #46 will accept (e.g. starts with U+FFFD).
114
+ var ascii = nodeUrl.domainToASCII(s);
115
+ if (!ascii) {
116
+ throw _err("public-suffix/invalid-domain",
117
+ "publicSuffix: domain failed IDN normalization");
118
+ }
119
+ // No empty labels (`foo..bar`) and no leading dot.
120
+ if (ascii.indexOf("..") !== -1 || ascii.charCodeAt(0) === 46) {
121
+ throw _err("public-suffix/invalid-domain",
122
+ "publicSuffix: domain contains empty label");
123
+ }
124
+ return ascii;
125
+ }
126
+
127
+ // _parsePsl — walk the vendored .dat file once at load and produce
128
+ // the lookup tables. The .dat format is:
129
+ // - blank lines: skip
130
+ // - lines starting with "//": comment / section marker
131
+ // - "*.suffix": wildcard rule (matches one extra label)
132
+ // - "!suffix": exception rule (suppresses a parent wildcard)
133
+ // - "suffix": exact rule
134
+ //
135
+ // Non-ASCII rule labels are punycode-encoded so they match
136
+ // IDN-normalized input directly. The original PSL file already
137
+ // contains them in punycode form; we still canonicalize defensively
138
+ // in case a future revision changes shape.
139
+ function _parsePsl(text) {
140
+ var exact = Object.create(null); // suffix -> true
141
+ var wildcard = Object.create(null); // parent -> true (e.g. "ck" for "*.ck")
142
+ var exception = Object.create(null); // suffix -> true (full e.g. "www.ck")
143
+ var lines = text.split(/\r?\n/);
144
+ var entries = 0;
145
+
146
+ for (var i = 0; i < lines.length; i += 1) {
147
+ var line = lines[i];
148
+ if (!line) continue;
149
+ // A space within a line is the start of an inline comment /
150
+ // metadata note (Mozilla's convention); take the leading token.
151
+ var sp = line.indexOf(" ");
152
+ if (sp !== -1) line = line.slice(0, sp);
153
+ if (!line) continue;
154
+ if (line.charCodeAt(0) === 47 /* "/" */ &&
155
+ line.charCodeAt(1) === 47) continue;
156
+
157
+ var rule = line.toLowerCase();
158
+ // IDN-normalize each rule. domainToASCII returns "" on failure;
159
+ // we skip rather than throw — the PSL is curated and any
160
+ // failure here means a future format change rather than hostile
161
+ // input from a caller.
162
+ var asciiRule = nodeUrl.domainToASCII(rule);
163
+ if (!asciiRule) continue;
164
+
165
+ if (asciiRule.charCodeAt(0) === 33 /* "!" */) {
166
+ exception[asciiRule.slice(1)] = true;
167
+ } else if (asciiRule.charCodeAt(0) === 42 /* "*" */ &&
168
+ asciiRule.charCodeAt(1) === 46 /* "." */) {
169
+ wildcard[asciiRule.slice(2)] = true;
170
+ } else {
171
+ exact[asciiRule] = true;
172
+ }
173
+ entries += 1;
174
+ }
175
+
176
+ return { exact: exact, wildcard: wildcard, exception: exception, entries: entries };
177
+ }
178
+
179
+ // Initialize once at module load. Operators with a missing or
180
+ // unreadable vendored file see a clear startup-time failure ("config-
181
+ // time" tier — throw rather than silently fall back to a permissive
182
+ // default that would let phishing-shaped hosts past).
183
+ var _data;
184
+ var _sourceMeta;
185
+ (function _init() {
186
+ var raw;
187
+ try {
188
+ raw = fs.readFileSync(PSL_PATH);
189
+ } catch (e) {
190
+ throw _err("public-suffix/not-loaded",
191
+ "publicSuffix: vendored PSL data file missing at " + PSL_PATH +
192
+ " (" + (e && e.message ? e.message : "unknown error") + ")");
193
+ }
194
+ var sha256 = nodeCrypto.createHash("sha256").update(raw).digest("hex");
195
+ var parsed = _parsePsl(raw.toString("utf8"));
196
+ _data = parsed;
197
+ // Read vendoredAt from MANIFEST.json so the metadata stays in lock-
198
+ // step with the vendor refresh. Falls back to "unknown" if the
199
+ // manifest can't be read or doesn't carry the entry — the parsed
200
+ // sha256 still uniquely identifies the file content.
201
+ var vendoredAt = "unknown";
202
+ try {
203
+ var manifestPath = nodePath.join(__dirname, "vendor", "MANIFEST.json");
204
+ var manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); // allow:bare-json-parse — MANIFEST.json is a framework-internal vendored file checked in alongside code; never from operator / network input
205
+ var entry = manifest && manifest.packages && manifest.packages["publicsuffix-list"];
206
+ if (entry && typeof entry.bundledAt === "string") vendoredAt = entry.bundledAt;
207
+ } catch (_e) {
208
+ // Manifest missing / malformed — continue with vendoredAt =
209
+ // "unknown". This is observability-only metadata; a request-path
210
+ // failure here would punish operators for a non-fatal drift.
211
+ }
212
+ _sourceMeta = Object.freeze({
213
+ vendoredAt: vendoredAt,
214
+ entries: parsed.entries,
215
+ sha256: sha256,
216
+ });
217
+ })();
218
+
219
+ // _lookupAscii — core algorithm against the parsed tables. Operates
220
+ // on a normalized ASCII domain. Returns the longest matching public
221
+ // suffix, or null if no rule matches and the implicit-* rule produces
222
+ // a shorter result than the input (which only happens for single-
223
+ // label inputs — those have no public suffix per the algorithm).
224
+ function _lookupAscii(ascii) {
225
+ var labels = ascii.split(".");
226
+
227
+ // Walk longest-to-shortest. Per Mozilla's algorithm:
228
+ // 1. If an exception rule "!a.b.c" matches the input, the public
229
+ // suffix is the parent of the matched rule (one label dropped).
230
+ // 2. Else if an exact rule matches, that's the suffix.
231
+ // 3. Else if a wildcard rule "*.b.c" matches (input ends in
232
+ // ".b.c" with at least one extra label), the suffix is one
233
+ // label deeper than the wildcard's parent.
234
+ // 4. Else the implicit "*" rule applies: suffix = the rightmost
235
+ // label.
236
+ //
237
+ // Exception > exact > wildcard. We collect candidates per rule
238
+ // type and pick the precedence order at the end.
239
+ var exceptionMatch = null;
240
+ var exactMatch = null;
241
+ var wildcardMatch = null;
242
+
243
+ for (var i = 0; i < labels.length; i += 1) {
244
+ var candidate = labels.slice(i).join(".");
245
+ if (_data.exception[candidate]) {
246
+ // Exception rule's "public suffix" is the candidate with its
247
+ // leftmost label removed (the rule overrides a parent wildcard
248
+ // by saying "this exact name is registrable, suffix is below").
249
+ var parentLabels = labels.slice(i + 1);
250
+ if (parentLabels.length > 0) {
251
+ exceptionMatch = parentLabels.join(".");
252
+ } else {
253
+ exceptionMatch = "";
254
+ }
255
+ break;
256
+ }
257
+ if (!exactMatch && _data.exact[candidate]) {
258
+ exactMatch = candidate;
259
+ }
260
+ if (!wildcardMatch && i > 0) {
261
+ // For "*.b.c" to match input "a.b.c": the wildcard rule keys
262
+ // off the parent ("b.c"). We're at label-index i; the parent
263
+ // suffix is labels[i..]. The wildcard table indexes by parent,
264
+ // so a hit at "b.c" means input "a.b.c" matches the rule
265
+ // "*.b.c", and the public suffix is labels[i-1..] (one extra
266
+ // label included).
267
+ if (_data.wildcard[candidate]) {
268
+ wildcardMatch = labels.slice(i - 1).join(".");
269
+ }
270
+ }
271
+ }
272
+
273
+ if (exceptionMatch !== null) return exceptionMatch === "" ? null : exceptionMatch;
274
+ if (exactMatch !== null) return exactMatch;
275
+ if (wildcardMatch !== null) return wildcardMatch;
276
+ // Implicit "*" rule — every TLD is its own public suffix even when
277
+ // the PSL doesn't list it. For a multi-label input, the suffix is
278
+ // the rightmost label. For a single-label input, there is no
279
+ // registrable parent (the input IS a TLD), return null so callers
280
+ // distinguish "is a public suffix" from "has a public suffix".
281
+ if (labels.length >= 2) return labels[labels.length - 1];
282
+ return null;
283
+ }
284
+
285
+ /**
286
+ * @primitive b.publicSuffix.publicSuffix
287
+ * @signature b.publicSuffix.publicSuffix(domain)
288
+ * @since 0.8.53
289
+ * @status stable
290
+ * @related b.publicSuffix.organizationalDomain, b.publicSuffix.isPublicSuffix
291
+ *
292
+ * Returns the longest matching public suffix for `domain`, per the
293
+ * Mozilla PSL algorithm (https://publicsuffix.org/list/). Exception
294
+ * rules outrank exact rules, exact rules outrank wildcards, wildcards
295
+ * outrank the implicit "*" rule. Input is lowercased and IDN-
296
+ * normalized (punycode) before lookup. Returns `null` for inputs that
297
+ * have no registrable parent (single-label TLDs, public-suffix-only
298
+ * inputs).
299
+ *
300
+ * Throws `PublicSuffixError` (`public-suffix/invalid-domain`) for
301
+ * non-string / empty / overlong / control-byte-bearing inputs.
302
+ *
303
+ * @example
304
+ * var b = require("@blamejs/core");
305
+ * b.publicSuffix.publicSuffix("example.co.uk");
306
+ * // → "co.uk"
307
+ * b.publicSuffix.publicSuffix("foo.bar.example.com");
308
+ * // → "com"
309
+ */
310
+ function publicSuffix(domain) {
311
+ var ascii = _normalizeInput(domain);
312
+ return _lookupAscii(ascii);
313
+ }
314
+
315
+ /**
316
+ * @primitive b.publicSuffix.organizationalDomain
317
+ * @signature b.publicSuffix.organizationalDomain(domain)
318
+ * @since 0.8.53
319
+ * @status stable
320
+ * @related b.publicSuffix.publicSuffix, b.publicSuffix.isPublicSuffix
321
+ *
322
+ * Returns the registrable "organizational domain" — the public
323
+ * suffix plus exactly one label to its left. This is the value
324
+ * DMARCbis, BIMI, and cookie-scope policies operate on when they
325
+ * decide whether two hostnames belong to the same registered party.
326
+ *
327
+ * Returns `null` when `domain` IS a public suffix (no organizational
328
+ * parent exists — `co.uk` has no registrable owner, only the labels
329
+ * registered under it do).
330
+ *
331
+ * Throws `PublicSuffixError` (`public-suffix/invalid-domain`) on bad
332
+ * input shape.
333
+ *
334
+ * @example
335
+ * var b = require("@blamejs/core");
336
+ * b.publicSuffix.organizationalDomain("foo.bar.example.co.uk");
337
+ * // → "example.co.uk"
338
+ * b.publicSuffix.organizationalDomain("example.com");
339
+ * // → "example.com"
340
+ * b.publicSuffix.organizationalDomain("co.uk");
341
+ * // → null
342
+ */
343
+ function organizationalDomain(domain) {
344
+ var ascii = _normalizeInput(domain);
345
+ var suffix = _lookupAscii(ascii);
346
+ if (suffix === null) return null;
347
+ if (suffix === ascii) return null; // input IS a public suffix
348
+ // Walk back one label from the suffix. ascii ends in "." + suffix
349
+ // by construction (exact / wildcard / implicit-* all guarantee it).
350
+ var suffixLabels = suffix.split(".").length;
351
+ var labels = ascii.split(".");
352
+ if (labels.length <= suffixLabels) return null;
353
+ return labels.slice(labels.length - suffixLabels - 1).join(".");
354
+ }
355
+
356
+ /**
357
+ * @primitive b.publicSuffix.isPublicSuffix
358
+ * @signature b.publicSuffix.isPublicSuffix(domain)
359
+ * @since 0.8.53
360
+ * @status stable
361
+ * @related b.publicSuffix.publicSuffix, b.publicSuffix.organizationalDomain
362
+ *
363
+ * Returns `true` when `domain` is itself a public suffix (e.g.
364
+ * `"co.uk"`, `"com"`, `"s3.amazonaws.com"`), `false` otherwise.
365
+ * DMARCbis uses this distinction for its `psd=` (public-suffix-
366
+ * domain) policy: a TLD operator publishing a record on `co.uk`
367
+ * itself is a different actor than `example.co.uk` publishing one.
368
+ *
369
+ * Throws `PublicSuffixError` (`public-suffix/invalid-domain`) on bad
370
+ * input shape.
371
+ *
372
+ * @example
373
+ * var b = require("@blamejs/core");
374
+ * b.publicSuffix.isPublicSuffix("co.uk");
375
+ * // → true
376
+ * b.publicSuffix.isPublicSuffix("example.co.uk");
377
+ * // → false
378
+ */
379
+ function isPublicSuffix(domain) {
380
+ var ascii = _normalizeInput(domain);
381
+ var suffix = _lookupAscii(ascii);
382
+ return suffix !== null && suffix === ascii;
383
+ }
384
+
385
+ /**
386
+ * @primitive b.publicSuffix.lookupSource
387
+ * @signature b.publicSuffix.lookupSource()
388
+ * @since 0.8.53
389
+ * @status stable
390
+ * @related b.publicSuffix.publicSuffix
391
+ *
392
+ * Returns transparency metadata for the loaded PSL: the date the
393
+ * file was vendored (`vendoredAt`, ISO 8601 from
394
+ * `lib/vendor/MANIFEST.json`), the parsed-rule count (`entries`),
395
+ * and the SHA-256 hash of the raw file contents (`sha256`, hex). Use
396
+ * to surface in operator dashboards / forensic logs so a snapshot of
397
+ * the PSL the framework was making decisions against is reproducible
398
+ * after the fact.
399
+ *
400
+ * @example
401
+ * var b = require("@blamejs/core");
402
+ * var src = b.publicSuffix.lookupSource();
403
+ * // → { vendoredAt: "2026-05-09", entries: 9000, sha256: "a008..." }
404
+ */
405
+ function lookupSource() {
406
+ return _sourceMeta;
407
+ }
408
+
409
+ module.exports = {
410
+ publicSuffix: publicSuffix,
411
+ organizationalDomain: organizationalDomain,
412
+ isPublicSuffix: isPublicSuffix,
413
+ lookupSource: lookupSource,
414
+ };
@@ -379,6 +379,12 @@ var BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
379
379
  var TRACE_ID_HEX_RE = /^[0-9a-f]{32}$/; // allow:regex-no-length-cap — fixed 32 hex chars (W3C §3.2.2.3)
380
380
  var SPAN_ID_HEX_RE = /^[0-9a-f]{16}$/; // allow:regex-no-length-cap — fixed 16 hex chars (W3C §3.2.2.4)
381
381
 
382
+ // IPv6 hextet predicate — 1..4 hex characters (case-insensitive).
383
+ // Used by every IPv6 string-to-bytes parser that splits on `:` and
384
+ // validates each group. Extracted from guard-cidr / safe-json /
385
+ // network-tls so the shape lives in one place.
386
+ var IPV6_HEXTET_RE = /^[0-9a-fA-F]{1,4}$/; // allow:regex-no-length-cap — RFC 4291 §2.2 hextet width
387
+
382
388
  // RFC 7230 §3.2.6 / RFC 9110 §5.1 `tchar` grammar — used by HTTP
383
389
  // header tokens, MIME parameter names, W3C Baggage keys, etc.
384
390
  // Length-agnostic; callers cap per protocol.
@@ -546,6 +552,7 @@ module.exports = {
546
552
  stripTrailingHspace: stripTrailingHspace,
547
553
  HEX_RE: HEX_RE,
548
554
  BASE64URL_RE: BASE64URL_RE,
555
+ IPV6_HEXTET_RE: IPV6_HEXTET_RE,
549
556
  TRACE_ID_HEX_RE: TRACE_ID_HEX_RE,
550
557
  SPAN_ID_HEX_RE: SPAN_ID_HEX_RE,
551
558
  RFC7230_TCHAR_RE: RFC7230_TCHAR_RE,
package/lib/safe-json.js CHANGED
@@ -564,7 +564,7 @@ var formats = {
564
564
 
565
565
  var all = leftParts.concat(rightParts);
566
566
  for (var i = 0; i < all.length; i++) {
567
- if (!/^[0-9a-fA-F]{1,4}$/.test(all[i])) return false;
567
+ if (!safeBuffer.IPV6_HEXTET_RE.test(all[i])) return false;
568
568
  }
569
569
  return true;
570
570
  },
package/lib/static.js CHANGED
@@ -119,6 +119,20 @@ var DEFAULTS = Object.freeze({
119
119
  // serve event is the audit-worthy act, not a precursor.
120
120
  auditSuccess: true,
121
121
  auditFailures: true,
122
+ // forceAttachmentForNonText — stored-XSS defense for user-upload
123
+ // directories. Default OFF because operator-curated asset dirs
124
+ // (CSS / JS bundles / fonts) need inline render. Opt in for
125
+ // user-upload-backed mounts so HTML / JS / SVG without sanitizer
126
+ // / PDF / archives are forced to download. See
127
+ // `_shouldForceAttachment` below for the safe-render allowlist.
128
+ forceAttachmentForNonText: false,
129
+ // Companion knobs — when forceAttachmentForNonText is on, allow
130
+ // image/svg+xml inline render IF an SVG sanitizer gate is wired
131
+ // (default true; the framework's default-on contentSafety wiring
132
+ // includes b.guardSvg). PDF inline render defaults OFF — operators
133
+ // who serve a trusted PDF library opt in explicitly.
134
+ safeRenderSvg: true,
135
+ safeRenderPdf: false,
122
136
  });
123
137
 
124
138
  // Module-level metadata cache. Entries hold:
@@ -240,6 +254,87 @@ function _isRiskyInlineMime(contentType) {
240
254
  return RISKY_INLINE_MIMES[bare] === true;
241
255
  }
242
256
 
257
+ // Safe-render allowlist for `forceAttachmentForNonText`. When the
258
+ // operator opts in, every served file whose Content-Type is NOT
259
+ // `text/*` AND NOT in this allowlist is forced to download via
260
+ // `Content-Disposition: attachment` plus `X-Content-Type-Options:
261
+ // nosniff`. The list is intentionally narrow:
262
+ //
263
+ // - image/png / jpeg / webp / gif: raster formats — browsers can't
264
+ // interpret as scripts, no inline-execution surface.
265
+ // - image/svg+xml: ONLY when an SVG-sanitizer is wired via
266
+ // `contentSafety` (the default-on `b.guardSvg` covers this) —
267
+ // SVG is XML and can carry `<script>` / event handlers; we
268
+ // refuse to render it inline without sanitization.
269
+ // - application/pdf: ONLY when `safeRenderPdf: true` is explicitly
270
+ // set. PDFs commonly carry JavaScript and can bypass the SOP via
271
+ // embedded forms; default is to force download.
272
+ var SAFE_RENDER_RASTER_MIMES = {
273
+ "image/png": true,
274
+ "image/jpeg": true,
275
+ "image/webp": true,
276
+ "image/gif": true,
277
+ };
278
+
279
+ function _bareMime(contentType) {
280
+ if (typeof contentType !== "string" || contentType.length === 0) return "";
281
+ var semi = contentType.indexOf(";");
282
+ return (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase();
283
+ }
284
+
285
+ // _shouldForceAttachment — decide whether the operator's opt-in policy
286
+ // forces this content type to download. Returns true when the
287
+ // response should carry `Content-Disposition: attachment` +
288
+ // `X-Content-Type-Options: nosniff`.
289
+ //
290
+ // Allowlist intent: text/plain / text/css / text/markdown render
291
+ // inline (no execution surface), raster images render inline (no
292
+ // execution surface), SVG renders inline ONLY when an SVG sanitizer
293
+ // gate is wired AND `safeRenderSvg` is enabled, PDF renders inline
294
+ // ONLY when `safeRenderPdf` is explicitly enabled. text/html and
295
+ // text/javascript are inside `text/*` but the browser executes them
296
+ // — they go through the risky path. Everything else (HTML, JS, MJS,
297
+ // XML, executables, archives, fonts when served from a user-upload
298
+ // directory) gets forced download to defeat stored-XSS via the
299
+ // upload directory.
300
+ function _shouldForceAttachment(contentType, ext, contentSafetyMap, allowSvgRender, allowPdfRender) {
301
+ var bare = _bareMime(contentType);
302
+ if (bare.length === 0) return true; // unknown MIME → safest path
303
+ // text/html / text/xml / text/javascript / xhtml are inside `text/*`
304
+ // but the browser executes them — risky path.
305
+ if (bare === "text/html" || bare === "text/xml" ||
306
+ bare === "text/javascript" || bare === "application/xhtml+xml") {
307
+ return true;
308
+ }
309
+ if (bare.indexOf("text/") === 0) return false;
310
+ if (SAFE_RENDER_RASTER_MIMES[bare]) return false;
311
+ if (bare === "image/svg+xml") {
312
+ if (!allowSvgRender) return true;
313
+ if (!contentSafetyMap || typeof contentSafetyMap !== "object") return true;
314
+ var svgGate = contentSafetyMap[".svg"];
315
+ if (!svgGate || typeof svgGate.check !== "function") return true;
316
+ return false;
317
+ }
318
+ if (bare === "application/pdf") {
319
+ return !allowPdfRender;
320
+ }
321
+ // Defense-in-depth: files served with .html / .htm / .xhtml / .js /
322
+ // .mjs / .svg / .xml / .pdf extensions but a generic
323
+ // application/octet-stream MIME still get forced download. The
324
+ // extension check catches misconfigured tables / sniffed-down MIMEs.
325
+ if (ext === ".html" || ext === ".htm" || ext === ".xhtml" ||
326
+ ext === ".js" || ext === ".mjs" || ext === ".svg" ||
327
+ ext === ".xml" || ext === ".pdf") {
328
+ if (ext === ".svg" && allowSvgRender) {
329
+ if (contentSafetyMap && contentSafetyMap[".svg"] &&
330
+ typeof contentSafetyMap[".svg"].check === "function") return false;
331
+ }
332
+ if (ext === ".pdf" && allowPdfRender) return false;
333
+ return true;
334
+ }
335
+ return true;
336
+ }
337
+
243
338
  // Build a safe Content-Disposition value for an attachment. The
244
339
  // filename is RFC 5987-encoded so non-ASCII characters survive without
245
340
  // allowing CR/LF header injection.
@@ -379,6 +474,12 @@ function _validateCreateOpts(opts) {
379
474
  validateOpts.optionalBoolean(opts.auditFailures, "staticServe.create: auditFailures", StaticServeError);
380
475
  validateOpts.optionalBoolean(opts.safeAttachmentForRiskyMimes,
381
476
  "staticServe.create: safeAttachmentForRiskyMimes", StaticServeError);
477
+ validateOpts.optionalBoolean(opts.forceAttachmentForNonText,
478
+ "staticServe.create: forceAttachmentForNonText", StaticServeError);
479
+ validateOpts.optionalBoolean(opts.safeRenderSvg,
480
+ "staticServe.create: safeRenderSvg", StaticServeError);
481
+ validateOpts.optionalBoolean(opts.safeRenderPdf,
482
+ "staticServe.create: safeRenderPdf", StaticServeError);
382
483
  numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxBytesPerActorPerWindowMs,
383
484
  "staticServe.create: maxBytesPerActorPerWindowMs", StaticServeError, "BAD_OPT");
384
485
  numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxBytesAllActorsPerWindowMs,
@@ -504,6 +605,7 @@ function create(opts) {
504
605
  "maxBytesPerActorPerWindowMs", "maxBytesAllActorsPerWindowMs",
505
606
  "bandwidthWindowMs", "maxConcurrentDownloadsPerActor", "maxIdleMs",
506
607
  "contentSafety", "contentSafetyDisabledReason",
608
+ "forceAttachmentForNonText", "safeRenderSvg", "safeRenderPdf",
507
609
  ], "staticServe.create");
508
610
  _validateCreateOpts(opts);
509
611
  var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
@@ -556,6 +658,9 @@ function create(opts) {
556
658
  var auditFailures = cfg.auditFailures;
557
659
  var acceptRanges = cfg.acceptRanges;
558
660
  var safeAttachment = !!cfg.safeAttachmentForRiskyMimes;
661
+ var forceAttachmentForNonText = !!cfg.forceAttachmentForNonText;
662
+ var allowSvgRender = cfg.safeRenderSvg !== false;
663
+ var allowPdfRender = !!cfg.safeRenderPdf;
559
664
  var perActorCap = cfg.maxBytesPerActorPerWindowMs;
560
665
  var globalCap = cfg.maxBytesAllActorsPerWindowMs;
561
666
  var bandwidthWindowMs = cfg.bandwidthWindowMs;
@@ -913,6 +1018,21 @@ function create(opts) {
913
1018
  if (safeAttachment && _isRiskyInlineMime(headers["Content-Type"])) {
914
1019
  headers["Content-Disposition"] = _attachmentDisposition(absPath);
915
1020
  }
1021
+ // Stored-XSS defense for user-upload directories: when
1022
+ // forceAttachmentForNonText is on, force download for every MIME
1023
+ // outside the safe-render allowlist (text/* except html/xml/js,
1024
+ // image/png|jpeg|webp|gif, image/svg+xml only when an SVG sanitizer
1025
+ // gate is wired, application/pdf only when safeRenderPdf is
1026
+ // explicitly on). Pairs with X-Content-Type-Options: nosniff so
1027
+ // browsers can't sniff the bytes back into an executable type.
1028
+ if (forceAttachmentForNonText) {
1029
+ var dispoExt = path.extname(absPath).toLowerCase();
1030
+ if (_shouldForceAttachment(headers["Content-Type"], dispoExt, contentSafety,
1031
+ allowSvgRender, allowPdfRender)) {
1032
+ headers["Content-Disposition"] = _attachmentDisposition(absPath);
1033
+ headers["X-Content-Type-Options"] = "nosniff";
1034
+ }
1035
+ }
916
1036
  if (acceptRanges) headers["Accept-Ranges"] = "bytes";
917
1037
  if (range) headers["Content-Range"] = "bytes " + range.start + "-" + range.end + "/" + meta.size;
918
1038