@blamejs/core 0.14.17 → 0.14.19

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/lib/mail-auth.js CHANGED
@@ -13,21 +13,21 @@
13
13
  * b.mail.dmarc.evaluate({ from, spf, dkim, dnsLookup }) → result
14
14
  * b.mail.arc.verify(rfc822, opts) → chain status
15
15
  *
16
- * SPF (RFC 7208) — ip4 / ip6 / a / mx / include / all / redirect=
17
- * mechanisms.
16
+ * SPF (RFC 7208) — ip4 / ip6 / a / mx / include / exists / all /
17
+ * redirect= mechanisms, with macro-string expansion (§7).
18
18
  * Mechanism limit: 10 DNS lookups per RFC 7208 §4.6.4 (with the
19
19
  * void-lookup sub-limit at 2). The `a` and `mx` arms honor RFC
20
- * §5.3 / §5.4 dual-cidr-length syntax (`a:foo.com/24//64`).
20
+ * §5.3 / §5.4 dual-cidr-length syntax (`a:foo.com/24//64`). The
21
+ * `exists` mechanism (§5.7) and include / redirect targets honor
22
+ * §7 macro expansion (`%{i}` / `%{s}` / `%{l}` / `%{d}` / `%{o}` /
23
+ * `%{h}` / `%{v}` / `%{p}` plus the digit / `r` / delimiter
24
+ * transformers); the §4.6.4 lookup + void ceilings still bound the
25
+ * macro-driven exists / a / mx queries.
21
26
  *
22
- * Deferred mechanisms (each carries an explicit Re-open condition
23
- * in the dispatch arm in this file):
24
- * - exists: requires macro-string expansion (§7) to be useful;
25
- * re-opens when macros land OR an operator surfaces a
26
- * real macro-less `exists:` policy.
27
+ * Deferred mechanism (carries an explicit Re-open condition in the
28
+ * dispatch arm in this file):
27
29
  * - ptr: "strongly discouraged" by §5.5; re-opens when an
28
30
  * operator surfaces a legitimate ptr-only sender.
29
- * - macro-string expansion (§7) itself — separate slice tracked
30
- * under blamejs-roadmap.md.
31
31
  *
32
32
  * DMARC (RFC 7489) — TXT record at _dmarc.<domain>; alignment check
33
33
  * between From-header domain and DKIM-d / SPF-from-domain;
@@ -308,6 +308,214 @@ function _ipv4InCidr(ip, cidr) {
308
308
  return (BigInt(ipInt) & maskInt) === (BigInt(netInt) & maskInt);
309
309
  }
310
310
 
311
+ // ---- SPF macro-string expansion (RFC 7208 §7) ----
312
+ //
313
+ // A macro-string is `*( macro-expand / macro-literal )`. A macro-expand
314
+ // is `"%{" macro-letter transformers *delimiter "}"` (RFC 7208 §7.1).
315
+ // The legacy `%%`, `%_`, `%-` escapes expand to "%", " ", "%20".
316
+ //
317
+ // Macro letters (RFC 7208 §7.2):
318
+ // s = <sender> (the MAIL FROM / HELO identity, localpart@domain)
319
+ // l = local-part of <sender>
320
+ // o = domain of <sender>
321
+ // d = <domain> (the SPF record's current domain)
322
+ // i = <ip> (dotted-decimal for IPv4; nibble-dotted-hex for IPv6)
323
+ // p = the validated domain name of <ip> (PTR — discouraged §5.5; "unknown"
324
+ // absent a validated name; the framework returns "unknown" rather
325
+ // than performing the discouraged reverse-lookup)
326
+ // v = "in-addr" for IPv4, "ip6" for IPv6
327
+ // h = HELO/EHLO domain
328
+ // c / r / t = SMTP-time-only macros (exp= text); not valid in a
329
+ // checked macro-string, so we expand them to empty in mechanism
330
+ // context per §7.3's split between "macro-string" and the
331
+ // exp-only letters.
332
+ //
333
+ // Transformers (RFC 7208 §7.1): an optional digit count limits the
334
+ // number of right-hand parts kept after a split; an optional `r`
335
+ // reverses the parts; an optional delimiter set (any of `.-+,/_=`)
336
+ // replaces "." as the split delimiter. After transforms, the parts are
337
+ // re-joined with ".".
338
+ //
339
+ // Length bound: the expanded macro-string is capped so a hostile policy
340
+ // can't inflate a DNS qname past the RFC 1035 §3.1 255-octet ceiling
341
+ // (the resulting name is used as a DNS query). RFC 7208 §7.1 mandates a
342
+ // 253-octet limit on the constructed domain-name; we cap the assembled
343
+ // string the same way.
344
+ var SPF_MACRO_MAX_EXPANDED_BYTES = 253; // RFC 1035 §3.1 / RFC 7208 §7.1 name ceiling
345
+ var SPF_MACRO_DELIMS = ".-+,/_="; // RFC 7208 §7.1 delimiter set
346
+
347
+ // IPv6 nibble-dotted form for the `i` macro (RFC 7208 §7.3): each of the
348
+ // 32 hex nibbles becomes its own "."-separated part. e.g.
349
+ // 2001:db8::1 → "2.0.0.1.0.d.b.8.0.…0.0.0.1".
350
+ function _ipv6Nibbles(ip) {
351
+ var groups = ipUtils.expandIpv6Groups(ip);
352
+ if (!groups) return null;
353
+ var nibbles = [];
354
+ for (var i = 0; i < groups.length; i += 1) {
355
+ var s = groups[i].toString(16); // hex radix
356
+ while (s.length < 4) s = "0" + s; // IPv6 group nibble count
357
+ for (var j = 0; j < 4; j += 1) nibbles.push(s.charAt(j)); // IPv6 group nibble count
358
+ }
359
+ return nibbles;
360
+ }
361
+
362
+ // Resolve a single macro letter to its base string value (pre-transform).
363
+ // `vars` carries { ip, isIpv6, sender, localPart, senderDomain, domain,
364
+ // helo }. Letters not meaningful in mechanism context expand to "".
365
+ function _spfMacroValue(letter, vars) {
366
+ var lower = letter.toLowerCase();
367
+ switch (lower) {
368
+ case "s": return vars.sender || "";
369
+ case "l": return vars.localPart || "";
370
+ case "o": return vars.senderDomain || "";
371
+ case "d": return vars.domain || "";
372
+ case "h": return vars.helo || "";
373
+ case "v": return vars.isIpv6 ? "ip6" : "in-addr";
374
+ case "i":
375
+ if (vars.isIpv6) {
376
+ var nib = _ipv6Nibbles(vars.ip);
377
+ return nib ? nib.join(".") : "";
378
+ }
379
+ return vars.ip || "";
380
+ // RFC 7208 §5.5 — `p` (validated domain name) is "strongly
381
+ // discouraged"; resolving it requires the reverse-DNS path the
382
+ // framework intentionally does not perform here. Expand to the
383
+ // RFC-mandated sentinel so an `exists:%{p}...` policy degrades to a
384
+ // deterministic miss rather than a forged match.
385
+ case "p": return "unknown";
386
+ // c / r / t are exp-text-only macros (RFC 7208 §7.3); empty in
387
+ // mechanism context.
388
+ default: return "";
389
+ }
390
+ }
391
+
392
+ // Split `value` on the active delimiter chars, optionally reverse, keep
393
+ // the rightmost `digits` parts, re-join with ".". RFC 7208 §7.1.
394
+ function _spfApplyTransform(value, digits, reverse, delims) {
395
+ if (value.length === 0) return "";
396
+ // Build a character class from the (validated) delimiter set. Each
397
+ // delim char is one of `.-+,/_=` — all regex-safe except none need
398
+ // escaping inside a class except `-` which we place last; the set is
399
+ // a fixed allowlist so no untrusted metacharacter reaches the class.
400
+ var splitParts;
401
+ if (delims === ".") {
402
+ splitParts = value.split(".");
403
+ } else {
404
+ var out = [];
405
+ var cur = "";
406
+ for (var ci = 0; ci < value.length; ci += 1) {
407
+ var ch = value.charAt(ci);
408
+ if (delims.indexOf(ch) !== -1) { out.push(cur); cur = ""; }
409
+ else cur += ch;
410
+ }
411
+ out.push(cur);
412
+ splitParts = out;
413
+ }
414
+ if (reverse) splitParts = splitParts.slice().reverse();
415
+ if (digits !== null && digits > 0 && digits < splitParts.length) {
416
+ splitParts = splitParts.slice(splitParts.length - digits);
417
+ }
418
+ return splitParts.join(".");
419
+ }
420
+
421
+ // Expand an SPF macro-string (RFC 7208 §7.1). `vars` is the macro
422
+ // variable bag. Throws MailAuthError on malformed `%` syntax (a bare
423
+ // `%` not followed by `{`, `%`, `_`, or `-` is a syntax error per
424
+ // §7.1 — receivers MUST permerror, mirrored by the caller catching the
425
+ // throw). The expanded result is byte-capped (§7.1 / RFC 1035 §3.1).
426
+ //
427
+ // The scanner is a single linear left-to-right pass (no backtracking
428
+ // regex over untrusted input): each `%{...}` token is matched by an
429
+ // index walk to the closing `}`, bounding work at O(n) in the macro
430
+ // length.
431
+ function _spfExpandMacros(macroString, vars) {
432
+ if (typeof macroString !== "string" || macroString.indexOf("%") === -1) {
433
+ return macroString;
434
+ }
435
+ var out = "";
436
+ var n = macroString.length;
437
+ var i = 0;
438
+ while (i < n) {
439
+ var ch = macroString.charAt(i);
440
+ if (ch !== "%") { out += ch; i += 1; continue; }
441
+ // ch === "%": peek the next char.
442
+ if (i + 1 >= n) {
443
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
444
+ "SPF macro-string ends with a bare '%' (RFC 7208 §7.1)");
445
+ }
446
+ var next = macroString.charAt(i + 1);
447
+ if (next === "%") { out += "%"; i += 2; continue; }
448
+ if (next === "_") { out += " "; i += 2; continue; }
449
+ if (next === "-") { out += "%20"; i += 2; continue; }
450
+ if (next !== "{") {
451
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
452
+ "SPF macro escape '%" + next + "' is invalid (RFC 7208 §7.1 allows %%, %_, %-, %{...})");
453
+ }
454
+ // next === "{": find the closing "}".
455
+ var close = macroString.indexOf("}", i + 2);
456
+ if (close === -1) {
457
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
458
+ "SPF macro '%{' has no closing '}' (RFC 7208 §7.1)");
459
+ }
460
+ var body = macroString.slice(i + 2, close);
461
+ // body = macro-letter [ digits ] [ "r" ] *delimiter (RFC 7208 §7.1)
462
+ if (body.length === 0) {
463
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
464
+ "SPF macro '%{}' is empty (RFC 7208 §7.1)");
465
+ }
466
+ var letter = body.charAt(0);
467
+ if (!/^[slodiphcrtv]$/i.test(letter)) {
468
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
469
+ "SPF macro letter " + JSON.stringify(letter) + " is not a valid macro-letter (RFC 7208 §7.2)");
470
+ }
471
+ var rest = body.slice(1);
472
+ var digits = null;
473
+ var di = 0;
474
+ while (di < rest.length && rest.charAt(di) >= "0" && rest.charAt(di) <= "9") di += 1;
475
+ if (di > 0) {
476
+ digits = parseInt(rest.slice(0, di), 10);
477
+ if (!isFinite(digits) || digits < 1) {
478
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
479
+ "SPF macro transformer digit count must be >= 1 (RFC 7208 §7.1): " + JSON.stringify(body));
480
+ }
481
+ }
482
+ rest = rest.slice(di);
483
+ var reverse = false;
484
+ if (rest.length > 0 && (rest.charAt(0) === "r" || rest.charAt(0) === "R")) {
485
+ reverse = true;
486
+ rest = rest.slice(1);
487
+ }
488
+ // Remaining chars are the optional delimiter set; each MUST be one
489
+ // of the RFC 7208 §7.1 delimiters. Anything else is malformed.
490
+ var delims = "";
491
+ for (var ri = 0; ri < rest.length; ri += 1) {
492
+ var dch = rest.charAt(ri);
493
+ if (SPF_MACRO_DELIMS.indexOf(dch) === -1) {
494
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
495
+ "SPF macro delimiter " + JSON.stringify(dch) + " is not in the RFC 7208 §7.1 set " +
496
+ JSON.stringify(SPF_MACRO_DELIMS));
497
+ }
498
+ if (delims.indexOf(dch) === -1) delims += dch;
499
+ }
500
+ if (delims.length === 0) delims = ".";
501
+ var base = _spfMacroValue(letter, vars);
502
+ out += _spfApplyTransform(base, digits, reverse, delims);
503
+ i = close + 1;
504
+ }
505
+ if (out.length > SPF_MACRO_MAX_EXPANDED_BYTES) {
506
+ // RFC 7208 §7.1 — the constructed domain-name is left-truncated to
507
+ // fit the 253-octet ceiling: leading labels are discarded until the
508
+ // remainder fits. This keeps the trailing (more-significant) labels
509
+ // the policy author intends as the lookup target.
510
+ while (out.length > SPF_MACRO_MAX_EXPANDED_BYTES) {
511
+ var dot = out.indexOf(".");
512
+ if (dot === -1) { out = out.slice(out.length - SPF_MACRO_MAX_EXPANDED_BYTES); break; }
513
+ out = out.slice(dot + 1);
514
+ }
515
+ }
516
+ return out;
517
+ }
518
+
311
519
  // Parse an SPF record into mechanisms.
312
520
  function _parseSpfRecord(text) {
313
521
  var trimmed = text.trim();
@@ -503,11 +711,25 @@ function _parseADualCidr(raw, mech, defaultDomain) {
503
711
  // { match: false } — no IP matched / record absent
504
712
  // { error: "temperror", reason: "..." } — transient DNS failure
505
713
  // { error: "permerror", reason: "..." } — over-limit / bad CIDR / bad MX count
506
- async function _spfMatchAMx(mech, raw, ip, isIpv6, defaultDomain, dnsLookup, lookups) {
714
+ async function _spfMatchAMx(mech, raw, ip, isIpv6, defaultDomain, dnsLookup, lookups, macroVars) {
507
715
  var parsed;
508
716
  try { parsed = _parseADualCidr(raw, mech, defaultDomain); }
509
717
  catch (e) { return { error: "permerror", reason: e.message }; }
510
718
 
719
+ // RFC 7208 §5.3 / §5.4 — the domain-spec after `a:` / `mx:` is a
720
+ // macro-string (§7). Expand it before resolving so policies like
721
+ // `a:%{i}._ah.example.com` evaluate correctly. The default-domain
722
+ // case (`a` / `mx` with no `:domain`) carries no `%` and passes
723
+ // through untouched.
724
+ if (macroVars && parsed.domain.indexOf("%") !== -1) {
725
+ try { parsed.domain = _spfExpandMacros(parsed.domain, macroVars).toLowerCase(); }
726
+ catch (e) { return { error: "permerror", reason: e.message }; }
727
+ if (!parsed.domain || parsed.domain.length === 0) {
728
+ return { error: "permerror",
729
+ reason: "SPF " + mech + ": domain-spec expanded to empty (RFC 7208 §7)" };
730
+ }
731
+ }
732
+
511
733
  var mask = isIpv6 ? parsed.v6Mask : parsed.v4Mask;
512
734
  var family = isIpv6 ? 6 : 4; // IP family marker
513
735
 
@@ -580,9 +802,9 @@ async function _spfMatchAMx(mech, raw, ip, isIpv6, defaultDomain, dnsLookup, loo
580
802
  }
581
803
 
582
804
  // SPF verify — recursive include resolution + ip4 / ip6 / a / mx /
583
- // include / all / redirect=. The `exists` and `ptr` mechanisms +
584
- // macro-string expansion remain deferred (see the mechanism dispatch
585
- // arm for the Re-open condition + operator escape hatch).
805
+ // include / exists / all / redirect=, with RFC 7208 §7 macro expansion.
806
+ // The `ptr` mechanism remains deferred (see the dispatch arm for the
807
+ // Re-open condition + operator escape hatch via b.mail.iprev.verify).
586
808
  async function spfVerify(opts) {
587
809
  opts = opts || {};
588
810
  validateOpts(opts, ["ip", "mailFrom", "helo", "dnsLookup"], "mail.spf.verify");
@@ -599,6 +821,29 @@ async function spfVerify(opts) {
599
821
  }
600
822
 
601
823
  var lookups = { count: 0, limit: SPF_DNS_LOOKUP_LIMIT, void: 0 };
824
+ // RFC 7208 §7 macro variable bag. `<sender>` is the MAIL FROM identity
825
+ // when present, else `postmaster@<helo>` per §4.3 (the localpart
826
+ // defaults to "postmaster" when the reverse-path is empty / HELO is
827
+ // the checked identity). `<domain>` (%{d}) tracks the SPF record's
828
+ // current domain and is rebound at each include/redirect re-entry.
829
+ var senderIdentity = opts.mailFrom
830
+ ? String(opts.mailFrom)
831
+ : ("postmaster@" + String(opts.helo || domain));
832
+ var senderLocal = senderIdentity.indexOf("@") !== -1
833
+ ? senderIdentity.slice(0, senderIdentity.indexOf("@"))
834
+ : "postmaster";
835
+ var senderDomain = senderIdentity.indexOf("@") !== -1
836
+ ? senderIdentity.slice(senderIdentity.indexOf("@") + 1)
837
+ : String(opts.helo || domain);
838
+ var macroVars = {
839
+ ip: opts.ip,
840
+ isIpv6: opts.ip.indexOf(":") !== -1,
841
+ sender: senderIdentity,
842
+ localPart: senderLocal,
843
+ senderDomain: senderDomain,
844
+ domain: domain.toLowerCase(),
845
+ helo: typeof opts.helo === "string" ? opts.helo : "",
846
+ };
602
847
  // RFC 7208 §4.6.4 — the initial query for the sender domain's SPF
603
848
  // record itself does NOT count toward the 10-lookup limit. Only
604
849
  // include / a / mx / ptr / exists / redirect mechanisms count.
@@ -606,7 +851,7 @@ async function spfVerify(opts) {
606
851
  // got false permerror.
607
852
  var result = await _spfEvaluateDomain(domain.toLowerCase(), opts.ip,
608
853
  opts.dnsLookup, lookups,
609
- { isInitial: true });
854
+ { isInitial: true, macroVars: macroVars });
610
855
  return {
611
856
  result: result.verdict, // pass | fail | softfail | neutral | none | temperror | permerror
612
857
  domain: domain,
@@ -660,6 +905,12 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
660
905
  return { verdict: "permerror", explanation: e.message };
661
906
  }
662
907
 
908
+ // RFC 7208 §7.2 — `%{d}` is the SPF record's CURRENT domain, which is
909
+ // rebound at each include / redirect re-entry. Clone the inherited
910
+ // macro bag with `domain` pinned to the domain we're evaluating now.
911
+ var baseMacroVars = ctx.macroVars || {};
912
+ var macroVars = Object.assign({}, baseMacroVars, { domain: domain });
913
+
663
914
  var isIpv6 = ip.indexOf(":") !== -1;
664
915
  for (var i = 0; i < mechanisms.length; i += 1) {
665
916
  var m = mechanisms[i];
@@ -671,7 +922,15 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
671
922
  if (m.arg && _ipv6InCidr(ip, m.arg)) match = true;
672
923
  } else if (m.mechanism === "include") {
673
924
  if (!m.arg) continue;
674
- var inner = await _spfEvaluateDomain(m.arg.toLowerCase(), ip, dnsLookup, lookups);
925
+ // RFC 7208 §7 the include target may itself be a macro-string
926
+ // (e.g. `include:%{d}.spf.example.net`). Expand against the
927
+ // current macro bag before recursing.
928
+ var includeTarget;
929
+ try { includeTarget = _spfExpandMacros(m.arg, macroVars); }
930
+ catch (e) { return { verdict: "permerror", explanation: e.message }; }
931
+ var inner = await _spfEvaluateDomain(includeTarget.toLowerCase(), ip,
932
+ dnsLookup, lookups,
933
+ { macroVars: macroVars });
675
934
  if (inner.verdict === "pass") match = true;
676
935
  else if (inner.verdict === "permerror" || inner.verdict === "temperror") {
677
936
  return inner;
@@ -701,7 +960,7 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
701
960
  m.mechanism };
702
961
  }
703
962
  var amRes = await _spfMatchAMx(m.mechanism, m.raw, ip, isIpv6,
704
- domain, dnsLookup, lookups);
963
+ domain, dnsLookup, lookups, macroVars);
705
964
  if (amRes.error === "permerror") {
706
965
  return { verdict: "permerror", explanation: amRes.reason };
707
966
  }
@@ -709,45 +968,69 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
709
968
  return { verdict: "temperror", explanation: amRes.reason };
710
969
  }
711
970
  if (amRes.match) match = true;
712
- } else if (m.mechanism === "exists" || m.mechanism === "ptr") {
713
- // RFC 7208 §5.7 (exists) + §5.5 (ptr) — deferred from v0.11.3.
714
- //
715
- // exists: requires macro-string expansion (RFC 7208 §7) to be
716
- // useful in practice; almost every published `exists:` policy
717
- // uses macros like `exists:%{l}.%{d}._spf.example.com` to do
718
- // per-recipient or per-IP lookups. A non-macro `exists:` is
719
- // technically valid but vanishingly rare in published policies.
720
- //
721
- // ptr: RFC 7208 §5.5 explicitly says "use of this mechanism
722
- // is strongly discouraged" the receiver does reverse-DNS +
723
- // forward-confirm per query, doubling DNS load and tying the
724
- // sender's authz to whoever controls their PTR zone. Despite
725
- // this discouragement, a small minority of legacy senders
726
- // still publish `+ptr -all` policies as their only SPF stance.
971
+ } else if (m.mechanism === "exists") {
972
+ // RFC 7208 §5.7 — `exists:<domain-spec>`. The domain-spec is
973
+ // macro-expanded (§7) and an A query is performed; the mechanism
974
+ // matches when ANY A record exists (the address is irrelevant
975
+ // existence alone is the signal, so an AAAA-only target does NOT
976
+ // match per the spec's "A query" wording). Published policies use
977
+ // it for per-IP / per-recipient lookups like
978
+ // `exists:%{ir}.%{v}._spf.example.com`.
979
+ if (!m.arg) continue;
980
+ var existsTarget;
981
+ try { existsTarget = _spfExpandMacros(m.arg, macroVars); }
982
+ catch (e) { return { verdict: "permerror", explanation: e.message }; }
983
+ if (!existsTarget || existsTarget.length === 0) {
984
+ return { verdict: "permerror",
985
+ explanation: "SPF exists: expanded to an empty domain (RFC 7208 §5.7)" };
986
+ }
987
+ // §4.6.4 — the exists A query counts as one DNS-touching lookup.
988
+ lookups.count += 1;
989
+ if (lookups.count > lookups.limit) {
990
+ return { verdict: "permerror",
991
+ explanation: "DNS lookup limit exceeded (RFC 7208 §4.6.4) at exists:" +
992
+ existsTarget };
993
+ }
994
+ var existsHit = false;
995
+ try {
996
+ var existsIps = await _safeResolveA(existsTarget.toLowerCase(), 4, dnsLookup);
997
+ existsHit = Array.isArray(existsIps) && existsIps.length > 0;
998
+ } catch (e) {
999
+ var ecode = e && e.code;
1000
+ if (ecode === "ENOTFOUND" || ecode === "ENODATA") {
1001
+ // Void lookup — RFC 7208 §4.6.4 ceiling. A non-existent target
1002
+ // is a miss, not an error, but charges the void slot so a
1003
+ // chain of exists: misses can't amplify resolver work.
1004
+ lookups.void = (lookups.void || 0) + 1;
1005
+ if (lookups.void > SPF_VOID_LOOKUP_LIMIT) {
1006
+ return { verdict: "permerror",
1007
+ explanation: "SPF void-lookup limit exceeded (RFC 7208 §4.6.4) during exists: evaluation" };
1008
+ }
1009
+ existsHit = false;
1010
+ } else {
1011
+ return { verdict: "temperror",
1012
+ explanation: "SPF exists:" + existsTarget + " lookup failed: " +
1013
+ ((e && e.message) || String(e)) };
1014
+ }
1015
+ }
1016
+ if (existsHit) match = true;
1017
+ } else if (m.mechanism === "ptr") {
1018
+ // RFC 7208 §5.5 — `ptr` is "strongly discouraged": it ties the
1019
+ // sender's authorization to whoever controls the connecting IP's
1020
+ // PTR zone and doubles DNS load (reverse + forward-confirm per
1021
+ // query). A small minority of legacy senders still publish
1022
+ // `+ptr -all` as their only stance.
727
1023
  //
728
- // Re-open conditions:
729
- // - exists: macro-string expansion lands in the framework (a
730
- // standalone slice; tracked under blamejs-roadmap.md), OR an
731
- // operator surfaces a real `exists:` policy without macros
732
- // and asks for the simple A-existence form.
733
- // - ptr: an operator surfaces a legitimate sender whose
734
- // ONLY SPF stance is `ptr` and needs the framework to
735
- // evaluate it (rather than the operator's MTA already doing
736
- // iprev via `b.mail.auth.iprev`).
1024
+ // Re-open condition: an operator surfaces a legitimate sender
1025
+ // whose ONLY SPF stance is `ptr` and needs the framework to
1026
+ // evaluate it rather than the MTA already doing iprev.
737
1027
  //
738
- // Operator escape hatch today:
739
- // - exists: senders almost universally have a non-`exists:`
740
- // mechanism alongside; the framework returns "permerror"
741
- // here, surfacing the gap, but legitimate mail flow that
742
- // ALSO carries a passing ip4/ip6/include path is unaffected.
743
- // - ptr: operators evaluating a ptr-only sender wire
744
- // `b.mail.auth.iprev(ip)` and treat fcrdns=true the same as
745
- // SPF pass for that domain.
1028
+ // Operator escape hatch today: wire `b.mail.iprev.verify(ip)` and
1029
+ // treat fcrdns=true the same as an SPF pass for that domain.
746
1030
  return {
747
1031
  verdict: "permerror",
748
- explanation: "SPF mechanism '" + m.mechanism + "' is not yet implemented (RFC 7208 §" +
749
- (m.mechanism === "exists" ? "5.7 + §7 macros" : "5.5") +
750
- "); senders typically publish ip4 / ip6 / a / mx / include alongside",
1032
+ explanation: "SPF mechanism 'ptr' is not implemented (RFC 7208 §5.5 — strongly " +
1033
+ "discouraged); use b.mail.iprev.verify for forward-confirmed reverse DNS",
751
1034
  };
752
1035
  }
753
1036
  if (match) {
@@ -772,10 +1055,14 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
772
1055
  var mods = mechanisms.modifiers || [];
773
1056
  for (var rmi = 0; rmi < mods.length; rmi += 1) {
774
1057
  if (mods[rmi].name === "redirect" && mods[rmi].value) {
775
- // Redirect counts as one DNS-mechanism per §4.6.4.
1058
+ // Redirect counts as one DNS-mechanism per §4.6.4. RFC 7208 §7 —
1059
+ // the redirect target may be a macro-string; expand it first.
1060
+ var redirectTarget;
1061
+ try { redirectTarget = _spfExpandMacros(mods[rmi].value, macroVars); }
1062
+ catch (e) { return { verdict: "permerror", explanation: e.message }; }
776
1063
  var redirected = await _spfEvaluateDomain(
777
- mods[rmi].value.toLowerCase(), ip, dnsLookup, lookups,
778
- { redirectDepth: (ctx.redirectDepth || 0) + 1 });
1064
+ redirectTarget.toLowerCase(), ip, dnsLookup, lookups,
1065
+ { redirectDepth: (ctx.redirectDepth || 0) + 1, macroVars: macroVars });
779
1066
  // RFC 7208 §6.1 — if the redirect target has no SPF record,
780
1067
  // permerror (the operator's intent is unverifiable).
781
1068
  if (redirected.verdict === "none") {
@@ -1991,6 +2278,217 @@ function _shapeAggregateReport(parsed) {
1991
2278
  return shaped;
1992
2279
  }
1993
2280
 
2281
+ // ---- DMARC aggregate (RUA) report builder/serializer (RFC 7489 Appendix C) ----
2282
+ //
2283
+ // The inverse of dmarcParseAggregateReport: an MTA acting as the
2284
+ // REPORTING side (it received mail under another domain's DMARC policy
2285
+ // and now owes that domain an aggregate report) serializes its
2286
+ // observation rows into the RFC 7489 Appendix C `<feedback>` XML.
2287
+ //
2288
+ // The builder accepts the SAME shaped object dmarcParseAggregateReport
2289
+ // returns (reportMetadata / policyPublished / records[...]), so a parsed
2290
+ // report round-trips back to identical structure. Operators may also
2291
+ // hand-assemble the shape directly.
2292
+ //
2293
+ // var xml = b.mail.dmarc.buildAggregateReport({
2294
+ // reportMetadata: { orgName, email, reportId, dateRange: { begin, end } },
2295
+ // policyPublished: { domain, adkim, aspf, p, sp, pct },
2296
+ // records: [{ sourceIp, count,
2297
+ // dispositions: { disposition, dkim, spf, reasons },
2298
+ // identifiers: { headerFrom, envelopeFrom, envelopeTo },
2299
+ // authResults: { dkim: [...], spf: [...] } }],
2300
+ // });
2301
+ // // → "<?xml version=\"1.0\" ...?>\n<feedback>...</feedback>"
2302
+ //
2303
+ // Validation tier: config-time/entry-point — the report shape is
2304
+ // operator-assembled structured data, so a malformed shape (missing
2305
+ // reportMetadata / policyPublished / non-array records) THROWS so the
2306
+ // operator catches the mistake before the report is mailed to a peer.
2307
+ //
2308
+ // XML safety: every emitted text node and the (rare) attribute-free
2309
+ // element bodies are escaped through _xmlEscapeText, which neutralizes
2310
+ // `& < > " '`. Source IPs, domains, and identifiers can carry
2311
+ // attacker-influenced bytes (a spoofed envelope-from observed in the
2312
+ // wild); escaping prevents a crafted observation from injecting markup
2313
+ // into the report a peer will parse.
2314
+
2315
+ // RFC 7489 Appendix C — the report is plain-element XML (no attributes
2316
+ // in the schema), so only the five XML text-content metacharacters need
2317
+ // neutralizing. Numeric / enum fields are coerced and range-checked
2318
+ // before they reach here, but escaping is applied uniformly so a future
2319
+ // caller can't bypass it.
2320
+ function _xmlEscapeText(value) {
2321
+ return String(value)
2322
+ .replace(/&/g, "&amp;")
2323
+ .replace(/</g, "&lt;")
2324
+ .replace(/>/g, "&gt;")
2325
+ .replace(/"/g, "&quot;")
2326
+ .replace(/'/g, "&apos;");
2327
+ }
2328
+
2329
+ // Emit `<tag>escaped-text</tag>` when value is non-null/defined; emit
2330
+ // nothing when the field is absent (RFC 7489 Appendix C marks many
2331
+ // child elements optional — omitting is correct, emitting an empty
2332
+ // element changes the parsed shape).
2333
+ function _xmlLeaf(tag, value) {
2334
+ if (value === undefined || value === null || value === "") return "";
2335
+ return "<" + tag + ">" + _xmlEscapeText(value) + "</" + tag + ">";
2336
+ }
2337
+
2338
+ // Integer leaf — coerce, refuse non-finite (a NaN count would serialize
2339
+ // as the string "NaN" and corrupt the peer's parse).
2340
+ function _xmlIntLeaf(tag, value) {
2341
+ if (value === undefined || value === null) return "";
2342
+ var n = typeof value === "number" ? value : parseInt(value, 10);
2343
+ if (!isFinite(n)) {
2344
+ throw new MailAuthError("mail-auth/dmarc-rua-build-bad-int",
2345
+ "dmarc.buildAggregateReport: " + tag + " must be a finite integer, got " + JSON.stringify(value));
2346
+ }
2347
+ return "<" + tag + ">" + String(Math.trunc(n)) + "</" + tag + ">";
2348
+ }
2349
+
2350
+ function _buildAuthResultsXml(authResults) {
2351
+ var ar = authResults || {};
2352
+ var parts = [];
2353
+ var dkimRows = Array.isArray(ar.dkim) ? ar.dkim : [];
2354
+ for (var i = 0; i < dkimRows.length; i += 1) {
2355
+ var d = dkimRows[i] || {};
2356
+ parts.push(
2357
+ "<dkim>" +
2358
+ _xmlLeaf("domain", d.domain) +
2359
+ _xmlLeaf("selector", d.selector) +
2360
+ _xmlLeaf("result", d.result) +
2361
+ _xmlLeaf("human_result", d.humanResult) +
2362
+ "</dkim>");
2363
+ }
2364
+ var spfRows = Array.isArray(ar.spf) ? ar.spf : [];
2365
+ for (var j = 0; j < spfRows.length; j += 1) {
2366
+ var s = spfRows[j] || {};
2367
+ parts.push(
2368
+ "<spf>" +
2369
+ _xmlLeaf("domain", s.domain) +
2370
+ _xmlLeaf("scope", s.scope) +
2371
+ _xmlLeaf("result", s.result) +
2372
+ "</spf>");
2373
+ }
2374
+ return "<auth_results>" + parts.join("") + "</auth_results>";
2375
+ }
2376
+
2377
+ function dmarcBuildAggregateReport(report, opts) {
2378
+ opts = opts || {};
2379
+ if (!report || typeof report !== "object") {
2380
+ throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
2381
+ "dmarc.buildAggregateReport: report must be an object");
2382
+ }
2383
+ var rm = report.reportMetadata;
2384
+ var pp = report.policyPublished;
2385
+ if (!rm || typeof rm !== "object") {
2386
+ throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
2387
+ "dmarc.buildAggregateReport: report.reportMetadata is required (RFC 7489 Appendix C)");
2388
+ }
2389
+ if (!pp || typeof pp !== "object") {
2390
+ throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
2391
+ "dmarc.buildAggregateReport: report.policyPublished is required (RFC 7489 Appendix C)");
2392
+ }
2393
+ var records = report.records;
2394
+ if (!Array.isArray(records)) {
2395
+ throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
2396
+ "dmarc.buildAggregateReport: report.records must be an array");
2397
+ }
2398
+ if (records.length > DMARC_RUA_MAX_RECORDS_PER_REPORT) {
2399
+ throw new MailAuthError("mail-auth/dmarc-rua-build-too-many-records",
2400
+ "dmarc.buildAggregateReport: " + records.length + " records exceeds cap " +
2401
+ DMARC_RUA_MAX_RECORDS_PER_REPORT);
2402
+ }
2403
+
2404
+ // report_metadata (RFC 7489 Appendix C). date_range is two epoch
2405
+ // seconds; org_name + report_id are mandatory per the schema.
2406
+ var dateRange = rm.dateRange || {};
2407
+ var metaXml =
2408
+ "<report_metadata>" +
2409
+ _xmlLeaf("org_name", rm.orgName) +
2410
+ _xmlLeaf("email", rm.email) +
2411
+ _xmlLeaf("extra_contact_info", rm.extraContact) +
2412
+ _xmlLeaf("report_id", rm.reportId) +
2413
+ "<date_range>" +
2414
+ _xmlIntLeaf("begin", dateRange.begin) +
2415
+ _xmlIntLeaf("end", dateRange.end) +
2416
+ "</date_range>" +
2417
+ "</report_metadata>";
2418
+
2419
+ // policy_published (RFC 7489 Appendix C).
2420
+ var policyXml =
2421
+ "<policy_published>" +
2422
+ _xmlLeaf("domain", pp.domain) +
2423
+ _xmlLeaf("adkim", pp.adkim) +
2424
+ _xmlLeaf("aspf", pp.aspf) +
2425
+ _xmlLeaf("p", pp.p) +
2426
+ _xmlLeaf("sp", pp.sp) +
2427
+ (pp.pct === undefined || pp.pct === null ? "" : _xmlIntLeaf("pct", pp.pct)) +
2428
+ _xmlLeaf("fo", pp.fo) +
2429
+ "</policy_published>";
2430
+
2431
+ // record[] rows. Each row: source_ip + count + policy_evaluated +
2432
+ // identifiers + auth_results.
2433
+ var recordXml = "";
2434
+ for (var i = 0; i < records.length; i += 1) {
2435
+ var rec = records[i] || {};
2436
+ var disp = rec.dispositions || {};
2437
+ var ids = rec.identifiers || {};
2438
+ var reasonRows = Array.isArray(disp.reasons) ? disp.reasons : [];
2439
+ var reasonXml = "";
2440
+ for (var ri = 0; ri < reasonRows.length; ri += 1) {
2441
+ var rs = reasonRows[ri] || {};
2442
+ reasonXml +=
2443
+ "<reason>" +
2444
+ _xmlLeaf("type", rs.type) +
2445
+ _xmlLeaf("comment", rs.comment) +
2446
+ "</reason>";
2447
+ }
2448
+ recordXml +=
2449
+ "<record>" +
2450
+ "<row>" +
2451
+ _xmlLeaf("source_ip", rec.sourceIp) +
2452
+ _xmlIntLeaf("count", rec.count) +
2453
+ "<policy_evaluated>" +
2454
+ _xmlLeaf("disposition", disp.disposition) +
2455
+ _xmlLeaf("dkim", disp.dkim) +
2456
+ _xmlLeaf("spf", disp.spf) +
2457
+ reasonXml +
2458
+ "</policy_evaluated>" +
2459
+ "</row>" +
2460
+ "<identifiers>" +
2461
+ _xmlLeaf("envelope_to", ids.envelopeTo) +
2462
+ _xmlLeaf("envelope_from", ids.envelopeFrom) +
2463
+ _xmlLeaf("header_from", ids.headerFrom) +
2464
+ "</identifiers>" +
2465
+ _buildAuthResultsXml(rec.authResults) +
2466
+ "</record>";
2467
+ }
2468
+
2469
+ // RFC 7489 §7.2.1.1 — report-format version is "1.0" (the `version`
2470
+ // element under <feedback>). Emit the XML declaration + a single
2471
+ // <feedback> root so the output round-trips through safeXml.parse.
2472
+ var version = _xmlLeaf("version", opts.version || "1.0");
2473
+ var doc =
2474
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
2475
+ "<feedback>" +
2476
+ version +
2477
+ metaXml +
2478
+ policyXml +
2479
+ recordXml +
2480
+ "</feedback>";
2481
+
2482
+ // Optional gzip per the same transport convention the parser accepts
2483
+ // (RFC 1952). Default is raw XML; operators opt into compression for
2484
+ // the mail attachment. Back-compat: default behavior is unchanged
2485
+ // (raw string out) — gzip is strictly opt-in.
2486
+ if (opts.gzip === true) {
2487
+ return zlib.gzipSync(Buffer.from(doc, "utf8"));
2488
+ }
2489
+ return doc;
2490
+ }
2491
+
1994
2492
  // ---- iprev (RFC 8601 §3) — Forward-Confirmed Reverse DNS verifier ----
1995
2493
  //
1996
2494
  // The receiving SMTP server reverse-resolves the connecting peer's IP
@@ -2126,6 +2624,7 @@ module.exports = {
2126
2624
  evaluate: dmarcEvaluate,
2127
2625
  parseRecord: _parseDmarcRecord,
2128
2626
  parseAggregateReport: dmarcParseAggregateReport,
2627
+ buildAggregateReport: dmarcBuildAggregateReport,
2129
2628
  }),
2130
2629
  arc: Object.freeze({
2131
2630
  verify: arcVerify,