@blamejs/core 0.15.48 → 0.15.50
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 +4 -0
- package/lib/http-message-signature.js +85 -8
- package/lib/mail-auth.js +7 -2
- package/lib/mail-bimi.js +22 -9
- package/lib/mail.js +9 -0
- package/lib/middleware/csrf-protect.js +10 -5
- package/lib/public-suffix.js +43 -5
- package/lib/ssrf-guard.js +7 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.15.x
|
|
10
10
|
|
|
11
|
+
- v0.15.50 (2026-06-28) — **`b.mail.bimi` closes a VMC certificate authorization bypass, and the host/origin comparisons in `b.ssrfGuard`, `b.middleware.csrfProtect`, and `b.mail.dmarc` now canonicalize both sides so case, trailing-dot, and IDN differences cannot decide a security check.** Four security and correctness decisions compared a host, origin, or domain where one side was normalized and the other was not, so two values that denote the same host in different encodings reached different verdicts. The most serious was in b.mail.bimi.fetchAndVerifyMark: when a VMC/CMC certificate's URI Subject Alternative Name could not be parsed as a URL (for example, a host carrying userinfo), the matcher fell back to a raw substring search of the whole SAN string — so a CA-chained certificate whose real host was attacker-controlled but whose SAN contained the victim domain anywhere (in the userinfo or path) was accepted to vouch for that victim domain. The fallback is removed (an unparseable URI SAN now fails closed) and both the certificate host and the BIMI domain are canonicalized before comparison. b.ssrfGuard allow/deny lists compared the operator's entries verbatim against the URL parser's already-lowercased host, so a mixed-case or trailing-dot deny entry silently failed to block its host; both sides now canonicalize through canonicalizeHost. b.middleware.csrfProtect canonicalized the candidate Origin via the URL parser but built the same-origin baseline by raw concatenation of the Host header, refusing a legitimate same-origin request whose Host was mixed-case or carried an explicit default port; the baseline and allowedOrigins now go through the same canonicalizer. b.mail.dmarc strict alignment compared the From and SPF/DKIM authentication domains with only case-folding, failing an aligned message whose authentication domain carried a trailing dot or an IDN label; both are now canonicalized the same way the relaxed path already was. A new b.publicSuffix.canonicalDomain primitive provides the shared encoding-stable host form. **Added:** *b.publicSuffix.canonicalDomain — encoding-stable host form* — Returns the bare canonical host form of a domain (lowercase, single trailing dot stripped, IDN labels as their A-label/punycode form) for identity comparison, without walking the public-suffix list. Two values that denote the same host in different encodings return the same string; an invalid or hostile host returns the empty string and matches nothing. It is the shared building block for the DMARC-alignment and certificate SAN authorization comparisons above. **Fixed:** *CSRF Origin check no longer refuses a legitimate same-origin request* — b.middleware.csrfProtect canonicalized the incoming Origin/Referer through the URL parser but built the same-origin baseline by concatenating the raw Host header, and compared allowedOrigins verbatim. A legitimate same-origin POST whose Host header was mixed-case or carried an explicit default port (:80/:443) was refused as cross-origin. The baseline and each allowedOrigins entry now pass through the same origin canonicalizer as the candidate. · *DMARC strict alignment canonicalizes the compared domains* — b.mail.dmarc strict alignment (aspf=s / adkim=s) compared the From domain against the SPF/DKIM authentication domain with only case-folding, while the relaxed path already normalized via the public-suffix lookup. An aligned message whose authentication domain carried a trailing dot or an IDN label was wrongly failed. Both domains are now canonicalized identically before the strict comparison. **Security:** *BIMI VMC certificate SubjectAltName authorization bypass closed* — b.mail.bimi.fetchAndVerifyMark binds a verified mark certificate to the BIMI domain via its Subject Alternative Name. When a URI SAN could not be parsed as a URL (e.g. a host with userinfo, or a malformed/homograph URI), the matcher fell back to a raw substring search of the entire SAN string, so a CA-chained certificate whose actual host was attacker-controlled — but whose SAN contained the victim domain as a substring (in the userinfo or path) — was accepted to vouch for the victim domain. The substring fallback is removed: a URI SAN the URL parser refuses now fails closed, and both the certificate host and the BIMI domain are canonicalized (lowercase, trailing-dot strip, IDN A-label) before an exact host comparison. · *SSRF allow/deny lists now match the host case-insensitively* — b.ssrfGuard.createAllowlist compared each operator allow/deny entry verbatim against the URL parser's host, which is already lowercased. A mixed-case or trailing-dot denylist entry therefore failed to match its own host and did not block it. Both the host and each non-CIDR entry now canonicalize through canonicalizeHost before comparison, so a denylisted host is blocked regardless of the case or trailing-dot form the operator wrote.
|
|
12
|
+
|
|
13
|
+
- v0.15.49 (2026-06-28) — **`b.crypto.httpSig` now canonicalizes `@query-param` names and values per RFC 9421 §2.2.8, so its HTTP Message Signatures interoperate with conformant peers.** b.crypto.httpSig built the signature base for a @query-param component from the raw on-wire query bytes — the name was matched with encodeURIComponent and the value was emitted verbatim, with no decode-then-reencode. RFC 9421 §2.2.8 requires both the name and the value to be canonicalized: parsed as application/x-www-form-urlencoded (so a '+' and a %20 both become a space, and hex case is normalized) and then re-encoded, with a space rendered as %20. Because the framework signed and verified with the same raw bytes, blamejs-to-blamejs round-trips still worked, but a signature covering a query parameter whose name or value required encoding (a space, a '+', mixed or lowercase percent-encoding) did not match the base a conformant peer constructs — and an emitted identifier could even carry a literal space that the verifier then could not parse. Sign now emits the canonical percent-encoded name and signs the canonical value, and both sign and verify resolve the value through the same canonicalizer; the framework's base now matches the RFC's own published §2.2.8 example vectors. The whole-query @query component (§2.2.7), which the RFC defines as the raw encoded query, is unchanged, and signatures over plain-ASCII parameter names and values are byte-identical to before. **Fixed:** *HTTP Message Signatures @query-param canonicalization (RFC 9421 §2.2.8)* — b.crypto.httpSig now canonicalizes a @query-param component's name and value per RFC 9421 §2.2.8 — decode as application/x-www-form-urlencoded then re-encode, so a '+'-encoded space becomes %20, a %20 and a '+' resolve identically, and percent-encoding hex case is normalized to uppercase. Previously the name was matched with encodeURIComponent and the value was emitted raw, so a signature covering a query parameter that required encoding did not match the signature base a conformant RFC 9421 peer builds, and an emitted ;name="..." identifier could carry a literal space the verifier could not parse. Sign emits the canonical name and signs the canonical value; verify resolves the value through the same canonicalizer and reproduces the received identifier per §2.5. The framework's signature base now matches the RFC's published §2.2.8 example vectors. The whole-query @query component (§2.2.7) stays the raw encoded query, and signatures over plain-ASCII parameters are byte-identical to before.
|
|
14
|
+
|
|
11
15
|
- v0.15.48 (2026-06-28) — **`b.network.dns.tsig` now accepts a message it signed with a non-default Original ID, fixing a sign/verify digest mismatch that made the originalId option non-functional (RFC 8945).** b.network.dns.tsig.verify restores the Original ID carried in the TSIG RDATA into the DNS message header before computing the HMAC, so a signed message survives an on-wire ID rewrite by a forwarder. b.network.dns.tsig.sign digested the message's current header ID instead of the Original ID, so any message signed with the originalId option set to a value other than the message's header ID produced a MAC the framework's own verify rejected (BADSIG) — the advertised originalId option was effectively non-functional for any non-default value. sign() now digests the Original-ID form, matching verify(); when originalId equals the message's header ID (the default) the digest is byte-for-byte identical to before, so existing signatures and the reference vectors are unaffected. **Fixed:** *TSIG: a message signed with a non-default Original ID now verifies (RFC 8945)* — b.network.dns.tsig.verify restores the Original ID carried in the TSIG RDATA into the DNS message header before computing the HMAC, so a signed message survives an on-wire ID rewrite by a forwarder. b.network.dns.tsig.sign digested the message's current header ID instead of the Original ID, so any message signed with the originalId option set to a value other than the message's header ID produced a MAC the framework's own verify rejected (BADSIG). sign() now digests the Original-ID form, matching verify(); when originalId equals the message's header ID (the default) the digest is byte-for-byte identical to before, so existing signatures and the reference vectors are unaffected.
|
|
12
16
|
|
|
13
17
|
- v0.15.47 (2026-06-28) — **`b.mail.arc.verify` now returns chainStatus=pass for a cryptographically valid ARC chain — three defects in the ARC-Message-Signature verification path that made every real chain fail are fixed.** ARC-Message-Signature (AMS) verification reuses the DKIM verifier against a synthetic message, and three independent defects in that seam caused b.mail.arc.verify to reject every cryptographically valid ARC chain — its own and those from upstream relays. First, the AMS i= tag is an RFC 8617 instance number (1..50), not a DKIM Agent/User Identifier, but it was run through the RFC 6376 §3.5 AUID-must-be-a-subdomain-of-d= check, which permerrored every chain. Second, the synthetic renames the AMS header to DKIM-Signature so the DKIM verifier can find it, but the signature header was then canonicalized under that renamed field name instead of ARC-Message-Signature, so the b= signature never matched what the relay signed. Third, when sealing hop i>=2 the signer canonicalized a prior hop's ARC-Authentication-Results into the AMS instead of the current hop's, so multi-hop chains failed verification past the first hop. All three are fixed: arc.verify now confirms valid single- and multi-hop chains as cv=pass. The RFC 6376 §3.5 AUID/d= binding check remains a non-opt-out default on the public b.mail.dkim.verify path — the ARC reuse signal that skips it is framework-internal and cannot be set through the public options. **Fixed:** *ARC chain verification now succeeds for valid chains (it previously failed every one)* — b.mail.arc.verify reused the DKIM verifier to check each ARC-Message-Signature, and three defects in that path made it reject all cryptographically valid ARC chains. (1) The AMS i= instance number (RFC 8617 §4.1.2) was treated as a DKIM AUID and rejected by the RFC 6376 §3.5 AUID/d= binding check (permerror). (2) The synthetic message renames the AMS header to DKIM-Signature so the verifier can locate it, but the signature header was canonicalized under the renamed name rather than ARC-Message-Signature, so the b= value could never match the relay's signature. (3) For hops at instance 2 and beyond, b.mail.arc.sign canonicalized a prior hop's ARC-Authentication-Results into the AMS rather than the current hop's, breaking verification past the first hop. arc.verify now returns chainStatus=pass for valid single- and multi-hop chains; the DKIM AUID check stays enforced on the public DKIM verify path.
|
|
@@ -152,6 +152,54 @@ function _resolveDerivedComponent(name, msg) {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// The WHATWG application/x-www-form-urlencoded percent-encode set leaves ONLY
|
|
156
|
+
// these (all ASCII) bytes UNescaped: ALPHA / DIGIT / "*" / "-" / "." / "_".
|
|
157
|
+
// Note "~" (0x7E) IS encoded here (unlike RFC 3986 unreserved) and "*" (0x2A)
|
|
158
|
+
// is NOT — which is why encodeURIComponent (survivor set differs by ! ' ( ) *
|
|
159
|
+
// ~) cannot be reused. Membership is a single-char lookup in this set string.
|
|
160
|
+
var _QP_SURVIVORS =
|
|
161
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*-._";
|
|
162
|
+
|
|
163
|
+
// _canonQueryParamPart — RFC 9421 §2.2.8 canonicalization of a single
|
|
164
|
+
// @query-param name or value. Two stages:
|
|
165
|
+
// (1) parse per WHATWG application/x-www-form-urlencoded PARSING (§5.1):
|
|
166
|
+
// "+" -> SP, "%XX" -> decoded byte. This collapses the "+"-for-space and
|
|
167
|
+
// "%20"-for-space wire forms to the same decoded byte, and normalizes
|
|
168
|
+
// hex case.
|
|
169
|
+
// (2) re-encode per byte over UTF-8 WITHOUT the form serializer's
|
|
170
|
+
// space-as-plus rule, so SP -> "%20" (NOT "+"). RFC 9421 §2.2.8's own
|
|
171
|
+
// worked example is the governing interop vector: a wire value
|
|
172
|
+
// "with+plus+whitespace" canonicalizes to "with%20plus%20whitespace".
|
|
173
|
+
// The form serializer (URLSearchParams.toString) emits "+" and is
|
|
174
|
+
// deliberately not used for output; encodeURIComponent has the wrong
|
|
175
|
+
// survivor set. Every non-survivor byte is percent-encoded UPPERCASE.
|
|
176
|
+
// Malformed input degrades to the raw token rather than throwing mid-base
|
|
177
|
+
// build (defensive request-shape reader — return default, don't throw).
|
|
178
|
+
function _canonQueryParamPart(rawToken) {
|
|
179
|
+
var decoded;
|
|
180
|
+
try {
|
|
181
|
+
// Parse the token as a single form value. The form parser splits pairs on
|
|
182
|
+
// "&" only (the first "=" is consumed by the "k=" prefix), so a literal "&"
|
|
183
|
+
// in a caller-supplied decoded name (e.g. "a&b") must be escaped first or
|
|
184
|
+
// it would split the token and silently drop everything after it. A "%26"
|
|
185
|
+
// already present (an encoded "&") is left as-is and decodes normally.
|
|
186
|
+
decoded = new URLSearchParams("k=" + rawToken.replace(/&/g, "%26")).get("k");
|
|
187
|
+
if (decoded === null) decoded = "";
|
|
188
|
+
} catch (_e) {
|
|
189
|
+
return rawToken;
|
|
190
|
+
}
|
|
191
|
+
var bytes = Buffer.from(decoded, "utf8");
|
|
192
|
+
var out = "";
|
|
193
|
+
for (var i = 0; i < bytes.length; i++) {
|
|
194
|
+
var b = bytes[i];
|
|
195
|
+
var ch = String.fromCharCode(b);
|
|
196
|
+
out += _QP_SURVIVORS.indexOf(ch) !== -1
|
|
197
|
+
? ch
|
|
198
|
+
: "%" + b.toString(16).toUpperCase().padStart(2, "0");
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
155
203
|
// _resolveQueryParam — RFC 9421 §2.2.8 — covered identifier of the
|
|
156
204
|
// shape `"@query-param";name="k"` (the name parameter selects which
|
|
157
205
|
// query-string parameter participates in the signature base).
|
|
@@ -162,21 +210,46 @@ function _resolveQueryParam(msg, paramName) {
|
|
|
162
210
|
"httpSig: @query-param;name=" + JSON.stringify(paramName) + " but URL has no query");
|
|
163
211
|
}
|
|
164
212
|
var pairs = search.split("&");
|
|
165
|
-
// RFC 9421 §2.2.8 —
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
213
|
+
// RFC 9421 §2.2.8 — canonicalize BOTH the requested name and each wire name
|
|
214
|
+
// token (decode then re-encode) and compare canonical forms, so "+"/"%20"/
|
|
215
|
+
// hex-case/"*"-vs-"%2A" wire variations all match; return the canonicalized
|
|
216
|
+
// value (§2.2.8 step 2).
|
|
217
|
+
var wantName = _canonQueryParamPart(paramName);
|
|
169
218
|
for (var i = 0; i < pairs.length; i++) {
|
|
170
219
|
var eq = pairs[i].indexOf("=");
|
|
171
220
|
var rawName = eq === -1 ? pairs[i] : pairs[i].slice(0, eq);
|
|
172
|
-
if (rawName ===
|
|
173
|
-
return eq === -1 ? "" : pairs[i].slice(eq + 1);
|
|
221
|
+
if (_canonQueryParamPart(rawName) === wantName) {
|
|
222
|
+
return _canonQueryParamPart(eq === -1 ? "" : pairs[i].slice(eq + 1));
|
|
174
223
|
}
|
|
175
224
|
}
|
|
176
225
|
throw _err("MISSING_QUERY_PARAM",
|
|
177
226
|
"httpSig: @query-param;name=" + JSON.stringify(paramName) + " not present in URL");
|
|
178
227
|
}
|
|
179
228
|
|
|
229
|
+
// _canonicalizeQueryParamIdentifiers — rewrite each covered identifier of the
|
|
230
|
+
// shape `@query-param;name="X"` so the name is the canonical RFC 9421 §2.2.8
|
|
231
|
+
// form. Applied once at sign() intake so the SAME canonical name appears in
|
|
232
|
+
// both the signed base (component line + @signature-params terminator) and the
|
|
233
|
+
// emitted Signature-Input header. Other components and other params (e.g. ;req)
|
|
234
|
+
// pass through verbatim. The verifier does NOT re-canonicalize the identifier —
|
|
235
|
+
// it reproduces Signature-Input byte-for-byte per §2.5 — but the shared
|
|
236
|
+
// _resolveQueryParam canonicalizes the value on both sides.
|
|
237
|
+
// Match the `;name="<sf-string body>"` parameter within a covered identifier.
|
|
238
|
+
// The body is a tempered quoted-string run ([^"\\] | \\.) so an escaped quote
|
|
239
|
+
// inside the name does not end it; linear, no backtracking. Only the name
|
|
240
|
+
// parameter is rewritten — any other params (e.g. ;req) are left untouched.
|
|
241
|
+
var _QP_NAME_PARAM_RE = /;name="((?:[^"\\]|\\.)*)"/;
|
|
242
|
+
|
|
243
|
+
function _canonicalizeQueryParamIdentifiers(covered) {
|
|
244
|
+
return covered.map(function (raw) {
|
|
245
|
+
if (raw.indexOf("@query-param;") !== 0) return raw;
|
|
246
|
+
return raw.replace(_QP_NAME_PARAM_RE, function (_m, body) {
|
|
247
|
+
var nameVal = structuredFields.unescapeSfStringBody(body);
|
|
248
|
+
return ";name=" + _sfQuotedString(_canonQueryParamPart(nameVal));
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
180
253
|
// _resolveHeader — case-insensitive header lookup. RFC 9421 §2.1
|
|
181
254
|
// requires obs-fold normalization (concat multi-values with ", ").
|
|
182
255
|
function _resolveHeader(headers, name) {
|
|
@@ -331,7 +404,11 @@ function sign(msg, opts) {
|
|
|
331
404
|
m.headers = Object.assign({}, m.headers, { "content-digest": digest });
|
|
332
405
|
}
|
|
333
406
|
|
|
334
|
-
|
|
407
|
+
// Canonicalize @query-param identifier names (RFC 9421 §2.2.8) once, so the
|
|
408
|
+
// identical canonical name appears in BOTH the signed base and the emitted
|
|
409
|
+
// Signature-Input header below.
|
|
410
|
+
var covered = _canonicalizeQueryParamIdentifiers(opts.covered);
|
|
411
|
+
var base = _buildSignatureBase(covered, params, m);
|
|
335
412
|
var sig;
|
|
336
413
|
try {
|
|
337
414
|
sig = nodeCrypto.sign(null, base, opts.privateKey);
|
|
@@ -340,7 +417,7 @@ function sign(msg, opts) {
|
|
|
340
417
|
}
|
|
341
418
|
var sigB64 = sig.toString("base64");
|
|
342
419
|
|
|
343
|
-
emittedHeaders["Signature-Input"] = label + "=" + _serializeCovered(
|
|
420
|
+
emittedHeaders["Signature-Input"] = label + "=" + _serializeCovered(covered) +
|
|
344
421
|
_serializeSigParams(params);
|
|
345
422
|
emittedHeaders["Signature"] = label + "=:" + sigB64 + ":";
|
|
346
423
|
|
package/lib/mail-auth.js
CHANGED
|
@@ -1177,8 +1177,13 @@ function _parseDmarcRecord(text) {
|
|
|
1177
1177
|
|
|
1178
1178
|
function _alignmentCheck(fromDomain, authDomain, mode) {
|
|
1179
1179
|
if (!fromDomain || !authDomain) return false;
|
|
1180
|
-
|
|
1181
|
-
|
|
1180
|
+
// Canonicalize both domains identically (lowercase + trailing-dot strip + IDN
|
|
1181
|
+
// A-label) so strict alignment compares the same host form the relaxed PSL
|
|
1182
|
+
// path already normalizes — a trailing-dot / U-label SPF auth-domain must not
|
|
1183
|
+
// fail an otherwise-aligned message.
|
|
1184
|
+
var f = publicSuffix.canonicalDomain(fromDomain);
|
|
1185
|
+
var a = publicSuffix.canonicalDomain(authDomain);
|
|
1186
|
+
if (!f || !a) return false;
|
|
1182
1187
|
if (mode === "s") return f === a; // strict
|
|
1183
1188
|
// RFC 7489 §3.1.1 + DMARCbis §4.4 — relaxed alignment compares the
|
|
1184
1189
|
// organizational domain (the public-suffix-tail registered name).
|
package/lib/mail-bimi.js
CHANGED
|
@@ -60,6 +60,7 @@ var markupTokenizer = require("./markup-tokenizer");
|
|
|
60
60
|
var x509Chain = require("./x509-chain");
|
|
61
61
|
var structuredFields = require("./structured-fields");
|
|
62
62
|
var safeUrl = require("./safe-url");
|
|
63
|
+
var publicSuffix = require("./public-suffix");
|
|
63
64
|
var validateOpts = require("./validate-opts");
|
|
64
65
|
var { defineClass, MailBimiError } = require("./framework-error");
|
|
65
66
|
|
|
@@ -866,25 +867,37 @@ function _verifyCertChain(leaf, intermediates, anchors) {
|
|
|
866
867
|
// is a comma-separated string like "URI:https://example.com, DNS:example.com";
|
|
867
868
|
// accept either a URI:* matching the domain's hostname OR a DNS:*
|
|
868
869
|
// exact match (compat - some VMC profiles emit DNS instead of URI).
|
|
870
|
+
// _canonBimiHost — canonical host form for the SAN-vs-domain authorization
|
|
871
|
+
// compare, via the one delimiter-safe canonicalizer (lowercase + trailing-dot
|
|
872
|
+
// strip + IDN A-label, and crucially rejecting "/" "?" "#" which domainToASCII
|
|
873
|
+
// truncates at — so a SAN like "victim.example/evil" can't masquerade as
|
|
874
|
+
// "victim.example"). Returns "" for any non-host value, which then matches
|
|
875
|
+
// nothing (fail closed).
|
|
876
|
+
function _canonBimiHost(host) {
|
|
877
|
+
return publicSuffix.canonicalDomain(host == null ? "" : host);
|
|
878
|
+
}
|
|
879
|
+
|
|
869
880
|
function _subjectAltNameMatchesDomain(cert, domain) {
|
|
870
881
|
var raw = cert.subjectAltName || "";
|
|
871
882
|
var parts = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
872
883
|
var found = parts.slice();
|
|
873
|
-
var dom = domain
|
|
884
|
+
var dom = _canonBimiHost(domain);
|
|
885
|
+
if (dom.length === 0) return { ok: false, found: found };
|
|
874
886
|
for (var i = 0; i < parts.length; i += 1) {
|
|
875
887
|
var p = parts[i];
|
|
876
888
|
var lp = p.toLowerCase();
|
|
877
889
|
if (lp.indexOf("dns:") === 0) {
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
}
|
|
881
|
-
if (lp.indexOf("uri:") === 0) {
|
|
882
|
-
var uri = p.slice(4);
|
|
890
|
+
if (_canonBimiHost(p.slice(4)) === dom) return { ok: true, found: found };
|
|
891
|
+
} else if (lp.indexOf("uri:") === 0) {
|
|
883
892
|
try {
|
|
884
|
-
var u = safeUrl.parse(
|
|
885
|
-
if ((u.hostname
|
|
893
|
+
var u = safeUrl.parse(p.slice(4), { allowedProtocols: ["https:", "http:"] });
|
|
894
|
+
if (_canonBimiHost(u.hostname) === dom) return { ok: true, found: found };
|
|
886
895
|
} catch (_e) {
|
|
887
|
-
|
|
896
|
+
// A URI SAN the URL parser refuses (userinfo / malformed / homograph)
|
|
897
|
+
// is not a usable host binding — FAIL CLOSED. No substring fallback:
|
|
898
|
+
// the old `lp.indexOf(dom) !== -1` matched the domain anywhere in the
|
|
899
|
+
// raw SAN string (userinfo / path), so a CA-chained cert whose real
|
|
900
|
+
// host differed could vouch for an arbitrary victim domain.
|
|
888
901
|
}
|
|
889
902
|
}
|
|
890
903
|
}
|
package/lib/mail.js
CHANGED
|
@@ -143,6 +143,14 @@ function _isAscii(s) {
|
|
|
143
143
|
*/
|
|
144
144
|
function toAscii(domain) {
|
|
145
145
|
if (typeof domain !== "string" || domain.length === 0) return null;
|
|
146
|
+
// domainToASCII silently TRUNCATES at a URL delimiter ("a.com/evil" -> "a.com"),
|
|
147
|
+
// so a string carrying one is not a bare host — return null rather than a
|
|
148
|
+
// misleading prefix. (":" / "@" / "[" / "]" already yield "", but reject them
|
|
149
|
+
// here too so every non-host character fails the same way.)
|
|
150
|
+
if (domain.indexOf("/") !== -1 || domain.indexOf("?") !== -1 ||
|
|
151
|
+
domain.indexOf("#") !== -1 || domain.indexOf("\\") !== -1 ||
|
|
152
|
+
domain.indexOf(":") !== -1 || domain.indexOf("@") !== -1 ||
|
|
153
|
+
domain.indexOf("[") !== -1 || domain.indexOf("]") !== -1) return null;
|
|
146
154
|
var ascii;
|
|
147
155
|
try { ascii = nodeUrl.domainToASCII(domain); }
|
|
148
156
|
catch (_e) { return null; }
|
|
@@ -524,6 +532,7 @@ function _validateMessage(message) {
|
|
|
524
532
|
var detected = fileType().detect(att.content);
|
|
525
533
|
if (detected && detected.mime &&
|
|
526
534
|
detected.mime.split("/")[0] !==
|
|
535
|
+
// allow:bare-split-on-quoted-header-token-grammar — split(";")[0] takes the Content-Type type/subtype, which precedes every parameter (RFC 9110 §8.3); a quoted ";" can only appear inside a later parameter value and so cannot affect [0].
|
|
527
536
|
att.contentType.split(";")[0].trim().toLowerCase().split("/")[0]) {
|
|
528
537
|
throw new MailError("mail/invalid-attachment",
|
|
529
538
|
"attachments[" + i + "].contentType '" + att.contentType +
|
|
@@ -189,9 +189,6 @@ function _checkOriginAllowed(req, allowedOrigins, isHttpsFn, requireOrigin) {
|
|
|
189
189
|
return null;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
var requestOrigin = (isHttpsFn && isHttpsFn(req) ? "https://" : "http://") +
|
|
193
|
-
(headers.host || "");
|
|
194
|
-
|
|
195
192
|
function _originOf(rawUrl) {
|
|
196
193
|
try {
|
|
197
194
|
var u = new URL(rawUrl); // allow:raw-new-url-parse-only — origin-shape inspection (NOT outbound). Intentionally tolerates file:// / data: which safeUrl.parse refuses.
|
|
@@ -199,12 +196,20 @@ function _checkOriginAllowed(req, allowedOrigins, isHttpsFn, requireOrigin) {
|
|
|
199
196
|
} catch (_e) { return null; }
|
|
200
197
|
}
|
|
201
198
|
|
|
199
|
+
// Canonicalize the same-origin baseline through the SAME _originOf the
|
|
200
|
+
// candidate Origin/Referer go through (lowercases the host, strips the
|
|
201
|
+
// default port), or a mixed-case / default-port Host header would not match
|
|
202
|
+
// an equivalent Origin and a legitimate same-origin request would be refused.
|
|
203
|
+
var requestOrigin = _originOf((isHttpsFn && isHttpsFn(req) ? "https://" : "http://") +
|
|
204
|
+
(headers.host || ""));
|
|
205
|
+
|
|
202
206
|
function _isAllowed(candidateOrigin) {
|
|
203
207
|
if (!candidateOrigin) return false;
|
|
204
|
-
if (candidateOrigin === requestOrigin) return true;
|
|
208
|
+
if (requestOrigin !== null && candidateOrigin === requestOrigin) return true;
|
|
205
209
|
if (Array.isArray(allowedOrigins)) {
|
|
206
210
|
for (var i = 0; i < allowedOrigins.length; i += 1) {
|
|
207
|
-
|
|
211
|
+
// Canonicalize each operator allowedOrigins entry the same way.
|
|
212
|
+
if (candidateOrigin === _originOf(allowedOrigins[i])) return true;
|
|
208
213
|
}
|
|
209
214
|
}
|
|
210
215
|
return false;
|
package/lib/public-suffix.js
CHANGED
|
@@ -97,14 +97,19 @@ function _normalizeInput(domain) {
|
|
|
97
97
|
"publicSuffix: domain must not be a bare dot");
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
-
// Reject control / null / whitespace bytes
|
|
101
|
-
//
|
|
102
|
-
//
|
|
100
|
+
// Reject control / null / whitespace bytes AND the URL-structural delimiters
|
|
101
|
+
// domainToASCII silently TRUNCATES at — "/" (0x2F), "?" (0x3F), "#" (0x23),
|
|
102
|
+
// "\" (0x5C) reduce "example.com/evil" to "example.com" rather than failing,
|
|
103
|
+
// which would let a hostile host masquerade as a trusted prefix. ":" / "@" /
|
|
104
|
+
// "[" / "]" already make domainToASCII return "" (caught below), but reject
|
|
105
|
+
// them here too so every non-host character fails closed, not silently.
|
|
103
106
|
for (var i = 0; i < s.length; i += 1) {
|
|
104
107
|
var cp = s.charCodeAt(i);
|
|
105
|
-
if (cp < 0x21 || cp === 0x7f
|
|
108
|
+
if (cp < 0x21 || cp === 0x7f ||
|
|
109
|
+
cp === 0x2f || cp === 0x3f || cp === 0x23 || cp === 0x5c || // / ? # \
|
|
110
|
+
cp === 0x3a || cp === 0x40 || cp === 0x5b || cp === 0x5d) { // : @ [ ]
|
|
106
111
|
throw _err("public-suffix/invalid-domain",
|
|
107
|
-
"publicSuffix: domain contains control
|
|
112
|
+
"publicSuffix: domain contains a control byte or URL delimiter");
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
// IDN-normalize — non-ASCII labels become xn--… via Node's UTS #46
|
|
@@ -395,9 +400,42 @@ function lookupSource() {
|
|
|
395
400
|
return _sourceMeta;
|
|
396
401
|
}
|
|
397
402
|
|
|
403
|
+
/**
|
|
404
|
+
* @primitive b.publicSuffix.canonicalDomain
|
|
405
|
+
* @signature b.publicSuffix.canonicalDomain(domain)
|
|
406
|
+
* @since 0.15.50
|
|
407
|
+
* @status stable
|
|
408
|
+
* @related b.publicSuffix.organizationalDomain, b.publicSuffix.publicSuffix
|
|
409
|
+
*
|
|
410
|
+
* Returns the bare canonical host form of `domain` for identity
|
|
411
|
+
* comparison: lowercase, a single trailing dot stripped, and IDN
|
|
412
|
+
* labels normalized to their A-label (punycode) form. Unlike
|
|
413
|
+
* `organizationalDomain` it does NOT walk the public-suffix list — it
|
|
414
|
+
* returns the input host itself in canonical form.
|
|
415
|
+
*
|
|
416
|
+
* Two values that denote the same host in different encodings (case,
|
|
417
|
+
* trailing dot, U-label vs A-label) return the SAME string, so an
|
|
418
|
+
* equality compare is encoding-stable — the building block for DMARC
|
|
419
|
+
* alignment and certificate SAN-vs-domain authorization checks, where
|
|
420
|
+
* one side normalizing differently from the other is a bypass.
|
|
421
|
+
*
|
|
422
|
+
* Non-throwing: returns `""` for any input that is not a valid host
|
|
423
|
+
* (control bytes, empty labels, over the 253-octet limit), so a
|
|
424
|
+
* hostile or garbage value canonicalizes to `""` and matches nothing.
|
|
425
|
+
*
|
|
426
|
+
* @example
|
|
427
|
+
* var b = require("@blamejs/core");
|
|
428
|
+
* b.publicSuffix.canonicalDomain("Example.COM."); // → "example.com"
|
|
429
|
+
* b.publicSuffix.canonicalDomain("a..b"); // → ""
|
|
430
|
+
*/
|
|
431
|
+
function canonicalDomain(domain) {
|
|
432
|
+
try { return _normalizeInput(domain); } catch (_e) { return ""; }
|
|
433
|
+
}
|
|
434
|
+
|
|
398
435
|
module.exports = {
|
|
399
436
|
publicSuffix: publicSuffix,
|
|
400
437
|
organizationalDomain: organizationalDomain,
|
|
438
|
+
canonicalDomain: canonicalDomain,
|
|
401
439
|
isPublicSuffix: isPublicSuffix,
|
|
402
440
|
lookupSource: lookupSource,
|
|
403
441
|
};
|
package/lib/ssrf-guard.js
CHANGED
|
@@ -743,11 +743,17 @@ function createAllowlist(opts) {
|
|
|
743
743
|
"ssrf-guard/empty-allowlist", {});
|
|
744
744
|
}
|
|
745
745
|
function _matches(list, hostOrIp) {
|
|
746
|
+
// Canonicalize BOTH the URL host and each non-CIDR operator entry before
|
|
747
|
+
// comparing: the URL parser already lowercases the host (and strips a
|
|
748
|
+
// trailing dot), so a mixed-case or trailing-dot operator entry compared
|
|
749
|
+
// raw silently failed to match — letting a denylisted host through.
|
|
750
|
+
var canonHost = canonicalizeHost(hostOrIp);
|
|
746
751
|
for (var i = 0; i < list.length; i++) {
|
|
747
752
|
var entry = list[i];
|
|
748
|
-
if (entry === hostOrIp) return true;
|
|
749
753
|
if (entry.indexOf("/") !== -1) {
|
|
750
754
|
try { if (cidrContains(entry, hostOrIp)) return true; } catch (_e) { /* ignore */ }
|
|
755
|
+
} else if (canonicalizeHost(entry) === canonHost) {
|
|
756
|
+
return true;
|
|
751
757
|
}
|
|
752
758
|
}
|
|
753
759
|
return false;
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:74c051d6-47fc-4a6b-a557-b70f7fd7afdf",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-29T02:26:06.809Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.15.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.50",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.50",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.15.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.50",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.15.
|
|
57
|
+
"ref": "@blamejs/core@0.15.50",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|