@blamejs/core 0.9.49 → 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.
Files changed (77) hide show
  1. package/CHANGELOG.md +951 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-jmap.js +321 -0
  29. package/lib/guard-managesieve-command.js +566 -0
  30. package/lib/guard-pop3-command.js +317 -0
  31. package/lib/guard-smtp-command.js +58 -3
  32. package/lib/mail-agent.js +20 -7
  33. package/lib/mail-arc-sign.js +12 -8
  34. package/lib/mail-auth.js +323 -34
  35. package/lib/mail-crypto-pgp.js +934 -0
  36. package/lib/mail-crypto-smime.js +340 -0
  37. package/lib/mail-crypto.js +108 -0
  38. package/lib/mail-dav.js +1224 -0
  39. package/lib/mail-deploy.js +492 -0
  40. package/lib/mail-dkim.js +431 -26
  41. package/lib/mail-journal.js +435 -0
  42. package/lib/mail-scan.js +502 -0
  43. package/lib/mail-server-imap.js +64 -26
  44. package/lib/mail-server-jmap.js +488 -0
  45. package/lib/mail-server-managesieve.js +853 -0
  46. package/lib/mail-server-mx.js +40 -30
  47. package/lib/mail-server-pop3.js +836 -0
  48. package/lib/mail-server-rate-limit.js +13 -0
  49. package/lib/mail-server-submission.js +70 -24
  50. package/lib/mail-server-tls.js +445 -0
  51. package/lib/mail-sieve.js +557 -0
  52. package/lib/mail-spam-score.js +284 -0
  53. package/lib/mail.js +99 -0
  54. package/lib/metrics.js +80 -3
  55. package/lib/middleware/dpop.js +58 -3
  56. package/lib/middleware/idempotency-key.js +255 -42
  57. package/lib/middleware/protected-resource-metadata.js +114 -2
  58. package/lib/network-dns-resolver.js +33 -0
  59. package/lib/network-tls.js +46 -0
  60. package/lib/outbox.js +62 -12
  61. package/lib/pqc-agent.js +13 -5
  62. package/lib/retry.js +23 -9
  63. package/lib/router.js +23 -1
  64. package/lib/safe-ical.js +634 -0
  65. package/lib/safe-icap.js +502 -0
  66. package/lib/safe-mime.js +15 -0
  67. package/lib/safe-sieve.js +684 -0
  68. package/lib/safe-smtp.js +57 -0
  69. package/lib/safe-url.js +37 -0
  70. package/lib/safe-vcard.js +473 -0
  71. package/lib/self-update-standalone-verifier.js +32 -3
  72. package/lib/self-update.js +153 -33
  73. package/lib/vendor/MANIFEST.json +161 -156
  74. package/lib/vendor-data.js +127 -9
  75. package/lib/vex.js +324 -59
  76. package/package.json +1 -1
  77. 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
- // relaxed: same org-domain (suffix check). Without PSL we can't do
395
- // exact org-domain extraction; best-effort is "auth domain ends
396
- // with from domain or vice versa".
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
- if (f.length > a.length && f.slice(-a.length - 1) === "." + a) return true;
399
- if (a.length > f.length && a.slice(-f.length - 1) === "." + f) return true;
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
- // Pre-v0.8.32 this was ignored — the framework's recommendedAction
522
- // returned the unconditional `policy.p` which over-applied at low
523
- // pct values.
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 sampled = !pass && pct < 100 && Math.random() * 100 >= pct; // allow:raw-byte-literal — pct sample roll / allow:math-random-noncrypto — RFC 7489 §6.6.4 pct probabilistic sampling, not security-sensitive
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: " + ((e && e.message) || String(e)));
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 dnsPromises.reverse(ip); }
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
- var ptr = String(ptrs[0]);
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") {