@blamejs/core 0.8.52 → 0.8.58
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 +6 -0
- package/index.js +8 -0
- package/lib/audit.js +4 -0
- package/lib/auth/fido-mds3.js +624 -0
- package/lib/auth/passkey.js +214 -2
- package/lib/auth-bot-challenge.js +1 -1
- package/lib/credential-hash.js +2 -2
- package/lib/db-collection.js +290 -0
- package/lib/db-query.js +245 -0
- package/lib/db.js +173 -67
- package/lib/framework-error.js +55 -0
- package/lib/guard-cidr.js +2 -1
- package/lib/guard-jwt.js +2 -2
- package/lib/guard-oauth.js +2 -2
- package/lib/http-client-cache.js +916 -0
- package/lib/http-client.js +242 -0
- package/lib/mail-arf.js +343 -0
- package/lib/mail-auth.js +265 -40
- package/lib/mail-bimi.js +948 -33
- package/lib/mail-bounce.js +386 -4
- package/lib/mail-mdn.js +424 -0
- package/lib/mail-unsubscribe.js +265 -25
- package/lib/mail.js +403 -21
- package/lib/middleware/bearer-auth.js +1 -1
- package/lib/middleware/clear-site-data.js +122 -0
- package/lib/middleware/dpop.js +1 -1
- package/lib/middleware/index.js +9 -0
- package/lib/middleware/nel.js +214 -0
- package/lib/middleware/security-headers.js +56 -4
- package/lib/middleware/speculation-rules.js +323 -0
- package/lib/mime-parse.js +198 -0
- package/lib/mtls-ca.js +15 -5
- package/lib/network-dns.js +890 -27
- package/lib/network-tls.js +745 -0
- package/lib/object-store/sigv4.js +54 -0
- package/lib/public-suffix.js +414 -0
- package/lib/safe-buffer.js +7 -0
- package/lib/safe-json.js +1 -1
- package/lib/static.js +120 -0
- package/lib/storage.js +11 -0
- package/lib/vendor/MANIFEST.json +33 -0
- package/lib/vendor/bimi-trust-anchors.pem +33 -0
- package/lib/vendor/public-suffix-list.dat +16376 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/mail-auth.js
CHANGED
|
@@ -35,11 +35,13 @@
|
|
|
35
35
|
var dns = require("node:dns");
|
|
36
36
|
var dnsPromises = dns.promises;
|
|
37
37
|
var zlib = require("node:zlib");
|
|
38
|
+
var net = require("node:net");
|
|
38
39
|
var lazyRequire = require("./lazy-require");
|
|
39
40
|
var validateOpts = require("./validate-opts");
|
|
40
41
|
var C = require("./constants");
|
|
41
42
|
var dkim = require("./mail-dkim");
|
|
42
43
|
var safeXml = require("./parsers/safe-xml");
|
|
44
|
+
var publicSuffix = require("./public-suffix");
|
|
43
45
|
var { MailAuthError } = require("./framework-error");
|
|
44
46
|
|
|
45
47
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
@@ -361,8 +363,22 @@ async function _fetchDmarcRecord(domain, dnsLookup) {
|
|
|
361
363
|
return matches[0];
|
|
362
364
|
}
|
|
363
365
|
|
|
366
|
+
// RFC 7489 base policy keys + DMARCbis (draft-ietf-dmarc-dmarcbis)
|
|
367
|
+
// extensions:
|
|
368
|
+
// np=<none|quarantine|reject> policy for non-existent subdomains
|
|
369
|
+
// psd=<y|n|u> applies-at-public-suffix-domain (TLD
|
|
370
|
+
// operator publishes a DMARC record on
|
|
371
|
+
// the suffix itself)
|
|
372
|
+
// Validation tier: parse is config-time (operator-supplied DNS bytes);
|
|
373
|
+
// throw on malformed v= / unrecognized np= or psd= values rather than
|
|
374
|
+
// silently dropping — operators with a typo'd record otherwise see the
|
|
375
|
+
// fallback policy applied without warning.
|
|
376
|
+
var DMARCBIS_VALID_NP = { none: 1, quarantine: 1, reject: 1 };
|
|
377
|
+
var DMARCBIS_VALID_PSD = { y: 1, n: 1, u: 1 };
|
|
378
|
+
|
|
364
379
|
function _parseDmarcRecord(text) {
|
|
365
|
-
var policy = { v: null, p: null, sp: null,
|
|
380
|
+
var policy = { v: null, p: null, sp: null, np: null, psd: null,
|
|
381
|
+
pct: 100, adkim: "r", aspf: "r" }; // allow:raw-byte-literal — RFC 7489 default pct
|
|
366
382
|
var pairs = text.split(";");
|
|
367
383
|
for (var i = 0; i < pairs.length; i += 1) {
|
|
368
384
|
var kv = pairs[i].trim();
|
|
@@ -377,6 +393,22 @@ function _parseDmarcRecord(text) {
|
|
|
377
393
|
else if (key === "pct") policy.pct = parseInt(val, 10);
|
|
378
394
|
else if (key === "adkim") policy.adkim = val.toLowerCase();
|
|
379
395
|
else if (key === "aspf") policy.aspf = val.toLowerCase();
|
|
396
|
+
else if (key === "np") {
|
|
397
|
+
var npVal = val.toLowerCase();
|
|
398
|
+
if (!DMARCBIS_VALID_NP[npVal]) {
|
|
399
|
+
throw new MailAuthError("mail-auth/dmarcbis-bad-tag",
|
|
400
|
+
"DMARC np= must be one of none|quarantine|reject, got " + JSON.stringify(val));
|
|
401
|
+
}
|
|
402
|
+
policy.np = npVal;
|
|
403
|
+
}
|
|
404
|
+
else if (key === "psd") {
|
|
405
|
+
var psdVal = val.toLowerCase();
|
|
406
|
+
if (!DMARCBIS_VALID_PSD[psdVal]) {
|
|
407
|
+
throw new MailAuthError("mail-auth/dmarcbis-bad-tag",
|
|
408
|
+
"DMARC psd= must be one of y|n|u, got " + JSON.stringify(val));
|
|
409
|
+
}
|
|
410
|
+
policy.psd = psdVal;
|
|
411
|
+
}
|
|
380
412
|
}
|
|
381
413
|
if (policy.v !== "DMARC1") {
|
|
382
414
|
throw new MailAuthError("mail-auth/dmarc-bad-version",
|
|
@@ -401,7 +433,8 @@ function _alignmentCheck(fromDomain, authDomain, mode) {
|
|
|
401
433
|
|
|
402
434
|
async function dmarcEvaluate(opts) {
|
|
403
435
|
opts = opts || {};
|
|
404
|
-
validateOpts(opts, ["from", "spf", "dkim", "dnsLookup"
|
|
436
|
+
validateOpts(opts, ["from", "spf", "dkim", "dnsLookup", "domainExists"],
|
|
437
|
+
"mail.dmarc.evaluate");
|
|
405
438
|
if (typeof opts.from !== "string") {
|
|
406
439
|
throw new MailAuthError("mail-auth/dmarc-bad-from",
|
|
407
440
|
"dmarc.evaluate: opts.from must be the From-header email address");
|
|
@@ -411,47 +444,89 @@ async function dmarcEvaluate(opts) {
|
|
|
411
444
|
throw new MailAuthError("mail-auth/dmarc-bad-from",
|
|
412
445
|
"dmarc.evaluate: opts.from is missing the @domain part");
|
|
413
446
|
}
|
|
447
|
+
fromDomain = fromDomain.toLowerCase();
|
|
448
|
+
|
|
449
|
+
// DMARCbis (draft-ietf-dmarc-dmarcbis) replaces the legacy "drop one
|
|
450
|
+
// label" org-domain heuristic with a proper Public Suffix List lookup.
|
|
451
|
+
// organizationalDomain returns null when the input IS a public suffix
|
|
452
|
+
// (e.g. "co.uk") OR when no PSL match resolves; either way, the
|
|
453
|
+
// org-domain walk below short-circuits.
|
|
454
|
+
var orgDomain = null;
|
|
455
|
+
try { orgDomain = publicSuffix.organizationalDomain(fromDomain); }
|
|
456
|
+
catch (_e) { orgDomain = null; }
|
|
414
457
|
|
|
415
458
|
var policy = null;
|
|
416
459
|
var policyOriginDomain = null;
|
|
417
460
|
var orgDomainPolicyApplied = false;
|
|
461
|
+
var psdPolicyApplied = false;
|
|
418
462
|
try {
|
|
419
463
|
var rec = await _fetchDmarcRecord(fromDomain, opts.dnsLookup);
|
|
420
464
|
if (rec) {
|
|
421
465
|
policy = _parseDmarcRecord(rec);
|
|
422
466
|
policyOriginDomain = fromDomain;
|
|
423
|
-
} else {
|
|
424
|
-
// RFC 7489 §6.6.3
|
|
425
|
-
// domain
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
467
|
+
} else if (orgDomain && orgDomain !== fromDomain) {
|
|
468
|
+
// RFC 7489 §6.6.3 + DMARCbis §4.6 — fall through to organizational
|
|
469
|
+
// domain. When the org-domain record sets sp= it applies to this
|
|
470
|
+
// subdomain; otherwise p= is the operative policy.
|
|
471
|
+
var orgRec = await _fetchDmarcRecord(orgDomain, opts.dnsLookup);
|
|
472
|
+
if (orgRec) {
|
|
473
|
+
var orgPolicy = _parseDmarcRecord(orgRec);
|
|
474
|
+
orgPolicy.p = orgPolicy.sp || orgPolicy.p;
|
|
475
|
+
policy = orgPolicy;
|
|
476
|
+
policyOriginDomain = orgDomain;
|
|
477
|
+
orgDomainPolicyApplied = true;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// DMARCbis §4.7 — when the org-domain record carries `psd=y`, OR
|
|
482
|
+
// the published record sits at the public suffix itself (TLD
|
|
483
|
+
// operator), the receiver continues lookup at the public suffix
|
|
484
|
+
// for downstream DSP cooperation. We honor the `psd=y` opt-in by
|
|
485
|
+
// surfacing the tag so operators can route on it; the explicit
|
|
486
|
+
// suffix walk below covers the suffix-record case.
|
|
487
|
+
if (!policy) {
|
|
488
|
+
var suffix = null;
|
|
489
|
+
try { suffix = publicSuffix.publicSuffix(fromDomain); }
|
|
490
|
+
catch (_e) { suffix = null; }
|
|
491
|
+
if (suffix && suffix !== fromDomain && suffix !== orgDomain) {
|
|
492
|
+
var psdRec = await _fetchDmarcRecord(suffix, opts.dnsLookup);
|
|
493
|
+
if (psdRec) {
|
|
494
|
+
var psdPolicy = _parseDmarcRecord(psdRec);
|
|
495
|
+
if (psdPolicy.psd === "y") {
|
|
496
|
+
psdPolicy.p = psdPolicy.sp || psdPolicy.p;
|
|
497
|
+
policy = psdPolicy;
|
|
498
|
+
policyOriginDomain = suffix;
|
|
499
|
+
psdPolicyApplied = true;
|
|
500
|
+
}
|
|
445
501
|
}
|
|
446
502
|
}
|
|
447
503
|
}
|
|
448
504
|
} catch (e) {
|
|
449
505
|
return { result: "temperror", explanation: e.message,
|
|
450
|
-
policy: null, alignment: { spf: false, dkim: false }
|
|
506
|
+
policy: null, alignment: { spf: false, dkim: false },
|
|
507
|
+
orgDomain: orgDomain };
|
|
451
508
|
}
|
|
452
509
|
if (!policy) {
|
|
453
510
|
return { result: "none", explanation: "no DMARC record at _dmarc." + fromDomain,
|
|
454
|
-
policy: null, alignment: { spf: false, dkim: false }
|
|
511
|
+
policy: null, alignment: { spf: false, dkim: false },
|
|
512
|
+
orgDomain: orgDomain };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// DMARCbis §4.8 — non-existent subdomain (NXDOMAIN on MX/A/AAAA for
|
|
516
|
+
// the message-from domain) gets the np= policy when published. The
|
|
517
|
+
// operator wires the existence check via opts.domainExists; absent
|
|
518
|
+
// that callback we conservatively treat the domain as existing
|
|
519
|
+
// (the np= path is opt-in observability, not a downgrade gate).
|
|
520
|
+
var npApplied = false;
|
|
521
|
+
if (typeof policy.np === "string" && typeof opts.domainExists === "function" &&
|
|
522
|
+
orgDomainPolicyApplied) {
|
|
523
|
+
var exists = true;
|
|
524
|
+
try { exists = await opts.domainExists(fromDomain); }
|
|
525
|
+
catch (_e) { exists = true; }
|
|
526
|
+
if (exists === false) {
|
|
527
|
+
policy = Object.assign({}, policy, { p: policy.np });
|
|
528
|
+
npApplied = true;
|
|
529
|
+
}
|
|
455
530
|
}
|
|
456
531
|
|
|
457
532
|
var spfDomain = (opts.spf && opts.spf.domain) || null;
|
|
@@ -492,7 +567,10 @@ async function dmarcEvaluate(opts) {
|
|
|
492
567
|
result: pass ? "pass" : "fail",
|
|
493
568
|
policy: policy,
|
|
494
569
|
policyOriginDomain: policyOriginDomain,
|
|
570
|
+
orgDomain: orgDomain,
|
|
495
571
|
orgDomainPolicyApplied: orgDomainPolicyApplied,
|
|
572
|
+
psdPolicyApplied: psdPolicyApplied,
|
|
573
|
+
npPolicyApplied: npApplied,
|
|
496
574
|
alignment: { spf: spfAligned, dkim: dkimAligned },
|
|
497
575
|
recommendedAction: recommendedAction,
|
|
498
576
|
explanation: pass
|
|
@@ -967,7 +1045,11 @@ async function arcEvaluate(rfc822, opts) {
|
|
|
967
1045
|
var trusted = {};
|
|
968
1046
|
for (var ti = 0; ti < opts.trustedSealers.length; ti += 1) {
|
|
969
1047
|
var d = opts.trustedSealers[ti];
|
|
970
|
-
if (typeof d
|
|
1048
|
+
if (typeof d !== "string" || d.length === 0) {
|
|
1049
|
+
throw new MailAuthError("mail-auth/arc-trust-eval-failed",
|
|
1050
|
+
"arc.evaluate: trustedSealers[" + ti + "] must be a non-empty domain string");
|
|
1051
|
+
}
|
|
1052
|
+
trusted[d.toLowerCase()] = true;
|
|
971
1053
|
}
|
|
972
1054
|
|
|
973
1055
|
var verdict = await arcVerify(rfc822, opts);
|
|
@@ -977,39 +1059,82 @@ async function arcEvaluate(rfc822, opts) {
|
|
|
977
1059
|
trusted: false,
|
|
978
1060
|
trustedHop: null,
|
|
979
1061
|
trustedDomain: null,
|
|
1062
|
+
// RFC 8617 §6 trust evaluation extension surface (B6).
|
|
1063
|
+
// trust: "trusted" | "unverified" | "failed"
|
|
1064
|
+
// trustedHops: [{ instance, domain }] of every trusted sealer
|
|
1065
|
+
// in the validated chain
|
|
1066
|
+
// finalAr: verbatim AAR from the most-recent hop (the
|
|
1067
|
+
// receiver's view of upstream auth results)
|
|
1068
|
+
// breakAt: first instance whose AMS or AS failed, or null
|
|
1069
|
+
// when every hop verified
|
|
1070
|
+
trust: verdict.chainStatus === "pass" ? "unverified" : "failed",
|
|
1071
|
+
trustedHops: [],
|
|
1072
|
+
finalAr: null,
|
|
1073
|
+
breakAt: null,
|
|
980
1074
|
};
|
|
981
1075
|
if (verdict.reason) out.reason = verdict.reason;
|
|
982
1076
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
//
|
|
986
|
-
// verify-result shape doesn't carry it. Walk hops most-recent-first
|
|
987
|
-
// so we attribute the trust decision to the deepest trusted sealer.
|
|
1077
|
+
// Re-extract per-hop d= (signing domain on AS) AND the AAR text from
|
|
1078
|
+
// the original headers — the verify-result shape doesn't carry
|
|
1079
|
+
// them. One pass over the header section.
|
|
988
1080
|
var headers = _parseHeaderLines(_splitHeaders(rfc822));
|
|
989
1081
|
var hopDomains = {};
|
|
1082
|
+
var hopAr = {};
|
|
990
1083
|
for (var hi = 0; hi < headers.length; hi += 1) {
|
|
991
1084
|
var line = headers[hi];
|
|
992
1085
|
var colonAt = line.indexOf(":");
|
|
993
1086
|
if (colonAt === -1) continue;
|
|
994
1087
|
var name = line.slice(0, colonAt).trim().toLowerCase();
|
|
995
|
-
if (name !== "arc-seal") continue;
|
|
996
1088
|
var value = line.slice(colonAt + 1).trim();
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1089
|
+
if (name === "arc-seal") {
|
|
1090
|
+
var iMatch = value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
|
|
1091
|
+
var dMatch = value.match(/(?:^|[;,\s])d=([^\s;]+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
|
|
1092
|
+
if (iMatch && dMatch) hopDomains[parseInt(iMatch[1], 10)] = dMatch[1].toLowerCase();
|
|
1093
|
+
} else if (name === "arc-authentication-results") {
|
|
1094
|
+
var arIMatch = value.match(/\bi\s*=\s*(\d+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
|
|
1095
|
+
if (arIMatch) hopAr[parseInt(arIMatch[1], 10)] = value;
|
|
1096
|
+
}
|
|
1000
1097
|
}
|
|
1001
1098
|
|
|
1099
|
+
// finalAr — the most-recent hop's AAR. Always populated when the
|
|
1100
|
+
// chain has at least one hop (regardless of pass/fail), so the
|
|
1101
|
+
// operator can surface upstream auth context even on a broken chain.
|
|
1102
|
+
if (verdict.hopCount > 0) {
|
|
1103
|
+
out.finalAr = hopAr[verdict.hopCount] || null;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// breakAt — first instance whose AMS or AS failed.
|
|
1107
|
+
if (Array.isArray(verdict.hops)) {
|
|
1108
|
+
for (var bi = 0; bi < verdict.hops.length; bi += 1) {
|
|
1109
|
+
var bhop = verdict.hops[bi];
|
|
1110
|
+
if (!bhop) continue;
|
|
1111
|
+
if (bhop.amsResult !== "pass" || bhop.asResult !== "pass") {
|
|
1112
|
+
out.breakAt = bhop.instance;
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (verdict.chainStatus !== "pass" || !Array.isArray(verdict.hops)) return out;
|
|
1119
|
+
|
|
1120
|
+
// Walk hops most-recent-first so we attribute the primary trust
|
|
1121
|
+
// decision to the deepest (closest-to-receiver) trusted sealer, but
|
|
1122
|
+
// also collect EVERY trusted hop so the operator can audit the
|
|
1123
|
+
// full custody chain.
|
|
1002
1124
|
for (var ri2 = verdict.hops.length - 1; ri2 >= 0; ri2 -= 1) {
|
|
1003
1125
|
var hop = verdict.hops[ri2];
|
|
1004
1126
|
if (!hop || hop.amsResult !== "pass" || hop.asResult !== "pass") continue;
|
|
1005
1127
|
var domain = hopDomains[hop.instance];
|
|
1006
1128
|
if (domain && trusted[domain]) {
|
|
1007
|
-
out.
|
|
1008
|
-
out.
|
|
1009
|
-
|
|
1010
|
-
|
|
1129
|
+
out.trustedHops.push({ instance: hop.instance, domain: domain });
|
|
1130
|
+
if (!out.trusted) {
|
|
1131
|
+
out.trusted = true;
|
|
1132
|
+
out.trustedHop = hop.instance;
|
|
1133
|
+
out.trustedDomain = domain;
|
|
1134
|
+
}
|
|
1011
1135
|
}
|
|
1012
1136
|
}
|
|
1137
|
+
out.trust = out.trusted ? "trusted" : "unverified";
|
|
1013
1138
|
return out;
|
|
1014
1139
|
}
|
|
1015
1140
|
|
|
@@ -1323,6 +1448,103 @@ function _shapeAggregateReport(parsed) {
|
|
|
1323
1448
|
return shaped;
|
|
1324
1449
|
}
|
|
1325
1450
|
|
|
1451
|
+
// ---- iprev (RFC 8601 §3) — Forward-Confirmed Reverse DNS verifier ----
|
|
1452
|
+
//
|
|
1453
|
+
// The receiving SMTP server reverse-resolves the connecting peer's IP
|
|
1454
|
+
// to a PTR name, forward-resolves the PTR name to an A or AAAA set,
|
|
1455
|
+
// and confirms the original IP appears in the forward set. Spoofed
|
|
1456
|
+
// PTR records (attacker controls the rDNS zone but not the forward
|
|
1457
|
+
// zone) fail this check and SHOULD be reflected in the
|
|
1458
|
+
// Authentication-Results header so downstream policies can react.
|
|
1459
|
+
//
|
|
1460
|
+
// Surface:
|
|
1461
|
+
// await b.mail.iprev.verify(ip)
|
|
1462
|
+
// → { result: "pass"|"fail"|"permerror"|"temperror",
|
|
1463
|
+
// ptr, forward, fcrdns, ip }
|
|
1464
|
+
//
|
|
1465
|
+
// Returns "permerror" on bad-shape input (not an IP literal); returns
|
|
1466
|
+
// "temperror" on ENODATA / ENOTFOUND / lookup failure (the receiver
|
|
1467
|
+
// retries on transient DNS faults). Pure-DNS — no operator state.
|
|
1468
|
+
|
|
1469
|
+
async function iprevVerify(ip) {
|
|
1470
|
+
if (typeof ip !== "string" || ip.length === 0) {
|
|
1471
|
+
return { result: "permerror", ip: ip || null,
|
|
1472
|
+
ptr: null, forward: [], fcrdns: false,
|
|
1473
|
+
explanation: "ip must be a non-empty string" };
|
|
1474
|
+
}
|
|
1475
|
+
if (!net.isIP(ip)) {
|
|
1476
|
+
return { result: "permerror", ip: ip,
|
|
1477
|
+
ptr: null, forward: [], fcrdns: false,
|
|
1478
|
+
explanation: "ip is not a valid IPv4 / IPv6 literal" };
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
var ptrs;
|
|
1482
|
+
try { ptrs = await dnsPromises.reverse(ip); }
|
|
1483
|
+
catch (e) {
|
|
1484
|
+
var rcode = e && e.code;
|
|
1485
|
+
if (rcode === "ENOTFOUND" || rcode === "ENODATA") {
|
|
1486
|
+
return { result: "fail", ip: ip,
|
|
1487
|
+
ptr: null, forward: [], fcrdns: false,
|
|
1488
|
+
explanation: "no PTR record for " + ip };
|
|
1489
|
+
}
|
|
1490
|
+
return { result: "temperror", ip: ip,
|
|
1491
|
+
ptr: null, forward: [], fcrdns: false,
|
|
1492
|
+
explanation: "PTR lookup failed: " + ((e && e.message) || String(e)) };
|
|
1493
|
+
}
|
|
1494
|
+
if (!Array.isArray(ptrs) || ptrs.length === 0) {
|
|
1495
|
+
return { result: "fail", ip: ip,
|
|
1496
|
+
ptr: null, forward: [], fcrdns: false,
|
|
1497
|
+
explanation: "PTR returned empty answer set" };
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// RFC 8601 §3 — when multiple PTRs exist the receiver picks ONE
|
|
1501
|
+
// and continues. We pick the first (matches mainstream MTA
|
|
1502
|
+
// behavior) and stash the rest for operator visibility on the
|
|
1503
|
+
// out-of-band metadata.
|
|
1504
|
+
var ptr = String(ptrs[0]);
|
|
1505
|
+
var isV6 = net.isIPv6(ip);
|
|
1506
|
+
var forwardAddrs;
|
|
1507
|
+
try {
|
|
1508
|
+
forwardAddrs = isV6
|
|
1509
|
+
? await dnsPromises.resolve6(ptr)
|
|
1510
|
+
: await dnsPromises.resolve4(ptr);
|
|
1511
|
+
} catch (e) {
|
|
1512
|
+
var fcode = e && e.code;
|
|
1513
|
+
if (fcode === "ENOTFOUND" || fcode === "ENODATA") {
|
|
1514
|
+
return { result: "fail", ip: ip,
|
|
1515
|
+
ptr: ptr, forward: [], fcrdns: false,
|
|
1516
|
+
explanation: "no forward record for PTR " + ptr };
|
|
1517
|
+
}
|
|
1518
|
+
if (fcode === "ETIMEOUT" || fcode === "ESERVFAIL") {
|
|
1519
|
+
return { result: "temperror", ip: ip,
|
|
1520
|
+
ptr: ptr, forward: [], fcrdns: false,
|
|
1521
|
+
explanation: "forward lookup transient failure: " + fcode };
|
|
1522
|
+
}
|
|
1523
|
+
// Anything else — propagate as temperror; Node DNS surfaces some
|
|
1524
|
+
// non-RFC error codes via the platform resolver. Permerror only
|
|
1525
|
+
// for definitive negative answers above.
|
|
1526
|
+
throw new MailAuthError("mail-auth/iprev-temperror",
|
|
1527
|
+
"iprev.verify: forward lookup of " + ptr + " threw: " +
|
|
1528
|
+
((e && e.message) || String(e)));
|
|
1529
|
+
}
|
|
1530
|
+
var forward = Array.isArray(forwardAddrs) ? forwardAddrs.slice() : [];
|
|
1531
|
+
var ipLc = ip.toLowerCase();
|
|
1532
|
+
var fcrdns = false;
|
|
1533
|
+
for (var i = 0; i < forward.length; i += 1) {
|
|
1534
|
+
if (String(forward[i]).toLowerCase() === ipLc) { fcrdns = true; break; }
|
|
1535
|
+
}
|
|
1536
|
+
return {
|
|
1537
|
+
result: fcrdns ? "pass" : "fail",
|
|
1538
|
+
ip: ip,
|
|
1539
|
+
ptr: ptr,
|
|
1540
|
+
forward: forward,
|
|
1541
|
+
fcrdns: fcrdns,
|
|
1542
|
+
explanation: fcrdns
|
|
1543
|
+
? "PTR " + ptr + " forward-resolves to " + ip
|
|
1544
|
+
: "PTR " + ptr + " does not forward-resolve to " + ip,
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1326
1548
|
module.exports = {
|
|
1327
1549
|
spf: Object.freeze({
|
|
1328
1550
|
verify: spfVerify,
|
|
@@ -1339,6 +1561,9 @@ module.exports = {
|
|
|
1339
1561
|
sign: require("./mail-arc-sign").sign, // allow:inline-require — re-export from sibling module
|
|
1340
1562
|
ALLOWED_CV: require("./mail-arc-sign").ALLOWED_CV, // allow:inline-require — re-export from sibling module
|
|
1341
1563
|
}),
|
|
1564
|
+
iprev: Object.freeze({
|
|
1565
|
+
verify: iprevVerify,
|
|
1566
|
+
}),
|
|
1342
1567
|
authResults: Object.freeze({
|
|
1343
1568
|
emit: authResultsEmit,
|
|
1344
1569
|
}),
|