@blamejs/core 0.9.46 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +951 -893
- package/index.js +30 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-imap-command.js +335 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +1102 -0
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +164 -34
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +269 -0
- package/lib/mail-server-submission.js +1032 -0
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +130 -10
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +168 -17
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-auth.js
CHANGED
|
@@ -32,10 +32,9 @@
|
|
|
32
32
|
* verifier that's deferred from this patch).
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
var dns = require("node:dns");
|
|
36
|
-
var dnsPromises = dns.promises;
|
|
37
35
|
var zlib = require("node:zlib");
|
|
38
36
|
var net = require("node:net");
|
|
37
|
+
var nodeCrypto = require("node:crypto");
|
|
39
38
|
var lazyRequire = require("./lazy-require");
|
|
40
39
|
var validateOpts = require("./validate-opts");
|
|
41
40
|
var C = require("./constants");
|
|
@@ -43,6 +42,7 @@ var dkim = require("./mail-dkim");
|
|
|
43
42
|
var safeXml = require("./parsers/safe-xml");
|
|
44
43
|
var ipUtils = require("./ip-utils");
|
|
45
44
|
var publicSuffix = require("./public-suffix");
|
|
45
|
+
var networkDnsResolver = lazyRequire(function () { return require("./network-dns-resolver"); });
|
|
46
46
|
var { MailAuthError } = require("./framework-error");
|
|
47
47
|
|
|
48
48
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
@@ -53,6 +53,128 @@ void observability;
|
|
|
53
53
|
// when crossed, matching mainstream MTAs.
|
|
54
54
|
var SPF_DNS_LOOKUP_LIMIT = 10;
|
|
55
55
|
|
|
56
|
+
// RFC 7208 §4.6.4 — "void lookup" cap. A void lookup is a successful
|
|
57
|
+
// DNS query whose answer is empty (NXDOMAIN, no-data response, or
|
|
58
|
+
// zero records returned). The SPF spec caps void lookups at 2; beyond
|
|
59
|
+
// that the policy MUST permerror. Attackers chain misconfigured
|
|
60
|
+
// `include:`s pointing at non-existent domains to amplify recursive
|
|
61
|
+
// resolver work without tripping the 10-lookup ceiling.
|
|
62
|
+
var SPF_VOID_LOOKUP_LIMIT = 2; // allow:raw-byte-literal — RFC 7208 §4.6.4 void-lookup ceiling
|
|
63
|
+
|
|
64
|
+
// RFC 7208 §3.3 — each SPF TXT record MUST NOT exceed 450 bytes when
|
|
65
|
+
// concatenated across multi-string TXT chunks. The spec lifts a
|
|
66
|
+
// receiver MUST-refuse on >450-byte records to bound parse work.
|
|
67
|
+
var SPF_RECORD_MAX_BYTES = 450; // allow:raw-byte-literal — RFC 7208 §3.3 record ceiling
|
|
68
|
+
|
|
69
|
+
// SPF redirect= modifier (RFC 7208 §6.1) recursion cap. The modifier
|
|
70
|
+
// re-evaluates against a different domain; a chain of redirect= cycles
|
|
71
|
+
// MUST terminate. We bound at the same depth as the lookup ceiling
|
|
72
|
+
// minus current count (the redirect itself counts as one lookup); the
|
|
73
|
+
// hard cap below is an additional belt-and-braces against malformed
|
|
74
|
+
// upstream policies that would otherwise spin until the lookup cap
|
|
75
|
+
// alone tripped.
|
|
76
|
+
var SPF_REDIRECT_DEPTH_LIMIT = 10; // allow:raw-byte-literal — same shape as RFC 7208 §4.6.4 lookup ceiling
|
|
77
|
+
|
|
78
|
+
// Shared safe-DNS TXT/A/AAAA/PTR lookup. Operator-supplied
|
|
79
|
+
// `dnsLookup` (legacy `[[strings]]` shape for TXT; flat `[addr, ...]`
|
|
80
|
+
// for A/AAAA; flat `[name]` for PTR) takes precedence; otherwise
|
|
81
|
+
// routes through `b.network.dns.resolver` (DoH by default per
|
|
82
|
+
// v0.7.23). CVE-2008-1447 (Kaminsky) + CVE-2022-3204
|
|
83
|
+
// (NRDelegationAttack) class — the encrypted DoH transport plus
|
|
84
|
+
// b.safeDns parse caps defend transport and parse-side. Earlier
|
|
85
|
+
// shape fell back to `node:dns.promises.resolveTxt` directly, which
|
|
86
|
+
// sent plaintext UDP/53 to whatever the system resolver was — every
|
|
87
|
+
// downstream finding inherited that exposure.
|
|
88
|
+
var _defaultResolver = null;
|
|
89
|
+
function _getDefaultResolver() {
|
|
90
|
+
if (_defaultResolver) return _defaultResolver;
|
|
91
|
+
_defaultResolver = networkDnsResolver().create();
|
|
92
|
+
return _defaultResolver;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function _safeResolveTxt(qname, operatorLookup) {
|
|
96
|
+
if (operatorLookup) return operatorLookup(qname, "TXT");
|
|
97
|
+
var r = await _getDefaultResolver().queryTxt(qname);
|
|
98
|
+
var out = [];
|
|
99
|
+
for (var i = 0; i < r.rrs.length; i += 1) {
|
|
100
|
+
var rr = r.rrs[i];
|
|
101
|
+
if (rr && rr.type === 16) { // allow:raw-byte-literal — IANA DNS qtype TXT
|
|
102
|
+
out.push(Array.isArray(rr.decoded) ? rr.decoded : [String(rr.decoded)]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (out.length === 0) {
|
|
106
|
+
var err = new Error("no TXT records for " + qname);
|
|
107
|
+
err.code = "ENODATA";
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function _safeResolveA(qname, family /* 4|6 */) {
|
|
114
|
+
var r = await _getDefaultResolver().query(qname, family === 6 ? "AAAA" : "A");
|
|
115
|
+
var out = [];
|
|
116
|
+
for (var i = 0; i < r.rrs.length; i += 1) {
|
|
117
|
+
var rr = r.rrs[i];
|
|
118
|
+
var wantType = family === 6 ? 28 : 1; // allow:raw-byte-literal — IANA DNS qtype AAAA / A
|
|
119
|
+
if (rr && rr.type === wantType) out.push(rr.decoded);
|
|
120
|
+
}
|
|
121
|
+
if (out.length === 0) {
|
|
122
|
+
var err = new Error("no " + (family === 6 ? "AAAA" : "A") + " records for " + qname);
|
|
123
|
+
err.code = "ENODATA";
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function _safeReverse(ip) {
|
|
130
|
+
// PTR query against the reverse-arpa name. IPv4: a.b.c.d.in-addr.arpa
|
|
131
|
+
// (reversed octets); IPv6: nibble-reversed under ip6.arpa.
|
|
132
|
+
var qname = _ipToReverseArpa(ip);
|
|
133
|
+
if (qname === null) {
|
|
134
|
+
var err = new Error("invalid IP literal: " + ip);
|
|
135
|
+
err.code = "ENOTFOUND";
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
var r = await _getDefaultResolver().query(qname, "PTR");
|
|
139
|
+
var out = [];
|
|
140
|
+
for (var i = 0; i < r.rrs.length; i += 1) {
|
|
141
|
+
var rr = r.rrs[i];
|
|
142
|
+
if (rr && rr.type === 12) { // allow:raw-byte-literal — IANA DNS qtype PTR
|
|
143
|
+
// Strip trailing dot if present (PTR rdata is FQDN with root dot).
|
|
144
|
+
var name = String(rr.decoded || "").replace(/\.$/, "");
|
|
145
|
+
if (name.length > 0) out.push(name);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (out.length === 0) {
|
|
149
|
+
var e2 = new Error("no PTR records for " + ip);
|
|
150
|
+
e2.code = "ENODATA";
|
|
151
|
+
throw e2;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function _ipToReverseArpa(ip) {
|
|
157
|
+
if (typeof ip !== "string") return null;
|
|
158
|
+
if (net.isIPv4(ip)) {
|
|
159
|
+
var p = ip.split(".");
|
|
160
|
+
if (p.length !== 4) return null; // allow:raw-byte-literal — IPv4 octet count
|
|
161
|
+
return p[3] + "." + p[2] + "." + p[1] + "." + p[0] + ".in-addr.arpa";
|
|
162
|
+
}
|
|
163
|
+
if (net.isIPv6(ip)) {
|
|
164
|
+
var groups = ipUtils.expandIpv6Groups(ip);
|
|
165
|
+
if (!groups) return null;
|
|
166
|
+
var hex = "";
|
|
167
|
+
for (var i = 0; i < groups.length; i += 1) {
|
|
168
|
+
var s = groups[i].toString(16); // allow:raw-byte-literal — hex radix
|
|
169
|
+
while (s.length < 4) s = "0" + s; // allow:raw-byte-literal — IPv6 group nibble count
|
|
170
|
+
hex += s;
|
|
171
|
+
}
|
|
172
|
+
var rev = hex.split("").reverse().join(".");
|
|
173
|
+
return rev + ".ip6.arpa";
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
56
178
|
// ---- Helpers ----
|
|
57
179
|
|
|
58
180
|
function _ipv4ToInt(ip) {
|
|
@@ -168,9 +290,7 @@ function _parseSpfRecord(text) {
|
|
|
168
290
|
async function _fetchSpfRecord(domain, dnsLookup) {
|
|
169
291
|
var records;
|
|
170
292
|
try {
|
|
171
|
-
records = dnsLookup
|
|
172
|
-
? await dnsLookup(domain, "TXT")
|
|
173
|
-
: await dnsPromises.resolveTxt(domain);
|
|
293
|
+
records = await _safeResolveTxt(domain, dnsLookup);
|
|
174
294
|
} catch (e) {
|
|
175
295
|
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return { kind: "none" };
|
|
176
296
|
throw new MailAuthError("mail-auth/spf-lookup-failed",
|
|
@@ -189,6 +309,15 @@ async function _fetchSpfRecord(domain, dnsLookup) {
|
|
|
189
309
|
reason: "domain " + domain + " publishes " + matches.length +
|
|
190
310
|
" v=spf1 records; RFC 7208 §4.5 requires at most one" };
|
|
191
311
|
}
|
|
312
|
+
// RFC 7208 §3.3 — the SPF record (concatenated across multi-string
|
|
313
|
+
// TXT chunks) MUST NOT exceed 450 bytes. Receivers MUST refuse
|
|
314
|
+
// larger records (permerror) so a malformed-large policy can't
|
|
315
|
+
// amplify parser work.
|
|
316
|
+
if (matches[0].length > SPF_RECORD_MAX_BYTES) {
|
|
317
|
+
return { kind: "permerror",
|
|
318
|
+
reason: "domain " + domain + " SPF record is " + matches[0].length +
|
|
319
|
+
" bytes; RFC 7208 §3.3 caps at " + SPF_RECORD_MAX_BYTES };
|
|
320
|
+
}
|
|
192
321
|
return { kind: "found", record: matches[0] };
|
|
193
322
|
}
|
|
194
323
|
|
|
@@ -210,7 +339,7 @@ async function spfVerify(opts) {
|
|
|
210
339
|
"spf.verify: mailFrom or helo is required");
|
|
211
340
|
}
|
|
212
341
|
|
|
213
|
-
var lookups = { count: 0, limit: SPF_DNS_LOOKUP_LIMIT };
|
|
342
|
+
var lookups = { count: 0, limit: SPF_DNS_LOOKUP_LIMIT, void: 0 };
|
|
214
343
|
// RFC 7208 §4.6.4 — the initial query for the sender domain's SPF
|
|
215
344
|
// record itself does NOT count toward the 10-lookup limit. Only
|
|
216
345
|
// include / a / mx / ptr / exists / redirect mechanisms count.
|
|
@@ -232,6 +361,19 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
|
|
|
232
361
|
if (lookups.count > lookups.limit) {
|
|
233
362
|
return { verdict: "permerror", explanation: "DNS lookup limit exceeded (RFC 7208 §4.6.4)" };
|
|
234
363
|
}
|
|
364
|
+
// RFC 7208 §4.6.4 — void-lookup ceiling. Each successful query that
|
|
365
|
+
// returns 0 records (NXDOMAIN, no-data) counts. Beyond 2, permerror.
|
|
366
|
+
if ((lookups.void || 0) > SPF_VOID_LOOKUP_LIMIT) {
|
|
367
|
+
return { verdict: "permerror",
|
|
368
|
+
explanation: "SPF void-lookup limit exceeded (RFC 7208 §4.6.4)" };
|
|
369
|
+
}
|
|
370
|
+
// RFC 7208 §6.1 — redirect= recursion bound. Per-evaluation
|
|
371
|
+
// re-entries via redirect MUST terminate. The lookup limit also
|
|
372
|
+
// catches pathological chains; this bound is the belt-and-braces.
|
|
373
|
+
if ((ctx.redirectDepth || 0) > SPF_REDIRECT_DEPTH_LIMIT) {
|
|
374
|
+
return { verdict: "permerror",
|
|
375
|
+
explanation: "SPF redirect= recursion limit exceeded (RFC 7208 §6.1)" };
|
|
376
|
+
}
|
|
235
377
|
// Initial query for the sender's SPF record doesn't count (RFC 7208
|
|
236
378
|
// §4.6.4); only include / a / mx / ptr / exists / redirect do.
|
|
237
379
|
if (!ctx.isInitial) lookups.count += 1;
|
|
@@ -245,6 +387,11 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
|
|
|
245
387
|
return { verdict: "permerror", explanation: fetched.reason };
|
|
246
388
|
}
|
|
247
389
|
if (fetched.kind === "none") {
|
|
390
|
+
// Void lookup — count toward §4.6.4 ceiling. Initial query
|
|
391
|
+
// doesn't count as a "lookup" but DOES count as void if the
|
|
392
|
+
// sender has no SPF (mirrors the spec's intent: a misconfigured
|
|
393
|
+
// sender that publishes no record still consumes a slot).
|
|
394
|
+
lookups.void = (lookups.void || 0) + 1;
|
|
248
395
|
return { verdict: "none", explanation: "no SPF record at " + domain };
|
|
249
396
|
}
|
|
250
397
|
|
|
@@ -280,8 +427,7 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
|
|
|
280
427
|
explanation: "include:" + m.arg + " has no SPF record (RFC 7208 §5.2)" };
|
|
281
428
|
}
|
|
282
429
|
} else if (m.mechanism === "a" || m.mechanism === "mx" ||
|
|
283
|
-
m.mechanism === "exists" || m.mechanism === "ptr"
|
|
284
|
-
m.mechanism === "redirect") {
|
|
430
|
+
m.mechanism === "exists" || m.mechanism === "ptr") {
|
|
285
431
|
// Out of scope this patch — operators with these get permerror
|
|
286
432
|
// so they know to investigate.
|
|
287
433
|
return {
|
|
@@ -301,6 +447,33 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
|
|
|
301
447
|
(m.arg ? ":" + m.arg : "") };
|
|
302
448
|
}
|
|
303
449
|
}
|
|
450
|
+
|
|
451
|
+
// RFC 7208 §6.1 — `redirect=<domain>` modifier: when no mechanism
|
|
452
|
+
// matched, fall through to the target domain's policy. The redirect
|
|
453
|
+
// is ignored if an `all` mechanism is present (since `all` matches
|
|
454
|
+
// unconditionally, the redirect is unreachable by construction).
|
|
455
|
+
// Pre-this-patch the redirect= modifier was silently dropped — a
|
|
456
|
+
// domain whose only policy was `v=spf1 redirect=_spf.example.com`
|
|
457
|
+
// returned "neutral" instead of the redirected verdict, leaving
|
|
458
|
+
// every legitimate sender unauthenticated.
|
|
459
|
+
var mods = mechanisms.modifiers || [];
|
|
460
|
+
for (var rmi = 0; rmi < mods.length; rmi += 1) {
|
|
461
|
+
if (mods[rmi].name === "redirect" && mods[rmi].value) {
|
|
462
|
+
// Redirect counts as one DNS-mechanism per §4.6.4.
|
|
463
|
+
var redirected = await _spfEvaluateDomain(
|
|
464
|
+
mods[rmi].value.toLowerCase(), ip, dnsLookup, lookups,
|
|
465
|
+
{ redirectDepth: (ctx.redirectDepth || 0) + 1 });
|
|
466
|
+
// RFC 7208 §6.1 — if the redirect target has no SPF record,
|
|
467
|
+
// permerror (the operator's intent is unverifiable).
|
|
468
|
+
if (redirected.verdict === "none") {
|
|
469
|
+
return { verdict: "permerror",
|
|
470
|
+
explanation: "redirect=" + mods[rmi].value +
|
|
471
|
+
" has no SPF record (RFC 7208 §6.1)" };
|
|
472
|
+
}
|
|
473
|
+
return redirected;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
304
477
|
return { verdict: "neutral", explanation: "no mechanism matched" };
|
|
305
478
|
}
|
|
306
479
|
|
|
@@ -310,9 +483,7 @@ async function _fetchDmarcRecord(domain, dnsLookup) {
|
|
|
310
483
|
var qname = "_dmarc." + domain.toLowerCase();
|
|
311
484
|
var records;
|
|
312
485
|
try {
|
|
313
|
-
records = dnsLookup
|
|
314
|
-
? await dnsLookup(qname, "TXT")
|
|
315
|
-
: await dnsPromises.resolveTxt(qname);
|
|
486
|
+
records = await _safeResolveTxt(qname, dnsLookup);
|
|
316
487
|
} catch (e) {
|
|
317
488
|
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return null;
|
|
318
489
|
throw new MailAuthError("mail-auth/dmarc-lookup-failed",
|
|
@@ -391,18 +562,25 @@ function _alignmentCheck(fromDomain, authDomain, mode) {
|
|
|
391
562
|
var f = fromDomain.toLowerCase();
|
|
392
563
|
var a = authDomain.toLowerCase();
|
|
393
564
|
if (mode === "s") return f === a; // strict
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
565
|
+
// RFC 7489 §3.1.1 + DMARCbis §4.4 — relaxed alignment compares the
|
|
566
|
+
// organizational domain (the public-suffix-tail registered name).
|
|
567
|
+
// Earlier shape did a naive `endsWith` text-suffix check which over-
|
|
568
|
+
// approximated alignment: `evil-bank.com` and `bank.com` looked
|
|
569
|
+
// aligned even though they're separately registered. PSL lookup
|
|
570
|
+
// closes the gap.
|
|
397
571
|
if (f === a) return true;
|
|
398
|
-
|
|
399
|
-
|
|
572
|
+
var fOrg = null;
|
|
573
|
+
var aOrg = null;
|
|
574
|
+
try { fOrg = publicSuffix.organizationalDomain(f); } catch (_e) { fOrg = null; }
|
|
575
|
+
try { aOrg = publicSuffix.organizationalDomain(a); } catch (_e) { aOrg = null; }
|
|
576
|
+
if (fOrg && aOrg && fOrg === aOrg) return true;
|
|
400
577
|
return false;
|
|
401
578
|
}
|
|
402
579
|
|
|
403
580
|
async function dmarcEvaluate(opts) {
|
|
404
581
|
opts = opts || {};
|
|
405
|
-
validateOpts(opts, ["from", "spf", "dkim", "dnsLookup", "domainExists"
|
|
582
|
+
validateOpts(opts, ["from", "spf", "dkim", "dnsLookup", "domainExists",
|
|
583
|
+
"pctSampleKey"],
|
|
406
584
|
"mail.dmarc.evaluate");
|
|
407
585
|
if (typeof opts.from !== "string") {
|
|
408
586
|
throw new MailAuthError("mail-auth/dmarc-bad-from",
|
|
@@ -518,12 +696,35 @@ async function dmarcEvaluate(opts) {
|
|
|
518
696
|
// not "deliver". When pct is < 100 the receiver applies the policy
|
|
519
697
|
// to that fraction of failing messages and the rest gets the next-
|
|
520
698
|
// less-strict disposition (reject → quarantine; quarantine → none).
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
699
|
+
//
|
|
700
|
+
// Sampling determinism: a single message MUST receive the same
|
|
701
|
+
// sampled/not-sampled verdict across retries. `Math.random()` re-
|
|
702
|
+
// rolls per-call so the receiver's first attempt could deliver
|
|
703
|
+
// (sampled=true → quarantine→none) while a retry rejected — leading
|
|
704
|
+
// to inconsistent disposition for the same SMTP envelope. Derive
|
|
705
|
+
// the sample roll from a stable per-message key (operator-supplied
|
|
706
|
+
// `pctSampleKey` — typically the Message-ID + From-domain + a
|
|
707
|
+
// receiver-side secret) hashed via SHAKE256, mapped to [0,100). When
|
|
708
|
+
// the operator doesn't supply a key we fall back to a per-call
|
|
709
|
+
// crypto.randomInt — still cryptographically uniform, just not
|
|
710
|
+
// retry-stable. The fallback is the framework's hardening floor
|
|
711
|
+
// (replaces Math.random); retry-stability requires the operator to
|
|
712
|
+
// wire a key.
|
|
524
713
|
var pctRaw = parseInt(policy.pct, 10); // allow:raw-byte-literal — pct percentage, not bytes
|
|
525
714
|
var pct = isFinite(pctRaw) && pctRaw >= 0 && pctRaw <= 100 ? pctRaw : 100; // allow:raw-byte-literal — pct percentage, not bytes
|
|
526
|
-
var
|
|
715
|
+
var sampleRoll;
|
|
716
|
+
if (typeof opts.pctSampleKey === "string" && opts.pctSampleKey.length > 0) {
|
|
717
|
+
// Deterministic per-message sample roll. SHAKE256 → first 4 bytes
|
|
718
|
+
// → uint32 → modulo 100. 4 bytes is far in excess of the
|
|
719
|
+
// information needed for 0..99 and uniform mapping is fine.
|
|
720
|
+
var hash = nodeCrypto.createHash("shake256", { outputLength: 4 })
|
|
721
|
+
.update(String(opts.pctSampleKey)).digest();
|
|
722
|
+
var u32 = (hash[0] << 24 >>> 0) + (hash[1] << 16) + (hash[2] << 8) + hash[3]; // allow:raw-byte-literal — uint32 bit assembly
|
|
723
|
+
sampleRoll = u32 % 100; // allow:raw-byte-literal — pct sample roll
|
|
724
|
+
} else {
|
|
725
|
+
sampleRoll = nodeCrypto.randomInt(0, 100); // allow:raw-byte-literal — pct sample roll
|
|
726
|
+
}
|
|
727
|
+
var sampled = !pass && pct < 100 && sampleRoll >= pct;
|
|
527
728
|
var recommendedAction = pass ? "deliver" :
|
|
528
729
|
sampled
|
|
529
730
|
? (policy.p === "reject" ? "quarantine" :
|
|
@@ -604,6 +805,15 @@ async function arcVerify(rfc822, opts) {
|
|
|
604
805
|
// instance would silently overwrite the original signer).
|
|
605
806
|
var duplicate = false;
|
|
606
807
|
var maxInstanceSeen = 0;
|
|
808
|
+
// RFC 8617 §5.2 — verifier MUST process the chain starting with the
|
|
809
|
+
// highest-instance set, then walk down. Each hop prepends its three
|
|
810
|
+
// headers (AS, AMS, AAR) to the message, so the source order from
|
|
811
|
+
// top to bottom is: i=N (AS, AMS, AAR), i=N-1 (...), ..., i=1.
|
|
812
|
+
// A chain whose source order doesn't decrease has been re-shuffled
|
|
813
|
+
// by an intermediary that didn't follow §5.1, or is forged. Track
|
|
814
|
+
// per-header-set first-appearance order and enforce strictly-
|
|
815
|
+
// decreasing instances.
|
|
816
|
+
var orderTrail = []; // [{ inst, name, idx }]
|
|
607
817
|
for (var i = 0; i < headers.length; i += 1) {
|
|
608
818
|
var line = headers[i];
|
|
609
819
|
var colonAt = line.indexOf(":");
|
|
@@ -625,6 +835,30 @@ async function arcVerify(rfc822, opts) {
|
|
|
625
835
|
seenSlot[slotKey] = true;
|
|
626
836
|
if (!hops[inst - 1]) hops[inst - 1] = { instance: inst };
|
|
627
837
|
hops[inst - 1][name] = value;
|
|
838
|
+
orderTrail.push({ inst: inst, name: name, idx: i });
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Source-order enforcement (RFC 8617 §5.1 + §5.2): the first AS for
|
|
842
|
+
// a given hop must appear before its AMS, which must appear before
|
|
843
|
+
// its AAR (within a single set). Across sets, hop instances MUST
|
|
844
|
+
// strictly decrease top-to-bottom. Use the first-appearance index
|
|
845
|
+
// per hop to validate the cross-set ordering; an out-of-order chain
|
|
846
|
+
// is treated as a structural failure rather than risking a permissive
|
|
847
|
+
// verdict.
|
|
848
|
+
var orderFail = null;
|
|
849
|
+
if (orderTrail.length > 0) {
|
|
850
|
+
// Per-hop first-appearance: which i= instance owns each contiguous
|
|
851
|
+
// run? Walk top to bottom and confirm the instance numbers, when
|
|
852
|
+
// they change, only EVER decrease.
|
|
853
|
+
var prevInst = null;
|
|
854
|
+
for (var oi = 0; oi < orderTrail.length; oi += 1) {
|
|
855
|
+
var cur = orderTrail[oi].inst;
|
|
856
|
+
if (prevInst !== null && cur > prevInst) {
|
|
857
|
+
orderFail = "header-order-ascending-i=" + cur + "-after-i=" + prevInst;
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
prevInst = cur;
|
|
861
|
+
}
|
|
628
862
|
}
|
|
629
863
|
|
|
630
864
|
if (hops.length === 0) {
|
|
@@ -646,6 +880,21 @@ async function arcVerify(rfc822, opts) {
|
|
|
646
880
|
};
|
|
647
881
|
}
|
|
648
882
|
|
|
883
|
+
if (orderFail) {
|
|
884
|
+
return {
|
|
885
|
+
chainStatus: "fail",
|
|
886
|
+
reason: "header-order-violation: " + orderFail,
|
|
887
|
+
hopCount: hops.filter(Boolean).length,
|
|
888
|
+
hops: hops.filter(Boolean).map(function (h) {
|
|
889
|
+
return { instance: h.instance,
|
|
890
|
+
hasSeal: !!h["arc-seal"],
|
|
891
|
+
hasMessageSignature: !!h["arc-message-signature"],
|
|
892
|
+
hasAuthenticationResults: !!h["arc-authentication-results"],
|
|
893
|
+
amsResult: "skipped", asResult: "skipped" };
|
|
894
|
+
}),
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
649
898
|
if (maxInstanceSeen > ARC_MAX_HOPS) {
|
|
650
899
|
return {
|
|
651
900
|
chainStatus: "fail",
|
|
@@ -827,12 +1076,7 @@ async function _verifyArc(rfc822, hop, allHops, kind, dnsLookup, dkim) {
|
|
|
827
1076
|
var keyTags;
|
|
828
1077
|
try {
|
|
829
1078
|
var qname = tags.s + "._domainkey." + tags.d;
|
|
830
|
-
var records;
|
|
831
|
-
if (dnsLookup) records = await dnsLookup(qname, "TXT");
|
|
832
|
-
else {
|
|
833
|
-
var dnsModule = require("node:dns/promises");
|
|
834
|
-
records = await dnsModule.resolveTxt(qname);
|
|
835
|
-
}
|
|
1079
|
+
var records = await _safeResolveTxt(qname, dnsLookup);
|
|
836
1080
|
keyTags = _parseDkimKeyRecord(records);
|
|
837
1081
|
} catch (e) {
|
|
838
1082
|
var verdict = (e && (e.code === "ENOTFOUND" || e.code === "ENODATA"))
|
|
@@ -1296,8 +1540,24 @@ function dmarcParseAggregateReport(input, opts) {
|
|
|
1296
1540
|
if (contentType.indexOf("gzip") !== -1 || looksGzip) {
|
|
1297
1541
|
try { bytes = zlib.gunzipSync(bytes, { maxOutputLength: DMARC_RUA_MAX_REPORT_BYTES }); }
|
|
1298
1542
|
catch (e) {
|
|
1543
|
+
// Distinguish "decompressed bytes exceed cap" (gunzip bomb /
|
|
1544
|
+
// amplification — operator should rate-limit the source) from
|
|
1545
|
+
// "stream is malformed" (operator-level diagnostic) so audit/
|
|
1546
|
+
// alert wiring can react differently. Node surfaces the bomb
|
|
1547
|
+
// case with ERR_BUFFER_TOO_LARGE / "Output length exceeded the
|
|
1548
|
+
// limit" / the explicit `maxOutputLength` code. CVE-class:
|
|
1549
|
+
// CVE-2024-zlib decompression amplification.
|
|
1550
|
+
var msg = (e && e.message) || String(e);
|
|
1551
|
+
var isBomb = (e && (e.code === "ERR_BUFFER_TOO_LARGE" ||
|
|
1552
|
+
e.code === "ERR_OUT_OF_RANGE")) ||
|
|
1553
|
+
/output length|max(?:imum)?\s+output|exceeds?/i.test(msg);
|
|
1554
|
+
if (isBomb) {
|
|
1555
|
+
throw new MailAuthError("mail-auth/dmarc-rua-gunzip-bomb",
|
|
1556
|
+
"dmarc.parseAggregateReport: gunzip output exceeded " +
|
|
1557
|
+
DMARC_RUA_MAX_REPORT_BYTES + " bytes (decompression amplification — refused)");
|
|
1558
|
+
}
|
|
1299
1559
|
throw new MailAuthError("mail-auth/dmarc-rua-gunzip-failed",
|
|
1300
|
-
"dmarc.parseAggregateReport: gunzip failed: " +
|
|
1560
|
+
"dmarc.parseAggregateReport: gunzip failed: " + msg);
|
|
1301
1561
|
}
|
|
1302
1562
|
}
|
|
1303
1563
|
|
|
@@ -1436,6 +1696,31 @@ function _shapeAggregateReport(parsed) {
|
|
|
1436
1696
|
// "temperror" on ENODATA / ENOTFOUND / lookup failure (the receiver
|
|
1437
1697
|
// retries on transient DNS faults). Pure-DNS — no operator state.
|
|
1438
1698
|
|
|
1699
|
+
// RFC 8601 §3 — PTR result shape. The PTR rdata is an FQDN (1*labels).
|
|
1700
|
+
// Reject answers that aren't shaped as a DNS name: non-strings,
|
|
1701
|
+
// empty strings, strings containing chars outside DNS LDH+dot, or
|
|
1702
|
+
// labels exceeding 63 octets. An attacker who controls a reverse
|
|
1703
|
+
// zone could publish a PTR whose rdata is arbitrary bytes (e.g.
|
|
1704
|
+
// `<script>...`) that downstream consumers (audit / Authentication-
|
|
1705
|
+
// Results emission) might fail to escape. Pre-filter at the iprev
|
|
1706
|
+
// boundary so only well-shaped names reach downstream.
|
|
1707
|
+
function _isValidPtrName(name) {
|
|
1708
|
+
if (typeof name !== "string") return false;
|
|
1709
|
+
var trimmed = name.replace(/\.$/, "");
|
|
1710
|
+
if (trimmed.length === 0 || trimmed.length > 253) return false; // allow:raw-byte-literal — RFC 1035 hostname cap
|
|
1711
|
+
// Labels: 1..63 octets, LDH (letter / digit / hyphen) + leading
|
|
1712
|
+
// alphanum (RFC 1035 §2.3.1). Permissive: PTR rdata can in practice
|
|
1713
|
+
// contain underscores (mail-server idiom) — allow underscore in
|
|
1714
|
+
// labels too. Reject anything else.
|
|
1715
|
+
var labels = trimmed.split(".");
|
|
1716
|
+
for (var i = 0; i < labels.length; i += 1) {
|
|
1717
|
+
var lab = labels[i];
|
|
1718
|
+
if (lab.length === 0 || lab.length > 63) return false; // allow:raw-byte-literal — RFC 1035 label cap
|
|
1719
|
+
if (!/^[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?$/.test(lab)) return false;
|
|
1720
|
+
}
|
|
1721
|
+
return true;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1439
1724
|
async function iprevVerify(ip) {
|
|
1440
1725
|
if (typeof ip !== "string" || ip.length === 0) {
|
|
1441
1726
|
return { result: "permerror", ip: ip || null,
|
|
@@ -1449,7 +1734,7 @@ async function iprevVerify(ip) {
|
|
|
1449
1734
|
}
|
|
1450
1735
|
|
|
1451
1736
|
var ptrs;
|
|
1452
|
-
try { ptrs = await
|
|
1737
|
+
try { ptrs = await _safeReverse(ip); }
|
|
1453
1738
|
catch (e) {
|
|
1454
1739
|
var rcode = e && e.code;
|
|
1455
1740
|
if (rcode === "ENOTFOUND" || rcode === "ENODATA") {
|
|
@@ -1470,14 +1755,18 @@ async function iprevVerify(ip) {
|
|
|
1470
1755
|
// RFC 8601 §3 — when multiple PTRs exist the receiver picks ONE
|
|
1471
1756
|
// and continues. We pick the first (matches mainstream MTA
|
|
1472
1757
|
// behavior) and stash the rest for operator visibility on the
|
|
1473
|
-
// out-of-band metadata.
|
|
1474
|
-
|
|
1758
|
+
// out-of-band metadata. Validate the PTR's shape FIRST — a PTR
|
|
1759
|
+
// with arbitrary bytes shouldn't reach downstream consumers.
|
|
1760
|
+
var ptr = String(ptrs[0]).replace(/\.$/, "");
|
|
1761
|
+
if (!_isValidPtrName(ptr)) {
|
|
1762
|
+
return { result: "permerror", ip: ip,
|
|
1763
|
+
ptr: ptr, forward: [], fcrdns: false,
|
|
1764
|
+
explanation: "PTR record is not a valid DNS name shape (RFC 8601 §3)" };
|
|
1765
|
+
}
|
|
1475
1766
|
var isV6 = net.isIPv6(ip);
|
|
1476
1767
|
var forwardAddrs;
|
|
1477
1768
|
try {
|
|
1478
|
-
forwardAddrs = isV6
|
|
1479
|
-
? await dnsPromises.resolve6(ptr)
|
|
1480
|
-
: await dnsPromises.resolve4(ptr);
|
|
1769
|
+
forwardAddrs = await _safeResolveA(ptr, isV6 ? 6 : 4);
|
|
1481
1770
|
} catch (e) {
|
|
1482
1771
|
var fcode = e && e.code;
|
|
1483
1772
|
if (fcode === "ENOTFOUND" || fcode === "ENODATA") {
|