@blamejs/core 0.8.52 → 0.8.57
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/CHANGELOG.md +5 -0
- package/index.js +8 -0
- package/lib/audit.js +4 -0
- package/lib/auth/fido-mds3.js +624 -0
- package/lib/auth/passkey.js +214 -2
- package/lib/auth-bot-challenge.js +1 -1
- package/lib/credential-hash.js +2 -2
- package/lib/framework-error.js +55 -0
- package/lib/guard-cidr.js +2 -1
- package/lib/guard-jwt.js +2 -2
- package/lib/guard-oauth.js +2 -2
- package/lib/http-client-cache.js +916 -0
- package/lib/http-client.js +242 -0
- package/lib/mail-arf.js +343 -0
- package/lib/mail-auth.js +265 -40
- package/lib/mail-bimi.js +948 -33
- package/lib/mail-bounce.js +386 -4
- package/lib/mail-mdn.js +424 -0
- package/lib/mail-unsubscribe.js +265 -25
- package/lib/mail.js +403 -21
- package/lib/middleware/bearer-auth.js +1 -1
- package/lib/middleware/clear-site-data.js +122 -0
- package/lib/middleware/dpop.js +1 -1
- package/lib/middleware/index.js +9 -0
- package/lib/middleware/nel.js +214 -0
- package/lib/middleware/security-headers.js +56 -4
- package/lib/middleware/speculation-rules.js +323 -0
- package/lib/mime-parse.js +198 -0
- package/lib/network-dns.js +890 -27
- package/lib/network-tls.js +745 -0
- package/lib/object-store/sigv4.js +54 -0
- package/lib/public-suffix.js +414 -0
- package/lib/safe-buffer.js +7 -0
- package/lib/safe-json.js +1 -1
- package/lib/static.js +120 -0
- package/lib/storage.js +11 -0
- package/lib/vendor/MANIFEST.json +33 -0
- package/lib/vendor/bimi-trust-anchors.pem +33 -0
- package/lib/vendor/public-suffix-list.dat +16376 -0
- package/package.json +1 -1
- 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
|
+
};
|
package/lib/safe-buffer.js
CHANGED
|
@@ -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 (
|
|
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
|
|