@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/CHANGELOG.md +4 -0
- package/README.md +3 -3
- package/lib/agent-orchestrator.js +10 -4
- package/lib/ai-prompt.js +1 -1
- package/lib/app-shutdown.js +28 -0
- package/lib/archive-read.js +215 -16
- package/lib/archive.js +206 -52
- package/lib/auth/oauth.js +58 -0
- package/lib/auth/oid4vci.js +84 -27
- package/lib/breach-deadline.js +166 -1
- package/lib/cloud-events.js +3 -1
- package/lib/codepoint-class.js +21 -0
- package/lib/db-schema.js +120 -3
- package/lib/db.js +10 -3
- package/lib/error-page.js +88 -8
- package/lib/external-db.js +164 -13
- package/lib/guard-email.js +36 -3
- package/lib/mail-auth.js +554 -55
- package/lib/mail-send-deliver.js +15 -5
- package/lib/mail-sieve.js +2 -1
- package/lib/middleware/ai-act-disclosure.js +88 -19
- package/lib/middleware/asyncapi-serve.js +56 -4
- package/lib/middleware/attach-user.js +45 -10
- package/lib/middleware/body-parser.js +70 -14
- package/lib/middleware/csp-report.js +30 -2
- package/lib/middleware/deny-response.js +22 -7
- package/lib/middleware/openapi-serve.js +56 -4
- package/lib/middleware/scim-server.js +301 -14
- package/lib/openapi-paths-builder.js +105 -29
- package/lib/openapi.js +225 -100
- package/lib/queue-local.js +148 -38
- package/lib/queue.js +41 -11
- package/lib/render.js +21 -3
- package/lib/safe-buffer.js +55 -0
- package/lib/static.js +46 -17
- package/lib/uri-template.js +3 -1
- 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;
|
|
@@ -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
|
|
584
|
-
//
|
|
585
|
-
//
|
|
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
|
-
|
|
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"
|
|
713
|
-
// RFC 7208 §5.7
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
|
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`).
|
|
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
|
-
//
|
|
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 '
|
|
749
|
-
|
|
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
|
-
|
|
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, "&")
|
|
2323
|
+
.replace(/</g, "<")
|
|
2324
|
+
.replace(/>/g, ">")
|
|
2325
|
+
.replace(/"/g, """)
|
|
2326
|
+
.replace(/'/g, "'");
|
|
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,
|