@blamejs/core 0.14.18 → 0.14.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/lib/archive.js +206 -52
- package/lib/auth/oauth.js +794 -1
- package/lib/auth/oid4vci.js +84 -27
- package/lib/auth/sd-jwt-vc-holder.js +46 -1
- package/lib/crypto-field.js +274 -17
- package/lib/mail-auth.js +887 -55
- package/lib/middleware/fetch-metadata.js +115 -14
- package/lib/middleware/scim-server.js +294 -10
- package/lib/middleware/security-headers.js +47 -0
- package/lib/observability.js +39 -1
- package/lib/openapi-paths-builder.js +105 -29
- package/lib/openapi.js +225 -100
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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 /
|
|
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
|
|
23
|
-
*
|
|
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;
|
|
@@ -50,6 +50,7 @@ var validateOpts = require("./validate-opts");
|
|
|
50
50
|
var bCrypto = require("./crypto");
|
|
51
51
|
var C = require("./constants");
|
|
52
52
|
var dkim = require("./mail-dkim");
|
|
53
|
+
var mimeParse = require("./mime-parse");
|
|
53
54
|
var safeXml = require("./parsers/safe-xml");
|
|
54
55
|
var ipUtils = require("./ip-utils");
|
|
55
56
|
var publicSuffix = require("./public-suffix");
|
|
@@ -308,6 +309,214 @@ function _ipv4InCidr(ip, cidr) {
|
|
|
308
309
|
return (BigInt(ipInt) & maskInt) === (BigInt(netInt) & maskInt);
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
// ---- SPF macro-string expansion (RFC 7208 §7) ----
|
|
313
|
+
//
|
|
314
|
+
// A macro-string is `*( macro-expand / macro-literal )`. A macro-expand
|
|
315
|
+
// is `"%{" macro-letter transformers *delimiter "}"` (RFC 7208 §7.1).
|
|
316
|
+
// The legacy `%%`, `%_`, `%-` escapes expand to "%", " ", "%20".
|
|
317
|
+
//
|
|
318
|
+
// Macro letters (RFC 7208 §7.2):
|
|
319
|
+
// s = <sender> (the MAIL FROM / HELO identity, localpart@domain)
|
|
320
|
+
// l = local-part of <sender>
|
|
321
|
+
// o = domain of <sender>
|
|
322
|
+
// d = <domain> (the SPF record's current domain)
|
|
323
|
+
// i = <ip> (dotted-decimal for IPv4; nibble-dotted-hex for IPv6)
|
|
324
|
+
// p = the validated domain name of <ip> (PTR — discouraged §5.5; "unknown"
|
|
325
|
+
// absent a validated name; the framework returns "unknown" rather
|
|
326
|
+
// than performing the discouraged reverse-lookup)
|
|
327
|
+
// v = "in-addr" for IPv4, "ip6" for IPv6
|
|
328
|
+
// h = HELO/EHLO domain
|
|
329
|
+
// c / r / t = SMTP-time-only macros (exp= text); not valid in a
|
|
330
|
+
// checked macro-string, so we expand them to empty in mechanism
|
|
331
|
+
// context per §7.3's split between "macro-string" and the
|
|
332
|
+
// exp-only letters.
|
|
333
|
+
//
|
|
334
|
+
// Transformers (RFC 7208 §7.1): an optional digit count limits the
|
|
335
|
+
// number of right-hand parts kept after a split; an optional `r`
|
|
336
|
+
// reverses the parts; an optional delimiter set (any of `.-+,/_=`)
|
|
337
|
+
// replaces "." as the split delimiter. After transforms, the parts are
|
|
338
|
+
// re-joined with ".".
|
|
339
|
+
//
|
|
340
|
+
// Length bound: the expanded macro-string is capped so a hostile policy
|
|
341
|
+
// can't inflate a DNS qname past the RFC 1035 §3.1 255-octet ceiling
|
|
342
|
+
// (the resulting name is used as a DNS query). RFC 7208 §7.1 mandates a
|
|
343
|
+
// 253-octet limit on the constructed domain-name; we cap the assembled
|
|
344
|
+
// string the same way.
|
|
345
|
+
var SPF_MACRO_MAX_EXPANDED_BYTES = 253; // RFC 1035 §3.1 / RFC 7208 §7.1 name ceiling
|
|
346
|
+
var SPF_MACRO_DELIMS = ".-+,/_="; // RFC 7208 §7.1 delimiter set
|
|
347
|
+
|
|
348
|
+
// IPv6 nibble-dotted form for the `i` macro (RFC 7208 §7.3): each of the
|
|
349
|
+
// 32 hex nibbles becomes its own "."-separated part. e.g.
|
|
350
|
+
// 2001:db8::1 → "2.0.0.1.0.d.b.8.0.…0.0.0.1".
|
|
351
|
+
function _ipv6Nibbles(ip) {
|
|
352
|
+
var groups = ipUtils.expandIpv6Groups(ip);
|
|
353
|
+
if (!groups) return null;
|
|
354
|
+
var nibbles = [];
|
|
355
|
+
for (var i = 0; i < groups.length; i += 1) {
|
|
356
|
+
var s = groups[i].toString(16); // hex radix
|
|
357
|
+
while (s.length < 4) s = "0" + s; // IPv6 group nibble count
|
|
358
|
+
for (var j = 0; j < 4; j += 1) nibbles.push(s.charAt(j)); // IPv6 group nibble count
|
|
359
|
+
}
|
|
360
|
+
return nibbles;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Resolve a single macro letter to its base string value (pre-transform).
|
|
364
|
+
// `vars` carries { ip, isIpv6, sender, localPart, senderDomain, domain,
|
|
365
|
+
// helo }. Letters not meaningful in mechanism context expand to "".
|
|
366
|
+
function _spfMacroValue(letter, vars) {
|
|
367
|
+
var lower = letter.toLowerCase();
|
|
368
|
+
switch (lower) {
|
|
369
|
+
case "s": return vars.sender || "";
|
|
370
|
+
case "l": return vars.localPart || "";
|
|
371
|
+
case "o": return vars.senderDomain || "";
|
|
372
|
+
case "d": return vars.domain || "";
|
|
373
|
+
case "h": return vars.helo || "";
|
|
374
|
+
case "v": return vars.isIpv6 ? "ip6" : "in-addr";
|
|
375
|
+
case "i":
|
|
376
|
+
if (vars.isIpv6) {
|
|
377
|
+
var nib = _ipv6Nibbles(vars.ip);
|
|
378
|
+
return nib ? nib.join(".") : "";
|
|
379
|
+
}
|
|
380
|
+
return vars.ip || "";
|
|
381
|
+
// RFC 7208 §5.5 — `p` (validated domain name) is "strongly
|
|
382
|
+
// discouraged"; resolving it requires the reverse-DNS path the
|
|
383
|
+
// framework intentionally does not perform here. Expand to the
|
|
384
|
+
// RFC-mandated sentinel so an `exists:%{p}...` policy degrades to a
|
|
385
|
+
// deterministic miss rather than a forged match.
|
|
386
|
+
case "p": return "unknown";
|
|
387
|
+
// c / r / t are exp-text-only macros (RFC 7208 §7.3); empty in
|
|
388
|
+
// mechanism context.
|
|
389
|
+
default: return "";
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Split `value` on the active delimiter chars, optionally reverse, keep
|
|
394
|
+
// the rightmost `digits` parts, re-join with ".". RFC 7208 §7.1.
|
|
395
|
+
function _spfApplyTransform(value, digits, reverse, delims) {
|
|
396
|
+
if (value.length === 0) return "";
|
|
397
|
+
// Build a character class from the (validated) delimiter set. Each
|
|
398
|
+
// delim char is one of `.-+,/_=` — all regex-safe except none need
|
|
399
|
+
// escaping inside a class except `-` which we place last; the set is
|
|
400
|
+
// a fixed allowlist so no untrusted metacharacter reaches the class.
|
|
401
|
+
var splitParts;
|
|
402
|
+
if (delims === ".") {
|
|
403
|
+
splitParts = value.split(".");
|
|
404
|
+
} else {
|
|
405
|
+
var out = [];
|
|
406
|
+
var cur = "";
|
|
407
|
+
for (var ci = 0; ci < value.length; ci += 1) {
|
|
408
|
+
var ch = value.charAt(ci);
|
|
409
|
+
if (delims.indexOf(ch) !== -1) { out.push(cur); cur = ""; }
|
|
410
|
+
else cur += ch;
|
|
411
|
+
}
|
|
412
|
+
out.push(cur);
|
|
413
|
+
splitParts = out;
|
|
414
|
+
}
|
|
415
|
+
if (reverse) splitParts = splitParts.slice().reverse();
|
|
416
|
+
if (digits !== null && digits > 0 && digits < splitParts.length) {
|
|
417
|
+
splitParts = splitParts.slice(splitParts.length - digits);
|
|
418
|
+
}
|
|
419
|
+
return splitParts.join(".");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Expand an SPF macro-string (RFC 7208 §7.1). `vars` is the macro
|
|
423
|
+
// variable bag. Throws MailAuthError on malformed `%` syntax (a bare
|
|
424
|
+
// `%` not followed by `{`, `%`, `_`, or `-` is a syntax error per
|
|
425
|
+
// §7.1 — receivers MUST permerror, mirrored by the caller catching the
|
|
426
|
+
// throw). The expanded result is byte-capped (§7.1 / RFC 1035 §3.1).
|
|
427
|
+
//
|
|
428
|
+
// The scanner is a single linear left-to-right pass (no backtracking
|
|
429
|
+
// regex over untrusted input): each `%{...}` token is matched by an
|
|
430
|
+
// index walk to the closing `}`, bounding work at O(n) in the macro
|
|
431
|
+
// length.
|
|
432
|
+
function _spfExpandMacros(macroString, vars) {
|
|
433
|
+
if (typeof macroString !== "string" || macroString.indexOf("%") === -1) {
|
|
434
|
+
return macroString;
|
|
435
|
+
}
|
|
436
|
+
var out = "";
|
|
437
|
+
var n = macroString.length;
|
|
438
|
+
var i = 0;
|
|
439
|
+
while (i < n) {
|
|
440
|
+
var ch = macroString.charAt(i);
|
|
441
|
+
if (ch !== "%") { out += ch; i += 1; continue; }
|
|
442
|
+
// ch === "%": peek the next char.
|
|
443
|
+
if (i + 1 >= n) {
|
|
444
|
+
throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
|
|
445
|
+
"SPF macro-string ends with a bare '%' (RFC 7208 §7.1)");
|
|
446
|
+
}
|
|
447
|
+
var next = macroString.charAt(i + 1);
|
|
448
|
+
if (next === "%") { out += "%"; i += 2; continue; }
|
|
449
|
+
if (next === "_") { out += " "; i += 2; continue; }
|
|
450
|
+
if (next === "-") { out += "%20"; i += 2; continue; }
|
|
451
|
+
if (next !== "{") {
|
|
452
|
+
throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
|
|
453
|
+
"SPF macro escape '%" + next + "' is invalid (RFC 7208 §7.1 allows %%, %_, %-, %{...})");
|
|
454
|
+
}
|
|
455
|
+
// next === "{": find the closing "}".
|
|
456
|
+
var close = macroString.indexOf("}", i + 2);
|
|
457
|
+
if (close === -1) {
|
|
458
|
+
throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
|
|
459
|
+
"SPF macro '%{' has no closing '}' (RFC 7208 §7.1)");
|
|
460
|
+
}
|
|
461
|
+
var body = macroString.slice(i + 2, close);
|
|
462
|
+
// body = macro-letter [ digits ] [ "r" ] *delimiter (RFC 7208 §7.1)
|
|
463
|
+
if (body.length === 0) {
|
|
464
|
+
throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
|
|
465
|
+
"SPF macro '%{}' is empty (RFC 7208 §7.1)");
|
|
466
|
+
}
|
|
467
|
+
var letter = body.charAt(0);
|
|
468
|
+
if (!/^[slodiphcrtv]$/i.test(letter)) {
|
|
469
|
+
throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
|
|
470
|
+
"SPF macro letter " + JSON.stringify(letter) + " is not a valid macro-letter (RFC 7208 §7.2)");
|
|
471
|
+
}
|
|
472
|
+
var rest = body.slice(1);
|
|
473
|
+
var digits = null;
|
|
474
|
+
var di = 0;
|
|
475
|
+
while (di < rest.length && rest.charAt(di) >= "0" && rest.charAt(di) <= "9") di += 1;
|
|
476
|
+
if (di > 0) {
|
|
477
|
+
digits = parseInt(rest.slice(0, di), 10);
|
|
478
|
+
if (!isFinite(digits) || digits < 1) {
|
|
479
|
+
throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
|
|
480
|
+
"SPF macro transformer digit count must be >= 1 (RFC 7208 §7.1): " + JSON.stringify(body));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
rest = rest.slice(di);
|
|
484
|
+
var reverse = false;
|
|
485
|
+
if (rest.length > 0 && (rest.charAt(0) === "r" || rest.charAt(0) === "R")) {
|
|
486
|
+
reverse = true;
|
|
487
|
+
rest = rest.slice(1);
|
|
488
|
+
}
|
|
489
|
+
// Remaining chars are the optional delimiter set; each MUST be one
|
|
490
|
+
// of the RFC 7208 §7.1 delimiters. Anything else is malformed.
|
|
491
|
+
var delims = "";
|
|
492
|
+
for (var ri = 0; ri < rest.length; ri += 1) {
|
|
493
|
+
var dch = rest.charAt(ri);
|
|
494
|
+
if (SPF_MACRO_DELIMS.indexOf(dch) === -1) {
|
|
495
|
+
throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
|
|
496
|
+
"SPF macro delimiter " + JSON.stringify(dch) + " is not in the RFC 7208 §7.1 set " +
|
|
497
|
+
JSON.stringify(SPF_MACRO_DELIMS));
|
|
498
|
+
}
|
|
499
|
+
if (delims.indexOf(dch) === -1) delims += dch;
|
|
500
|
+
}
|
|
501
|
+
if (delims.length === 0) delims = ".";
|
|
502
|
+
var base = _spfMacroValue(letter, vars);
|
|
503
|
+
out += _spfApplyTransform(base, digits, reverse, delims);
|
|
504
|
+
i = close + 1;
|
|
505
|
+
}
|
|
506
|
+
if (out.length > SPF_MACRO_MAX_EXPANDED_BYTES) {
|
|
507
|
+
// RFC 7208 §7.1 — the constructed domain-name is left-truncated to
|
|
508
|
+
// fit the 253-octet ceiling: leading labels are discarded until the
|
|
509
|
+
// remainder fits. This keeps the trailing (more-significant) labels
|
|
510
|
+
// the policy author intends as the lookup target.
|
|
511
|
+
while (out.length > SPF_MACRO_MAX_EXPANDED_BYTES) {
|
|
512
|
+
var dot = out.indexOf(".");
|
|
513
|
+
if (dot === -1) { out = out.slice(out.length - SPF_MACRO_MAX_EXPANDED_BYTES); break; }
|
|
514
|
+
out = out.slice(dot + 1);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
|
|
311
520
|
// Parse an SPF record into mechanisms.
|
|
312
521
|
function _parseSpfRecord(text) {
|
|
313
522
|
var trimmed = text.trim();
|
|
@@ -503,11 +712,25 @@ function _parseADualCidr(raw, mech, defaultDomain) {
|
|
|
503
712
|
// { match: false } — no IP matched / record absent
|
|
504
713
|
// { error: "temperror", reason: "..." } — transient DNS failure
|
|
505
714
|
// { error: "permerror", reason: "..." } — over-limit / bad CIDR / bad MX count
|
|
506
|
-
async function _spfMatchAMx(mech, raw, ip, isIpv6, defaultDomain, dnsLookup, lookups) {
|
|
715
|
+
async function _spfMatchAMx(mech, raw, ip, isIpv6, defaultDomain, dnsLookup, lookups, macroVars) {
|
|
507
716
|
var parsed;
|
|
508
717
|
try { parsed = _parseADualCidr(raw, mech, defaultDomain); }
|
|
509
718
|
catch (e) { return { error: "permerror", reason: e.message }; }
|
|
510
719
|
|
|
720
|
+
// RFC 7208 §5.3 / §5.4 — the domain-spec after `a:` / `mx:` is a
|
|
721
|
+
// macro-string (§7). Expand it before resolving so policies like
|
|
722
|
+
// `a:%{i}._ah.example.com` evaluate correctly. The default-domain
|
|
723
|
+
// case (`a` / `mx` with no `:domain`) carries no `%` and passes
|
|
724
|
+
// through untouched.
|
|
725
|
+
if (macroVars && parsed.domain.indexOf("%") !== -1) {
|
|
726
|
+
try { parsed.domain = _spfExpandMacros(parsed.domain, macroVars).toLowerCase(); }
|
|
727
|
+
catch (e) { return { error: "permerror", reason: e.message }; }
|
|
728
|
+
if (!parsed.domain || parsed.domain.length === 0) {
|
|
729
|
+
return { error: "permerror",
|
|
730
|
+
reason: "SPF " + mech + ": domain-spec expanded to empty (RFC 7208 §7)" };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
511
734
|
var mask = isIpv6 ? parsed.v6Mask : parsed.v4Mask;
|
|
512
735
|
var family = isIpv6 ? 6 : 4; // IP family marker
|
|
513
736
|
|
|
@@ -580,9 +803,9 @@ async function _spfMatchAMx(mech, raw, ip, isIpv6, defaultDomain, dnsLookup, loo
|
|
|
580
803
|
}
|
|
581
804
|
|
|
582
805
|
// SPF verify — recursive include resolution + ip4 / ip6 / a / mx /
|
|
583
|
-
// include / all / redirect
|
|
584
|
-
//
|
|
585
|
-
//
|
|
806
|
+
// include / exists / all / redirect=, with RFC 7208 §7 macro expansion.
|
|
807
|
+
// The `ptr` mechanism remains deferred (see the dispatch arm for the
|
|
808
|
+
// Re-open condition + operator escape hatch via b.mail.iprev.verify).
|
|
586
809
|
async function spfVerify(opts) {
|
|
587
810
|
opts = opts || {};
|
|
588
811
|
validateOpts(opts, ["ip", "mailFrom", "helo", "dnsLookup"], "mail.spf.verify");
|
|
@@ -599,6 +822,29 @@ async function spfVerify(opts) {
|
|
|
599
822
|
}
|
|
600
823
|
|
|
601
824
|
var lookups = { count: 0, limit: SPF_DNS_LOOKUP_LIMIT, void: 0 };
|
|
825
|
+
// RFC 7208 §7 macro variable bag. `<sender>` is the MAIL FROM identity
|
|
826
|
+
// when present, else `postmaster@<helo>` per §4.3 (the localpart
|
|
827
|
+
// defaults to "postmaster" when the reverse-path is empty / HELO is
|
|
828
|
+
// the checked identity). `<domain>` (%{d}) tracks the SPF record's
|
|
829
|
+
// current domain and is rebound at each include/redirect re-entry.
|
|
830
|
+
var senderIdentity = opts.mailFrom
|
|
831
|
+
? String(opts.mailFrom)
|
|
832
|
+
: ("postmaster@" + String(opts.helo || domain));
|
|
833
|
+
var senderLocal = senderIdentity.indexOf("@") !== -1
|
|
834
|
+
? senderIdentity.slice(0, senderIdentity.indexOf("@"))
|
|
835
|
+
: "postmaster";
|
|
836
|
+
var senderDomain = senderIdentity.indexOf("@") !== -1
|
|
837
|
+
? senderIdentity.slice(senderIdentity.indexOf("@") + 1)
|
|
838
|
+
: String(opts.helo || domain);
|
|
839
|
+
var macroVars = {
|
|
840
|
+
ip: opts.ip,
|
|
841
|
+
isIpv6: opts.ip.indexOf(":") !== -1,
|
|
842
|
+
sender: senderIdentity,
|
|
843
|
+
localPart: senderLocal,
|
|
844
|
+
senderDomain: senderDomain,
|
|
845
|
+
domain: domain.toLowerCase(),
|
|
846
|
+
helo: typeof opts.helo === "string" ? opts.helo : "",
|
|
847
|
+
};
|
|
602
848
|
// RFC 7208 §4.6.4 — the initial query for the sender domain's SPF
|
|
603
849
|
// record itself does NOT count toward the 10-lookup limit. Only
|
|
604
850
|
// include / a / mx / ptr / exists / redirect mechanisms count.
|
|
@@ -606,7 +852,7 @@ async function spfVerify(opts) {
|
|
|
606
852
|
// got false permerror.
|
|
607
853
|
var result = await _spfEvaluateDomain(domain.toLowerCase(), opts.ip,
|
|
608
854
|
opts.dnsLookup, lookups,
|
|
609
|
-
{ isInitial: true });
|
|
855
|
+
{ isInitial: true, macroVars: macroVars });
|
|
610
856
|
return {
|
|
611
857
|
result: result.verdict, // pass | fail | softfail | neutral | none | temperror | permerror
|
|
612
858
|
domain: domain,
|
|
@@ -660,6 +906,12 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
|
|
|
660
906
|
return { verdict: "permerror", explanation: e.message };
|
|
661
907
|
}
|
|
662
908
|
|
|
909
|
+
// RFC 7208 §7.2 — `%{d}` is the SPF record's CURRENT domain, which is
|
|
910
|
+
// rebound at each include / redirect re-entry. Clone the inherited
|
|
911
|
+
// macro bag with `domain` pinned to the domain we're evaluating now.
|
|
912
|
+
var baseMacroVars = ctx.macroVars || {};
|
|
913
|
+
var macroVars = Object.assign({}, baseMacroVars, { domain: domain });
|
|
914
|
+
|
|
663
915
|
var isIpv6 = ip.indexOf(":") !== -1;
|
|
664
916
|
for (var i = 0; i < mechanisms.length; i += 1) {
|
|
665
917
|
var m = mechanisms[i];
|
|
@@ -671,7 +923,15 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
|
|
|
671
923
|
if (m.arg && _ipv6InCidr(ip, m.arg)) match = true;
|
|
672
924
|
} else if (m.mechanism === "include") {
|
|
673
925
|
if (!m.arg) continue;
|
|
674
|
-
|
|
926
|
+
// RFC 7208 §7 — the include target may itself be a macro-string
|
|
927
|
+
// (e.g. `include:%{d}.spf.example.net`). Expand against the
|
|
928
|
+
// current macro bag before recursing.
|
|
929
|
+
var includeTarget;
|
|
930
|
+
try { includeTarget = _spfExpandMacros(m.arg, macroVars); }
|
|
931
|
+
catch (e) { return { verdict: "permerror", explanation: e.message }; }
|
|
932
|
+
var inner = await _spfEvaluateDomain(includeTarget.toLowerCase(), ip,
|
|
933
|
+
dnsLookup, lookups,
|
|
934
|
+
{ macroVars: macroVars });
|
|
675
935
|
if (inner.verdict === "pass") match = true;
|
|
676
936
|
else if (inner.verdict === "permerror" || inner.verdict === "temperror") {
|
|
677
937
|
return inner;
|
|
@@ -701,7 +961,7 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
|
|
|
701
961
|
m.mechanism };
|
|
702
962
|
}
|
|
703
963
|
var amRes = await _spfMatchAMx(m.mechanism, m.raw, ip, isIpv6,
|
|
704
|
-
domain, dnsLookup, lookups);
|
|
964
|
+
domain, dnsLookup, lookups, macroVars);
|
|
705
965
|
if (amRes.error === "permerror") {
|
|
706
966
|
return { verdict: "permerror", explanation: amRes.reason };
|
|
707
967
|
}
|
|
@@ -709,45 +969,69 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
|
|
|
709
969
|
return { verdict: "temperror", explanation: amRes.reason };
|
|
710
970
|
}
|
|
711
971
|
if (amRes.match) match = true;
|
|
712
|
-
} else if (m.mechanism === "exists"
|
|
713
|
-
// RFC 7208 §5.7
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
972
|
+
} else if (m.mechanism === "exists") {
|
|
973
|
+
// RFC 7208 §5.7 — `exists:<domain-spec>`. The domain-spec is
|
|
974
|
+
// macro-expanded (§7) and an A query is performed; the mechanism
|
|
975
|
+
// matches when ANY A record exists (the address is irrelevant —
|
|
976
|
+
// existence alone is the signal, so an AAAA-only target does NOT
|
|
977
|
+
// match per the spec's "A query" wording). Published policies use
|
|
978
|
+
// it for per-IP / per-recipient lookups like
|
|
979
|
+
// `exists:%{ir}.%{v}._spf.example.com`.
|
|
980
|
+
if (!m.arg) continue;
|
|
981
|
+
var existsTarget;
|
|
982
|
+
try { existsTarget = _spfExpandMacros(m.arg, macroVars); }
|
|
983
|
+
catch (e) { return { verdict: "permerror", explanation: e.message }; }
|
|
984
|
+
if (!existsTarget || existsTarget.length === 0) {
|
|
985
|
+
return { verdict: "permerror",
|
|
986
|
+
explanation: "SPF exists: expanded to an empty domain (RFC 7208 §5.7)" };
|
|
987
|
+
}
|
|
988
|
+
// §4.6.4 — the exists A query counts as one DNS-touching lookup.
|
|
989
|
+
lookups.count += 1;
|
|
990
|
+
if (lookups.count > lookups.limit) {
|
|
991
|
+
return { verdict: "permerror",
|
|
992
|
+
explanation: "DNS lookup limit exceeded (RFC 7208 §4.6.4) at exists:" +
|
|
993
|
+
existsTarget };
|
|
994
|
+
}
|
|
995
|
+
var existsHit = false;
|
|
996
|
+
try {
|
|
997
|
+
var existsIps = await _safeResolveA(existsTarget.toLowerCase(), 4, dnsLookup);
|
|
998
|
+
existsHit = Array.isArray(existsIps) && existsIps.length > 0;
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
var ecode = e && e.code;
|
|
1001
|
+
if (ecode === "ENOTFOUND" || ecode === "ENODATA") {
|
|
1002
|
+
// Void lookup — RFC 7208 §4.6.4 ceiling. A non-existent target
|
|
1003
|
+
// is a miss, not an error, but charges the void slot so a
|
|
1004
|
+
// chain of exists: misses can't amplify resolver work.
|
|
1005
|
+
lookups.void = (lookups.void || 0) + 1;
|
|
1006
|
+
if (lookups.void > SPF_VOID_LOOKUP_LIMIT) {
|
|
1007
|
+
return { verdict: "permerror",
|
|
1008
|
+
explanation: "SPF void-lookup limit exceeded (RFC 7208 §4.6.4) during exists: evaluation" };
|
|
1009
|
+
}
|
|
1010
|
+
existsHit = false;
|
|
1011
|
+
} else {
|
|
1012
|
+
return { verdict: "temperror",
|
|
1013
|
+
explanation: "SPF exists:" + existsTarget + " lookup failed: " +
|
|
1014
|
+
((e && e.message) || String(e)) };
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (existsHit) match = true;
|
|
1018
|
+
} else if (m.mechanism === "ptr") {
|
|
1019
|
+
// RFC 7208 §5.5 — `ptr` is "strongly discouraged": it ties the
|
|
1020
|
+
// sender's authorization to whoever controls the connecting IP's
|
|
1021
|
+
// PTR zone and doubles DNS load (reverse + forward-confirm per
|
|
1022
|
+
// query). A small minority of legacy senders still publish
|
|
1023
|
+
// `+ptr -all` as their only stance.
|
|
727
1024
|
//
|
|
728
|
-
// Re-open
|
|
729
|
-
//
|
|
730
|
-
//
|
|
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`).
|
|
1025
|
+
// Re-open condition: an operator surfaces a legitimate sender
|
|
1026
|
+
// whose ONLY SPF stance is `ptr` and needs the framework to
|
|
1027
|
+
// evaluate it rather than the MTA already doing iprev.
|
|
737
1028
|
//
|
|
738
|
-
// Operator escape hatch today:
|
|
739
|
-
//
|
|
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.
|
|
1029
|
+
// Operator escape hatch today: wire `b.mail.iprev.verify(ip)` and
|
|
1030
|
+
// treat fcrdns=true the same as an SPF pass for that domain.
|
|
746
1031
|
return {
|
|
747
1032
|
verdict: "permerror",
|
|
748
|
-
explanation: "SPF mechanism '
|
|
749
|
-
|
|
750
|
-
"); senders typically publish ip4 / ip6 / a / mx / include alongside",
|
|
1033
|
+
explanation: "SPF mechanism 'ptr' is not implemented (RFC 7208 §5.5 — strongly " +
|
|
1034
|
+
"discouraged); use b.mail.iprev.verify for forward-confirmed reverse DNS",
|
|
751
1035
|
};
|
|
752
1036
|
}
|
|
753
1037
|
if (match) {
|
|
@@ -772,10 +1056,14 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
|
|
|
772
1056
|
var mods = mechanisms.modifiers || [];
|
|
773
1057
|
for (var rmi = 0; rmi < mods.length; rmi += 1) {
|
|
774
1058
|
if (mods[rmi].name === "redirect" && mods[rmi].value) {
|
|
775
|
-
// Redirect counts as one DNS-mechanism per §4.6.4.
|
|
1059
|
+
// Redirect counts as one DNS-mechanism per §4.6.4. RFC 7208 §7 —
|
|
1060
|
+
// the redirect target may be a macro-string; expand it first.
|
|
1061
|
+
var redirectTarget;
|
|
1062
|
+
try { redirectTarget = _spfExpandMacros(mods[rmi].value, macroVars); }
|
|
1063
|
+
catch (e) { return { verdict: "permerror", explanation: e.message }; }
|
|
776
1064
|
var redirected = await _spfEvaluateDomain(
|
|
777
|
-
|
|
778
|
-
{ redirectDepth: (ctx.redirectDepth || 0) + 1 });
|
|
1065
|
+
redirectTarget.toLowerCase(), ip, dnsLookup, lookups,
|
|
1066
|
+
{ redirectDepth: (ctx.redirectDepth || 0) + 1, macroVars: macroVars });
|
|
779
1067
|
// RFC 7208 §6.1 — if the redirect target has no SPF record,
|
|
780
1068
|
// permerror (the operator's intent is unverifiable).
|
|
781
1069
|
if (redirected.verdict === "none") {
|
|
@@ -1991,6 +2279,217 @@ function _shapeAggregateReport(parsed) {
|
|
|
1991
2279
|
return shaped;
|
|
1992
2280
|
}
|
|
1993
2281
|
|
|
2282
|
+
// ---- DMARC aggregate (RUA) report builder/serializer (RFC 7489 Appendix C) ----
|
|
2283
|
+
//
|
|
2284
|
+
// The inverse of dmarcParseAggregateReport: an MTA acting as the
|
|
2285
|
+
// REPORTING side (it received mail under another domain's DMARC policy
|
|
2286
|
+
// and now owes that domain an aggregate report) serializes its
|
|
2287
|
+
// observation rows into the RFC 7489 Appendix C `<feedback>` XML.
|
|
2288
|
+
//
|
|
2289
|
+
// The builder accepts the SAME shaped object dmarcParseAggregateReport
|
|
2290
|
+
// returns (reportMetadata / policyPublished / records[...]), so a parsed
|
|
2291
|
+
// report round-trips back to identical structure. Operators may also
|
|
2292
|
+
// hand-assemble the shape directly.
|
|
2293
|
+
//
|
|
2294
|
+
// var xml = b.mail.dmarc.buildAggregateReport({
|
|
2295
|
+
// reportMetadata: { orgName, email, reportId, dateRange: { begin, end } },
|
|
2296
|
+
// policyPublished: { domain, adkim, aspf, p, sp, pct },
|
|
2297
|
+
// records: [{ sourceIp, count,
|
|
2298
|
+
// dispositions: { disposition, dkim, spf, reasons },
|
|
2299
|
+
// identifiers: { headerFrom, envelopeFrom, envelopeTo },
|
|
2300
|
+
// authResults: { dkim: [...], spf: [...] } }],
|
|
2301
|
+
// });
|
|
2302
|
+
// // → "<?xml version=\"1.0\" ...?>\n<feedback>...</feedback>"
|
|
2303
|
+
//
|
|
2304
|
+
// Validation tier: config-time/entry-point — the report shape is
|
|
2305
|
+
// operator-assembled structured data, so a malformed shape (missing
|
|
2306
|
+
// reportMetadata / policyPublished / non-array records) THROWS so the
|
|
2307
|
+
// operator catches the mistake before the report is mailed to a peer.
|
|
2308
|
+
//
|
|
2309
|
+
// XML safety: every emitted text node and the (rare) attribute-free
|
|
2310
|
+
// element bodies are escaped through _xmlEscapeText, which neutralizes
|
|
2311
|
+
// `& < > " '`. Source IPs, domains, and identifiers can carry
|
|
2312
|
+
// attacker-influenced bytes (a spoofed envelope-from observed in the
|
|
2313
|
+
// wild); escaping prevents a crafted observation from injecting markup
|
|
2314
|
+
// into the report a peer will parse.
|
|
2315
|
+
|
|
2316
|
+
// RFC 7489 Appendix C — the report is plain-element XML (no attributes
|
|
2317
|
+
// in the schema), so only the five XML text-content metacharacters need
|
|
2318
|
+
// neutralizing. Numeric / enum fields are coerced and range-checked
|
|
2319
|
+
// before they reach here, but escaping is applied uniformly so a future
|
|
2320
|
+
// caller can't bypass it.
|
|
2321
|
+
function _xmlEscapeText(value) {
|
|
2322
|
+
return String(value)
|
|
2323
|
+
.replace(/&/g, "&")
|
|
2324
|
+
.replace(/</g, "<")
|
|
2325
|
+
.replace(/>/g, ">")
|
|
2326
|
+
.replace(/"/g, """)
|
|
2327
|
+
.replace(/'/g, "'");
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
// Emit `<tag>escaped-text</tag>` when value is non-null/defined; emit
|
|
2331
|
+
// nothing when the field is absent (RFC 7489 Appendix C marks many
|
|
2332
|
+
// child elements optional — omitting is correct, emitting an empty
|
|
2333
|
+
// element changes the parsed shape).
|
|
2334
|
+
function _xmlLeaf(tag, value) {
|
|
2335
|
+
if (value === undefined || value === null || value === "") return "";
|
|
2336
|
+
return "<" + tag + ">" + _xmlEscapeText(value) + "</" + tag + ">";
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
// Integer leaf — coerce, refuse non-finite (a NaN count would serialize
|
|
2340
|
+
// as the string "NaN" and corrupt the peer's parse).
|
|
2341
|
+
function _xmlIntLeaf(tag, value) {
|
|
2342
|
+
if (value === undefined || value === null) return "";
|
|
2343
|
+
var n = typeof value === "number" ? value : parseInt(value, 10);
|
|
2344
|
+
if (!isFinite(n)) {
|
|
2345
|
+
throw new MailAuthError("mail-auth/dmarc-rua-build-bad-int",
|
|
2346
|
+
"dmarc.buildAggregateReport: " + tag + " must be a finite integer, got " + JSON.stringify(value));
|
|
2347
|
+
}
|
|
2348
|
+
return "<" + tag + ">" + String(Math.trunc(n)) + "</" + tag + ">";
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
function _buildAuthResultsXml(authResults) {
|
|
2352
|
+
var ar = authResults || {};
|
|
2353
|
+
var parts = [];
|
|
2354
|
+
var dkimRows = Array.isArray(ar.dkim) ? ar.dkim : [];
|
|
2355
|
+
for (var i = 0; i < dkimRows.length; i += 1) {
|
|
2356
|
+
var d = dkimRows[i] || {};
|
|
2357
|
+
parts.push(
|
|
2358
|
+
"<dkim>" +
|
|
2359
|
+
_xmlLeaf("domain", d.domain) +
|
|
2360
|
+
_xmlLeaf("selector", d.selector) +
|
|
2361
|
+
_xmlLeaf("result", d.result) +
|
|
2362
|
+
_xmlLeaf("human_result", d.humanResult) +
|
|
2363
|
+
"</dkim>");
|
|
2364
|
+
}
|
|
2365
|
+
var spfRows = Array.isArray(ar.spf) ? ar.spf : [];
|
|
2366
|
+
for (var j = 0; j < spfRows.length; j += 1) {
|
|
2367
|
+
var s = spfRows[j] || {};
|
|
2368
|
+
parts.push(
|
|
2369
|
+
"<spf>" +
|
|
2370
|
+
_xmlLeaf("domain", s.domain) +
|
|
2371
|
+
_xmlLeaf("scope", s.scope) +
|
|
2372
|
+
_xmlLeaf("result", s.result) +
|
|
2373
|
+
"</spf>");
|
|
2374
|
+
}
|
|
2375
|
+
return "<auth_results>" + parts.join("") + "</auth_results>";
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
function dmarcBuildAggregateReport(report, opts) {
|
|
2379
|
+
opts = opts || {};
|
|
2380
|
+
if (!report || typeof report !== "object") {
|
|
2381
|
+
throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
|
|
2382
|
+
"dmarc.buildAggregateReport: report must be an object");
|
|
2383
|
+
}
|
|
2384
|
+
var rm = report.reportMetadata;
|
|
2385
|
+
var pp = report.policyPublished;
|
|
2386
|
+
if (!rm || typeof rm !== "object") {
|
|
2387
|
+
throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
|
|
2388
|
+
"dmarc.buildAggregateReport: report.reportMetadata is required (RFC 7489 Appendix C)");
|
|
2389
|
+
}
|
|
2390
|
+
if (!pp || typeof pp !== "object") {
|
|
2391
|
+
throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
|
|
2392
|
+
"dmarc.buildAggregateReport: report.policyPublished is required (RFC 7489 Appendix C)");
|
|
2393
|
+
}
|
|
2394
|
+
var records = report.records;
|
|
2395
|
+
if (!Array.isArray(records)) {
|
|
2396
|
+
throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
|
|
2397
|
+
"dmarc.buildAggregateReport: report.records must be an array");
|
|
2398
|
+
}
|
|
2399
|
+
if (records.length > DMARC_RUA_MAX_RECORDS_PER_REPORT) {
|
|
2400
|
+
throw new MailAuthError("mail-auth/dmarc-rua-build-too-many-records",
|
|
2401
|
+
"dmarc.buildAggregateReport: " + records.length + " records exceeds cap " +
|
|
2402
|
+
DMARC_RUA_MAX_RECORDS_PER_REPORT);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// report_metadata (RFC 7489 Appendix C). date_range is two epoch
|
|
2406
|
+
// seconds; org_name + report_id are mandatory per the schema.
|
|
2407
|
+
var dateRange = rm.dateRange || {};
|
|
2408
|
+
var metaXml =
|
|
2409
|
+
"<report_metadata>" +
|
|
2410
|
+
_xmlLeaf("org_name", rm.orgName) +
|
|
2411
|
+
_xmlLeaf("email", rm.email) +
|
|
2412
|
+
_xmlLeaf("extra_contact_info", rm.extraContact) +
|
|
2413
|
+
_xmlLeaf("report_id", rm.reportId) +
|
|
2414
|
+
"<date_range>" +
|
|
2415
|
+
_xmlIntLeaf("begin", dateRange.begin) +
|
|
2416
|
+
_xmlIntLeaf("end", dateRange.end) +
|
|
2417
|
+
"</date_range>" +
|
|
2418
|
+
"</report_metadata>";
|
|
2419
|
+
|
|
2420
|
+
// policy_published (RFC 7489 Appendix C).
|
|
2421
|
+
var policyXml =
|
|
2422
|
+
"<policy_published>" +
|
|
2423
|
+
_xmlLeaf("domain", pp.domain) +
|
|
2424
|
+
_xmlLeaf("adkim", pp.adkim) +
|
|
2425
|
+
_xmlLeaf("aspf", pp.aspf) +
|
|
2426
|
+
_xmlLeaf("p", pp.p) +
|
|
2427
|
+
_xmlLeaf("sp", pp.sp) +
|
|
2428
|
+
(pp.pct === undefined || pp.pct === null ? "" : _xmlIntLeaf("pct", pp.pct)) +
|
|
2429
|
+
_xmlLeaf("fo", pp.fo) +
|
|
2430
|
+
"</policy_published>";
|
|
2431
|
+
|
|
2432
|
+
// record[] rows. Each row: source_ip + count + policy_evaluated +
|
|
2433
|
+
// identifiers + auth_results.
|
|
2434
|
+
var recordXml = "";
|
|
2435
|
+
for (var i = 0; i < records.length; i += 1) {
|
|
2436
|
+
var rec = records[i] || {};
|
|
2437
|
+
var disp = rec.dispositions || {};
|
|
2438
|
+
var ids = rec.identifiers || {};
|
|
2439
|
+
var reasonRows = Array.isArray(disp.reasons) ? disp.reasons : [];
|
|
2440
|
+
var reasonXml = "";
|
|
2441
|
+
for (var ri = 0; ri < reasonRows.length; ri += 1) {
|
|
2442
|
+
var rs = reasonRows[ri] || {};
|
|
2443
|
+
reasonXml +=
|
|
2444
|
+
"<reason>" +
|
|
2445
|
+
_xmlLeaf("type", rs.type) +
|
|
2446
|
+
_xmlLeaf("comment", rs.comment) +
|
|
2447
|
+
"</reason>";
|
|
2448
|
+
}
|
|
2449
|
+
recordXml +=
|
|
2450
|
+
"<record>" +
|
|
2451
|
+
"<row>" +
|
|
2452
|
+
_xmlLeaf("source_ip", rec.sourceIp) +
|
|
2453
|
+
_xmlIntLeaf("count", rec.count) +
|
|
2454
|
+
"<policy_evaluated>" +
|
|
2455
|
+
_xmlLeaf("disposition", disp.disposition) +
|
|
2456
|
+
_xmlLeaf("dkim", disp.dkim) +
|
|
2457
|
+
_xmlLeaf("spf", disp.spf) +
|
|
2458
|
+
reasonXml +
|
|
2459
|
+
"</policy_evaluated>" +
|
|
2460
|
+
"</row>" +
|
|
2461
|
+
"<identifiers>" +
|
|
2462
|
+
_xmlLeaf("envelope_to", ids.envelopeTo) +
|
|
2463
|
+
_xmlLeaf("envelope_from", ids.envelopeFrom) +
|
|
2464
|
+
_xmlLeaf("header_from", ids.headerFrom) +
|
|
2465
|
+
"</identifiers>" +
|
|
2466
|
+
_buildAuthResultsXml(rec.authResults) +
|
|
2467
|
+
"</record>";
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
// RFC 7489 §7.2.1.1 — report-format version is "1.0" (the `version`
|
|
2471
|
+
// element under <feedback>). Emit the XML declaration + a single
|
|
2472
|
+
// <feedback> root so the output round-trips through safeXml.parse.
|
|
2473
|
+
var version = _xmlLeaf("version", opts.version || "1.0");
|
|
2474
|
+
var doc =
|
|
2475
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
|
2476
|
+
"<feedback>" +
|
|
2477
|
+
version +
|
|
2478
|
+
metaXml +
|
|
2479
|
+
policyXml +
|
|
2480
|
+
recordXml +
|
|
2481
|
+
"</feedback>";
|
|
2482
|
+
|
|
2483
|
+
// Optional gzip per the same transport convention the parser accepts
|
|
2484
|
+
// (RFC 1952). Default is raw XML; operators opt into compression for
|
|
2485
|
+
// the mail attachment. Back-compat: default behavior is unchanged
|
|
2486
|
+
// (raw string out) — gzip is strictly opt-in.
|
|
2487
|
+
if (opts.gzip === true) {
|
|
2488
|
+
return zlib.gzipSync(Buffer.from(doc, "utf8"));
|
|
2489
|
+
}
|
|
2490
|
+
return doc;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
1994
2493
|
// ---- iprev (RFC 8601 §3) — Forward-Confirmed Reverse DNS verifier ----
|
|
1995
2494
|
//
|
|
1996
2495
|
// The receiving SMTP server reverse-resolves the connecting peer's IP
|
|
@@ -2117,6 +2616,337 @@ async function iprevVerify(ip) {
|
|
|
2117
2616
|
};
|
|
2118
2617
|
}
|
|
2119
2618
|
|
|
2619
|
+
// ---- DMARC forensic (RUF) failure-report parser (RFC 6591 + RFC 7489 §7.3) ----
|
|
2620
|
+
//
|
|
2621
|
+
// A domain publishing a DMARC `ruf=` policy receives per-message
|
|
2622
|
+
// failure reports when an authentication check fails. RFC 7489 §7.3
|
|
2623
|
+
// specifies the Authentication Failure Reporting Format (AFRF) of
|
|
2624
|
+
// RFC 6591 for these: a multipart/report (report-type=feedback-report)
|
|
2625
|
+
// carrying a `message/feedback-report` part whose header block adds the
|
|
2626
|
+
// DMARC-specific fields (Auth-Failure, Delivery-Result, Identity-
|
|
2627
|
+
// Alignment, DKIM-*/SPF-* result fields) on top of the RFC 5965 base
|
|
2628
|
+
// fields, plus a third part (message/rfc822 or text/rfc822-headers)
|
|
2629
|
+
// with the reported message (in full or headers-only).
|
|
2630
|
+
//
|
|
2631
|
+
// Composes the shared lib/mime-parse.js substrate (the same MIME walker
|
|
2632
|
+
// the RFC 5965 ARF ingest in lib/mail-arf.js uses) for the
|
|
2633
|
+
// multipart/report bisection + message/feedback-report extraction, then
|
|
2634
|
+
// shapes the full RFC 6591 §3.1 forensic field set (which the abuse-
|
|
2635
|
+
// report profile does not model) plus the reported message's headers.
|
|
2636
|
+
//
|
|
2637
|
+
// var rv = b.mail.dmarc.parseForensicReport(rawMessageBytes);
|
|
2638
|
+
// if (!rv.ok) { /* rv.error.code / rv.error.message */ }
|
|
2639
|
+
// else { /* rv.report.feedbackType / .authFailure / … */ }
|
|
2640
|
+
//
|
|
2641
|
+
// Validation tier: DEFENSIVE READER. The input is hostile-by-default
|
|
2642
|
+
// (a per-message failure report arrives at an operator endpoint from an
|
|
2643
|
+
// arbitrary reporting peer). The parser RETURNS a typed error object on
|
|
2644
|
+
// any malformed / over-cap / wrong-shape input — it does NOT throw in
|
|
2645
|
+
// the hot path, so a crafted report can't crash the request that
|
|
2646
|
+
// ingested it. Bytes + part-count + reported-header counts are bounded
|
|
2647
|
+
// (CWE-400 resource-exhaustion class) like the sibling aggregate-report
|
|
2648
|
+
// parser.
|
|
2649
|
+
//
|
|
2650
|
+
// { ok: true, report: { … } }
|
|
2651
|
+
// { ok: false, error: { code: "<slug>", message: "<reason>" } }
|
|
2652
|
+
|
|
2653
|
+
// RFC 6591 §3.2 — the report is small in practice; cap at 8 MiB to match
|
|
2654
|
+
// the sibling DMARC aggregate-report ceiling so operators have one
|
|
2655
|
+
// mental model for "what fits".
|
|
2656
|
+
var DMARC_RUF_MAX_REPORT_BYTES = C.BYTES.mib(8);
|
|
2657
|
+
|
|
2658
|
+
// RFC 2046 §5.1 — a multipart/report failure report has a handful of
|
|
2659
|
+
// parts (text/plain + message/feedback-report + the reported message);
|
|
2660
|
+
// bound the part count so a hostile report with thousands of empty
|
|
2661
|
+
// boundary delimiters can't force unbounded walk work.
|
|
2662
|
+
var DMARC_RUF_MAX_PARTS = 64; // resource-exhaustion bound (CWE-400)
|
|
2663
|
+
|
|
2664
|
+
// RFC 6591 §3.1 — required forensic fields. Feedback-Type and Auth-
|
|
2665
|
+
// Failure are the two that make an auth-failure report a DMARC forensic
|
|
2666
|
+
// report (RFC 7489 §7.3). User-Agent / Version are advisory in practice.
|
|
2667
|
+
var DMARC_RUF_REQUIRED_FIELDS = ["feedback-type", "auth-failure"];
|
|
2668
|
+
|
|
2669
|
+
// RFC 6591 §3.1 — Auth-Failure registry values. Unknown values pass
|
|
2670
|
+
// through (the IANA "Authentication Failure" registry grows); this set
|
|
2671
|
+
// documents the launch vocabulary so operators can route on it.
|
|
2672
|
+
var DMARC_RUF_AUTH_FAILURE_TYPES = Object.freeze({
|
|
2673
|
+
adsp: 1, // RFC 6591 §3.1 (historic ADSP)
|
|
2674
|
+
"bodyhash": 1, // DKIM body-hash mismatch
|
|
2675
|
+
dkim: 1, // DKIM signature failure
|
|
2676
|
+
dmarc: 1, // RFC 7489 §7.3 — DMARC evaluation failure
|
|
2677
|
+
revoked: 1, // signing key revoked
|
|
2678
|
+
signature: 1, // DKIM signature syntactically invalid
|
|
2679
|
+
spf: 1, // SPF check failure
|
|
2680
|
+
});
|
|
2681
|
+
void DMARC_RUF_AUTH_FAILURE_TYPES;
|
|
2682
|
+
|
|
2683
|
+
// RFC 6591 §3.2 — the reported message's header section can be large but
|
|
2684
|
+
// is bounded; cap the number of reported headers we normalize so a
|
|
2685
|
+
// crafted report can't force unbounded work. The full reported message
|
|
2686
|
+
// text is still surfaced verbatim under `reportedMessage` (bounded by
|
|
2687
|
+
// the overall byte cap), but the parsed `reportedHeaders` list is
|
|
2688
|
+
// header-count-capped.
|
|
2689
|
+
var DMARC_RUF_MAX_REPORTED_HEADERS = 256; // resource-exhaustion bound (CWE-400)
|
|
2690
|
+
|
|
2691
|
+
function _rufError(code, message) {
|
|
2692
|
+
return { ok: false, error: { code: code, message: message } };
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
// Parse the reported message's headers (RFC 6591 §3.2 — the third part
|
|
2696
|
+
// of the report carries the message that failed authentication, in full
|
|
2697
|
+
// or headers-only). Returns an own-keys-only map (null-prototype) of
|
|
2698
|
+
// header-name → value (last-wins for duplicate single-valued lookups;
|
|
2699
|
+
// the full ordered list is also returned) so a header named
|
|
2700
|
+
// `__proto__` / `constructor` in a hostile report can't pollute the
|
|
2701
|
+
// prototype chain (prototype-pollution class).
|
|
2702
|
+
function _parseReportedHeaders(reportedMessage) {
|
|
2703
|
+
var ordered = [];
|
|
2704
|
+
var map = Object.create(null);
|
|
2705
|
+
if (typeof reportedMessage !== "string" || reportedMessage.length === 0) {
|
|
2706
|
+
return { headers: ordered, map: map, truncated: false };
|
|
2707
|
+
}
|
|
2708
|
+
var split;
|
|
2709
|
+
try { split = mimeParse.splitHeadersAndBody(reportedMessage); }
|
|
2710
|
+
catch (_e) { return { headers: ordered, map: map, truncated: false }; }
|
|
2711
|
+
var hdrs = Array.isArray(split.headers) ? split.headers : [];
|
|
2712
|
+
var truncated = false;
|
|
2713
|
+
for (var i = 0; i < hdrs.length; i += 1) {
|
|
2714
|
+
if (ordered.length >= DMARC_RUF_MAX_REPORTED_HEADERS) { truncated = true; break; }
|
|
2715
|
+
var h = hdrs[i];
|
|
2716
|
+
if (!h || typeof h.name !== "string") continue;
|
|
2717
|
+
var name = h.name;
|
|
2718
|
+
var value = typeof h.value === "string" ? h.value : "";
|
|
2719
|
+
ordered.push({ name: name, value: value });
|
|
2720
|
+
// Own-key assignment on a null-prototype object: a reported header
|
|
2721
|
+
// named __proto__ / constructor / prototype is stored as data, not
|
|
2722
|
+
// walked up the chain.
|
|
2723
|
+
map[name.toLowerCase()] = value;
|
|
2724
|
+
}
|
|
2725
|
+
return { headers: ordered, map: map, truncated: truncated };
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// Reassemble a MIME part's headers + body so a reported message that
|
|
2729
|
+
// ships as text/rfc822-headers (no separate body) still round-trips its
|
|
2730
|
+
// header bytes (RFC 6591 §3.2 permits headers-only).
|
|
2731
|
+
function _reassembleRufPart(part) {
|
|
2732
|
+
var hdrs = "";
|
|
2733
|
+
var ph = Array.isArray(part.headers) ? part.headers : [];
|
|
2734
|
+
for (var i = 0; i < ph.length; i += 1) {
|
|
2735
|
+
hdrs += ph[i].name + ": " + ph[i].value + "\r\n";
|
|
2736
|
+
}
|
|
2737
|
+
return hdrs + "\r\n" + (part.body || "");
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
function dmarcParseForensicReport(input, opts) {
|
|
2741
|
+
opts = opts || {};
|
|
2742
|
+
|
|
2743
|
+
// ---- Coerce + byte-cap (defensive: typed error, never throw) ----
|
|
2744
|
+
var asString;
|
|
2745
|
+
if (typeof input === "string") asString = input;
|
|
2746
|
+
else if (Buffer.isBuffer(input)) asString = input.toString("utf8");
|
|
2747
|
+
else {
|
|
2748
|
+
return _rufError("mail-auth/dmarc-ruf-bad-input",
|
|
2749
|
+
"dmarc.parseForensicReport: input must be a string or Buffer");
|
|
2750
|
+
}
|
|
2751
|
+
var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) && opts.maxBytes > 0)
|
|
2752
|
+
? opts.maxBytes
|
|
2753
|
+
: DMARC_RUF_MAX_REPORT_BYTES;
|
|
2754
|
+
if (asString.length > maxBytes) {
|
|
2755
|
+
return _rufError("mail-auth/dmarc-ruf-too-large",
|
|
2756
|
+
"dmarc.parseForensicReport: report exceeds " + maxBytes + " bytes (got " + asString.length + ")");
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// ---- Bisect top-level headers / body; require multipart/report ----
|
|
2760
|
+
var top;
|
|
2761
|
+
try { top = mimeParse.splitHeadersAndBody(asString); }
|
|
2762
|
+
catch (e) {
|
|
2763
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2764
|
+
"dmarc.parseForensicReport: header/body split failed: " + ((e && e.message) || String(e)));
|
|
2765
|
+
}
|
|
2766
|
+
var ct = mimeParse.parseContentType(mimeParse.findHeader(top.headers, "Content-Type") || "");
|
|
2767
|
+
if (ct.type !== "multipart/report") {
|
|
2768
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2769
|
+
"dmarc.parseForensicReport: top-level Content-Type must be multipart/report (got '" + ct.type + "')");
|
|
2770
|
+
}
|
|
2771
|
+
// RFC 6591 §2 / RFC 5965 §2 — report-type=feedback-report. Tolerate an
|
|
2772
|
+
// omitted report-type (shipping reporters sometimes drop it); refuse a
|
|
2773
|
+
// mismatched value.
|
|
2774
|
+
if (ct.params["report-type"] && ct.params["report-type"].toLowerCase() !== "feedback-report") {
|
|
2775
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2776
|
+
"dmarc.parseForensicReport: report-type must be feedback-report (got '" +
|
|
2777
|
+
ct.params["report-type"] + "')");
|
|
2778
|
+
}
|
|
2779
|
+
if (!ct.params.boundary) {
|
|
2780
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2781
|
+
"dmarc.parseForensicReport: multipart/report Content-Type lacks boundary parameter");
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// ---- Walk the parts; find message/feedback-report + reported msg ----
|
|
2785
|
+
var parts = mimeParse.splitMimeParts(top.body, ct.params.boundary);
|
|
2786
|
+
if (parts.length === 0) {
|
|
2787
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2788
|
+
"dmarc.parseForensicReport: multipart/report body contains no parts");
|
|
2789
|
+
}
|
|
2790
|
+
if (parts.length > DMARC_RUF_MAX_PARTS) {
|
|
2791
|
+
return _rufError("mail-auth/dmarc-ruf-too-many-parts",
|
|
2792
|
+
"dmarc.parseForensicReport: report has " + parts.length + " parts (cap " +
|
|
2793
|
+
DMARC_RUF_MAX_PARTS + ")");
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
var feedbackPart = null;
|
|
2797
|
+
var reportedPart = null;
|
|
2798
|
+
for (var pi = 0; pi < parts.length; pi += 1) {
|
|
2799
|
+
var split;
|
|
2800
|
+
try { split = mimeParse.splitHeadersAndBody(parts[pi]); }
|
|
2801
|
+
catch (_e) { continue; }
|
|
2802
|
+
var partCt = mimeParse.parseContentType(
|
|
2803
|
+
mimeParse.findHeader(split.headers, "Content-Type") || "");
|
|
2804
|
+
if (partCt.type === "message/feedback-report" && !feedbackPart) {
|
|
2805
|
+
feedbackPart = split;
|
|
2806
|
+
} else if ((partCt.type === "message/rfc822" ||
|
|
2807
|
+
partCt.type === "text/rfc822-headers") && !reportedPart) {
|
|
2808
|
+
reportedPart = split;
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
if (!feedbackPart) {
|
|
2812
|
+
return _rufError("mail-auth/dmarc-ruf-no-feedback-report",
|
|
2813
|
+
"dmarc.parseForensicReport: missing message/feedback-report subpart (RFC 6591 §3)");
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// ---- Parse the feedback-report header block (RFC 6591 §3.1) ----
|
|
2817
|
+
// Field names are stored own-key on a null-prototype map so a hostile
|
|
2818
|
+
// field named __proto__ / constructor can't pollute the prototype.
|
|
2819
|
+
var fields;
|
|
2820
|
+
try { fields = mimeParse.parseHeaderBlock(feedbackPart.body); }
|
|
2821
|
+
catch (e) {
|
|
2822
|
+
return _rufError("mail-auth/dmarc-ruf-bad-report",
|
|
2823
|
+
"dmarc.parseForensicReport: feedback-report field parse failed: " + ((e && e.message) || String(e)));
|
|
2824
|
+
}
|
|
2825
|
+
var fieldMap = Object.create(null);
|
|
2826
|
+
var rcptToList = [];
|
|
2827
|
+
for (var fi = 0; fi < fields.length; fi += 1) {
|
|
2828
|
+
var f = fields[fi];
|
|
2829
|
+
if (!f || typeof f.name !== "string") continue;
|
|
2830
|
+
var lc = f.name.toLowerCase();
|
|
2831
|
+
var val = typeof f.value === "string" ? f.value : "";
|
|
2832
|
+
fieldMap[lc] = val;
|
|
2833
|
+
if (lc === "original-rcpt-to") rcptToList.push(val);
|
|
2834
|
+
}
|
|
2835
|
+
function _field(name) {
|
|
2836
|
+
return Object.prototype.hasOwnProperty.call(fieldMap, name) ? fieldMap[name] : null;
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
// ---- Required fields (RFC 6591 §3.1 / RFC 7489 §7.3) ----
|
|
2840
|
+
for (var ri = 0; ri < DMARC_RUF_REQUIRED_FIELDS.length; ri += 1) {
|
|
2841
|
+
var req = DMARC_RUF_REQUIRED_FIELDS[ri];
|
|
2842
|
+
var rv = _field(req);
|
|
2843
|
+
if (typeof rv !== "string" || rv.length === 0) {
|
|
2844
|
+
if (req === "auth-failure") {
|
|
2845
|
+
return _rufError("mail-auth/dmarc-ruf-missing-auth-failure",
|
|
2846
|
+
"dmarc.parseForensicReport: required field 'Auth-Failure' is missing (RFC 6591 §3.1)");
|
|
2847
|
+
}
|
|
2848
|
+
return _rufError("mail-auth/dmarc-ruf-missing-field",
|
|
2849
|
+
"dmarc.parseForensicReport: required field '" + req + "' is missing (RFC 6591 §3.1)");
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
// RFC 7489 §7.3 — a DMARC forensic report carries Feedback-Type:
|
|
2854
|
+
// auth-failure (the AFRF profile of RFC 6591). A report whose Feedback-
|
|
2855
|
+
// Type is another ARF class (e.g. plain "abuse") is a valid feedback
|
|
2856
|
+
// report but NOT a DMARC forensic report; surface the mismatch rather
|
|
2857
|
+
// than mislabeling it. Field values are case-insensitive tokens.
|
|
2858
|
+
var feedbackType = String(_field("feedback-type")).toLowerCase();
|
|
2859
|
+
if (feedbackType !== "auth-failure") {
|
|
2860
|
+
return _rufError("mail-auth/dmarc-ruf-not-auth-failure",
|
|
2861
|
+
"dmarc.parseForensicReport: Feedback-Type must be 'auth-failure' for a " +
|
|
2862
|
+
"DMARC forensic report (RFC 7489 §7.3 / RFC 6591), got " +
|
|
2863
|
+
JSON.stringify(_field("feedback-type")));
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
// ---- RFC 6591 §3.2 reported message ----
|
|
2867
|
+
var reportedMessage = null;
|
|
2868
|
+
if (reportedPart) {
|
|
2869
|
+
reportedMessage = (reportedPart.body && reportedPart.body.length > 0)
|
|
2870
|
+
? reportedPart.body
|
|
2871
|
+
: _reassembleRufPart(reportedPart);
|
|
2872
|
+
}
|
|
2873
|
+
var reported = _parseReportedHeaders(reportedMessage);
|
|
2874
|
+
|
|
2875
|
+
// ---- Normalize Arrival-Date / Incidents (RFC 5965 §3.1) ----
|
|
2876
|
+
var arrivalRaw = _field("arrival-date") || _field("received-date") || null;
|
|
2877
|
+
var arrivalIso = null;
|
|
2878
|
+
if (arrivalRaw) {
|
|
2879
|
+
var d = new Date(arrivalRaw);
|
|
2880
|
+
if (!isNaN(d.getTime())) arrivalIso = d.toISOString();
|
|
2881
|
+
}
|
|
2882
|
+
var incidentsRaw = _field("incidents");
|
|
2883
|
+
var incidents = null;
|
|
2884
|
+
if (typeof incidentsRaw === "string") {
|
|
2885
|
+
var inc = parseInt(incidentsRaw, 10);
|
|
2886
|
+
if (isFinite(inc) && inc >= 0) incidents = inc;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
// Surface unmodeled fields under extraFields for operator visibility
|
|
2890
|
+
// (vendor X-* tags). Own-key copy off the null-prototype fieldMap onto
|
|
2891
|
+
// a null-prototype target so a field named __proto__ / constructor in a
|
|
2892
|
+
// hostile report is stored as data, not as a prototype mutation.
|
|
2893
|
+
var KNOWN = Object.create(null);
|
|
2894
|
+
["feedback-type", "user-agent", "version", "auth-failure",
|
|
2895
|
+
"delivery-result", "identity-alignment", "dkim-domain",
|
|
2896
|
+
"dkim-identity", "dkim-selector", "dkim-canonicalized-header",
|
|
2897
|
+
"dkim-canonicalized-body", "spf-dns", "original-mail-from",
|
|
2898
|
+
"original-rcpt-to", "arrival-date", "received-date",
|
|
2899
|
+
"reported-domain", "source-ip", "authentication-results",
|
|
2900
|
+
"reported-uri", "incidents", "original-envelope-id"
|
|
2901
|
+
].forEach(function (k) { KNOWN[k] = 1; });
|
|
2902
|
+
var extraFields = Object.create(null);
|
|
2903
|
+
Object.keys(fieldMap).forEach(function (k) {
|
|
2904
|
+
if (!Object.prototype.hasOwnProperty.call(KNOWN, k)) extraFields[k] = fieldMap[k];
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
var report = {
|
|
2908
|
+
// ---- RFC 5965 base fields ----
|
|
2909
|
+
feedbackType: _field("feedback-type"),
|
|
2910
|
+
userAgent: _field("user-agent"),
|
|
2911
|
+
version: _field("version") || "1",
|
|
2912
|
+
arrivalDate: arrivalIso || arrivalRaw,
|
|
2913
|
+
reportedDomain: _field("reported-domain"),
|
|
2914
|
+
sourceIp: _field("source-ip"),
|
|
2915
|
+
originalFrom: _field("original-mail-from"),
|
|
2916
|
+
originalRcptTo: rcptToList,
|
|
2917
|
+
originalEnvelopeId: _field("original-envelope-id"),
|
|
2918
|
+
authenticationResults: _field("authentication-results"),
|
|
2919
|
+
incidents: incidents,
|
|
2920
|
+
reportedUri: _field("reported-uri"),
|
|
2921
|
+
|
|
2922
|
+
// ---- RFC 6591 §3.1 / RFC 7489 §7.3 forensic-specific fields ----
|
|
2923
|
+
authFailure: _field("auth-failure"), // RFC 6591 §3.1 — "dkim" | "spf" | "dmarc" | "bodyhash" | …
|
|
2924
|
+
deliveryResult: _field("delivery-result"), // RFC 6591 §3.1 — "delivered" | "spam" | "policy" | "reject" | "other"
|
|
2925
|
+
identityAlignment: _field("identity-alignment"), // RFC 7489 §7.3 — "none" | "spf" | "dkim" | "dkim spf"
|
|
2926
|
+
dkim: {
|
|
2927
|
+
domain: _field("dkim-domain"), // RFC 6591 §3.1
|
|
2928
|
+
identity: _field("dkim-identity"),
|
|
2929
|
+
selector: _field("dkim-selector"),
|
|
2930
|
+
canonicalizedHeader: _field("dkim-canonicalized-header"),
|
|
2931
|
+
canonicalizedBody: _field("dkim-canonicalized-body"),
|
|
2932
|
+
},
|
|
2933
|
+
spf: {
|
|
2934
|
+
dns: _field("spf-dns"), // RFC 6591 §3.1 — the SPF DNS record at evaluation time
|
|
2935
|
+
},
|
|
2936
|
+
|
|
2937
|
+
// ---- RFC 6591 §3.2 reported message ----
|
|
2938
|
+
reportedMessage: reportedMessage,
|
|
2939
|
+
reportedHeaders: reported.headers, // [ { name, value }, … ] — order preserved
|
|
2940
|
+
reportedHeaderMap: reported.map, // null-prototype lower-cased-name → value
|
|
2941
|
+
reportedHeadersTruncated: reported.truncated, // true when the §3.2 header cap clipped the list
|
|
2942
|
+
|
|
2943
|
+
// ---- operator-visible passthrough for unmodeled fields ----
|
|
2944
|
+
extraFields: extraFields,
|
|
2945
|
+
};
|
|
2946
|
+
|
|
2947
|
+
return { ok: true, report: report };
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2120
2950
|
module.exports = {
|
|
2121
2951
|
spf: Object.freeze({
|
|
2122
2952
|
verify: spfVerify,
|
|
@@ -2126,6 +2956,8 @@ module.exports = {
|
|
|
2126
2956
|
evaluate: dmarcEvaluate,
|
|
2127
2957
|
parseRecord: _parseDmarcRecord,
|
|
2128
2958
|
parseAggregateReport: dmarcParseAggregateReport,
|
|
2959
|
+
buildAggregateReport: dmarcBuildAggregateReport,
|
|
2960
|
+
parseForensicReport: dmarcParseForensicReport,
|
|
2129
2961
|
}),
|
|
2130
2962
|
arc: Object.freeze({
|
|
2131
2963
|
verify: arcVerify,
|