@blamejs/core 0.18.39 → 0.18.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/mail-auth.js CHANGED
@@ -38,20 +38,45 @@
38
38
  * which ships the Public Suffix List as vendored data; DNS queries
39
39
  * go through an operator-supplied `dnsLookup` callback.
40
40
  *
41
- * POLICY DISCOVERY QUERIES THREE NAMES, NOT RFC 9989 §4.10's DNS TREE
42
- * WALK. In order: the exact domain, then the organizational domain
43
- * from `b.publicSuffix` (RFC 7489 §6.6.3's two-step lookup), and
44
- * only when neither published a policy — the public suffix itself,
45
- * which is honored when that record carries `psd=y` (RFC 9989 §4.7,
46
- * for TLD-operator policy).
41
+ * Policy discovery is RFC 9989 §4.10's DNS tree walk: `_dmarc.` at the
42
+ * Author Domain, then at each ancestor in turn, so a policy published
43
+ * at an INTERMEDIATE label is found for `a.b.example.com`,
44
+ * `_dmarc.b.example.com` is queried, which a two-step exact-then-
45
+ * organizational-domain lookup skips.
47
46
  *
48
- * The tree walk queries each ancestor in turn, so it finds a policy
49
- * published at an INTERMEDIATE label that none of those three reach:
50
- * for `a.b.example.com` it would consider `_dmarc.b.example.com`, and
51
- * a `p=reject` there evaluates as `none` here. If your senders
52
- * publish policy at intermediate labels, that gap is real mail
53
- * delivered against an intended reject. Tracked as #31; the rest of
54
- * the DMARC surface tags, alignment, reporting follows RFC 9989.
47
+ * The walk carries the spec's own denial-of-service guard: a domain
48
+ * with eight or more labels drops straight to seven remaining after
49
+ * the first query, so no Author Domain costs more than eight lookups
50
+ * however many labels a sender gives it.
51
+ *
52
+ * A single valid record carrying `psd=n` or `psd=y` stops the walk
53
+ * (§4.10 step 2), and the Organizational Domain is chosen from what
54
+ * the walk found by §4.10.2: a `psd=n` record names it directly, a
55
+ * `psd=y` record found above the starting domain names the domain one
56
+ * label below it, and otherwise it is the record with the fewest
57
+ * labels. `b.publicSuffix` still reports the PSL organizational
58
+ * domain on the result, but no longer decides which record applies.
59
+ *
60
+ * A `psd=n` record declares its own name the organizational boundary,
61
+ * and relaxed alignment is then bounded by it rather than by the PSL.
62
+ * With the boundary at `b.example.com`, mail from `a.b.example.com`
63
+ * does not align with an authenticated `evil.example.com` even though
64
+ * the list reduces both to `example.com` — honoring the record for
65
+ * policy while ignoring it for alignment would let a sibling outside
66
+ * the boundary satisfy a policy published inside it. The
67
+ * fewest-labels fallback does NOT narrow alignment: it is an
68
+ * inference from what happened to be published, not a declaration.
69
+ *
70
+ * A lookup that FAILS is not an answer of "no policy". A name that did
71
+ * not resolve may publish the controlling policy, so applying whatever
72
+ * the walk did find would downgrade an unknown `p=reject` to a
73
+ * `p=none` published higher up: an incomplete walk is a temperror.
74
+ *
75
+ * The exception is a record at the Author Domain itself. That is the
76
+ * most specific name there is and its `p=` applies directly, so no
77
+ * name the walk failed to read can be more authoritative for the
78
+ * policy — otherwise every domain would be hostage to a flaky parent
79
+ * zone. A failure at the Author Domain is a temperror outright.
55
80
  *
56
81
  * ARC (RFC 8617) — chain-of-custody verification. The framework parses
57
82
  * the existing chain headers, recomputes the per-hop signatures, and
@@ -1151,6 +1176,269 @@ async function _fetchDmarcRecord(domain, dnsLookup) {
1151
1176
  return matches[0];
1152
1177
  }
1153
1178
 
1179
+ // RFC 9989 §4.10 — the DNS Tree Walk.
1180
+ //
1181
+ // An Author Domain with many labels must not turn into many DNS queries, so
1182
+ // the spec builds the cap into the walk itself: with eight or more labels the
1183
+ // second target drops straight to seven remaining, which bounds any domain at
1184
+ // eight queries however long it is. That is a denial-of-service guard on the
1185
+ // RECEIVER, not a nicety — the sender picks the domain.
1186
+ // RFC 1035 §2.3.4 — the wire form of a name is at most 253 octets, and a single
1187
+ // label at most 63.
1188
+ var DMARC_MAX_QNAME_OCTETS = 253;
1189
+ var DNS_MAX_LABEL_OCTETS = 63;
1190
+ var DMARC_TREE_WALK_MAX_LABELS = 8;
1191
+ var DMARC_TREE_WALK_LABEL_FLOOR = 7;
1192
+
1193
+ // The labels of an Author Domain, lowercased, or null when the name is not a
1194
+ // syntactically valid domain.
1195
+ //
1196
+ // A single trailing root dot is legal and is the only thing removed. Every
1197
+ // other empty label makes the name malformed, and a malformed name is refused
1198
+ // rather than repaired: dropping empty labels wholesale would rewrite
1199
+ // `evil..example.com` into the real, separately-owned `evil.example.com` and
1200
+ // evaluate THAT domain's policy — returning a verdict, and a
1201
+ // `policyOriginDomain`, for a name the message never claimed. b.publicSuffix
1202
+ // already refuses the same shape, and the two must not disagree about what
1203
+ // counts as a domain.
1204
+ // A U-label Author Domain is converted, not refused: the mail surface carries
1205
+ // EAI/IDN addresses, and a domain owner publishes `_dmarc` under the A-label
1206
+ // form of their name. Querying `_dmarc.münchen.example` finds nothing and would
1207
+ // report the domain as having no policy — while `_dmarc.xn--mnchen-3ya.example`
1208
+ // resolves. `canonicalDomain` returns "" for a name it cannot canonicalize,
1209
+ // including one carrying an empty label, which is the refusal this wants.
1210
+ function _dmarcAuthorDomainLabels(domain) {
1211
+ // ONE rule, for every Author Domain. canonicalDomain lowercases, converts to
1212
+ // A-labels, strips exactly one root marker in whichever spelling it arrived,
1213
+ // and refuses an empty label, a control byte, a URL delimiter and an
1214
+ // over-length name. Reproducing any part of that here is how the layers
1215
+ // drifted: a local root-marker strip let a second one come off inside
1216
+ // canonicalDomain and turned `münchen.example。.` into a real, different
1217
+ // domain, while a local ASCII fast path skipped the character and length
1218
+ // rules entirely and accepted `example.com/evil` — a name that matters
1219
+ // because `domainToASCII` TRUNCATES at the delimiter, so it can masquerade
1220
+ // as a trusted prefix of itself.
1221
+ //
1222
+ // Going through the same function the authenticated SPF and DKIM domains
1223
+ // reach via `_alignmentCheck` is also what lets a message align with itself:
1224
+ // both sides end up in one canonical form.
1225
+ var d = publicSuffix.canonicalDomain(String(domain));
1226
+ if (!d) return null;
1227
+ // RFC 1035 §2.3.4 bounds a LABEL at 1..63 octets as well as the whole name at
1228
+ // 253, and `canonicalDomain` enforces only the second — it is the framework's
1229
+ // definition of a domain NAME, and a label cap is a DNS wire rule rather than
1230
+ // a naming one. Checking it here keeps the answer the same whichever resolver
1231
+ // is wired in: the default one refuses an over-long label as `dns/bad-host`
1232
+ // and the evaluation temperrors, while an operator's own `dnsLookup` would
1233
+ // answer for it and let a policy apply to a name that cannot exist.
1234
+ var labels = d.split(".");
1235
+ for (var i = 0; i < labels.length; i += 1) {
1236
+ if (labels[i].length === 0 || labels[i].length > DNS_MAX_LABEL_OCTETS) return null;
1237
+ }
1238
+ return labels;
1239
+ }
1240
+
1241
+ // The ordered list of names the walk queries, starting with the domain itself.
1242
+ // For the RFC's own example, a.b.c.d.e.f.g.h.i.j.mail.example.com, this is the
1243
+ // eight names it lists. A malformed Author Domain has no targets; `evaluate`
1244
+ // refuses it before the walk is reached.
1245
+ function _dmarcTreeWalkTargets(domain) {
1246
+ var labels = _dmarcAuthorDomainLabels(domain);
1247
+ if (labels === null || labels.length === 0) return [];
1248
+ var targets = [labels.join(".")];
1249
+ var rest = labels.length >= DMARC_TREE_WALK_MAX_LABELS
1250
+ ? labels.slice(labels.length - DMARC_TREE_WALK_LABEL_FLOOR) // keep seven
1251
+ : labels.slice(1); // drop the leftmost
1252
+ while (rest.length > 0) {
1253
+ targets.push(rest.join("."));
1254
+ rest = rest.slice(1);
1255
+ }
1256
+ return targets;
1257
+ }
1258
+
1259
+ // Walk the targets in order, collecting every valid record. `_fetchDmarcRecord`
1260
+ // already applies step 2's first two rules — a record whose v= tag does not
1261
+ // name this DMARC version is discarded, and multiple records at one target are
1262
+ // ALL discarded. The third rule is here: a single valid record carrying `psd=n`
1263
+ // or `psd=y` stops the walk, so nothing above it is queried.
1264
+ // A lookup that FAILED is not the same answer as one that said "no such
1265
+ // record" — RFC 9989 is explicit that only the latter means DMARC does not
1266
+ // apply. The walk therefore records transient failures instead of letting the
1267
+ // first one abort it: a policy found at one name is still a policy even if an
1268
+ // ancestor's lookup fell over, and `transient` only decides the outcome when
1269
+ // the walk found nothing at all.
1270
+ //
1271
+ // A failure at the STARTING domain is different in kind and rethrown — that is
1272
+ // the authoritative lookup for this message, and answering "no policy" because
1273
+ // it was unreachable would be inventing an answer.
1274
+ async function _dmarcTreeWalk(domain, dnsLookup) {
1275
+ var targets = _dmarcTreeWalkTargets(domain);
1276
+ var found = [];
1277
+ var transient = false;
1278
+ // The first target is the Author Domain as the walk NORMALIZED it — lowercased
1279
+ // and with the root dot dropped. Callers must compare against this rather than
1280
+ // against the raw From-header domain: `example.com.` and `EXAMPLE.com` would
1281
+ // otherwise never match their own record, which would demote it to an
1282
+ // ancestor and apply `sp=` where `p=` governs. A `p=reject; sp=none` record
1283
+ // would then permit exactly the mail it rejects.
1284
+ var start = targets.length > 0 ? targets[0] : null;
1285
+ // Once ANY record is in hand, a name above it can only refine the walk, never
1286
+ // decide it: the walk runs most-specific first, so the closest record found
1287
+ // supplies `p=` directly and every closer name has already answered. A broken
1288
+ // or unresolvable record published higher is then a fact about that zone, and
1289
+ // a domain owner controls what they publish rather than what their parent
1290
+ // does — abandoning a `p=reject` that resolved cleanly over it would let a
1291
+ // parent turn its children's policy into no policy at all.
1292
+ //
1293
+ // Before the first record there is nothing to protect, the unread name may be
1294
+ // the one whose policy would have applied, and the walk cannot know what it
1295
+ // said: the error stands rather than skipping to a weaker record above it.
1296
+ //
1297
+ // True when the walk passed over a name without reading it. Keeping the
1298
+ // Author Domain's policy across such a gap is a decision about the POLICY
1299
+ // only: a name that was not read may publish `psd=n`, a boundary narrower
1300
+ // than the Public Suffix List, and relaxed alignment computed without it
1301
+ // would admit a sibling that boundary exists to separate. `evaluate` withholds
1302
+ // relaxed alignment while this is set.
1303
+ var skipped = false;
1304
+ // True when a name the walk could not read is MORE specific than the closest
1305
+ // record it found — including the case where it found none at all. Direction
1306
+ // is what decides whether an unread name can still matter: the walk runs
1307
+ // most-specific first, so once a record is in hand every closer name has
1308
+ // already answered, and a name above it can carry a boundary but never a
1309
+ // policy that beats it. A name BELOW it is the opposite — its policy would
1310
+ // have won, and the walk cannot know what it said.
1311
+ var unreadBelowClosestRecord = false;
1312
+ // How many names the resolver was actually asked about — not how many were
1313
+ // generated. The two differ whenever the walk stops early or steps over an
1314
+ // unqueryable name, and the count reaches operators in the "no DMARC record
1315
+ // at any of the N name(s)" explanation, where a name nothing was sent to
1316
+ // would read as one that answered nothing.
1317
+ var queried = 0;
1318
+ for (var i = 0; i < targets.length; i += 1) {
1319
+ var raw;
1320
+ var parsed;
1321
+ // `_dmarc.` is seven octets the Author Domain did not choose, so a domain
1322
+ // close to the RFC 1035 ceiling can be perfectly valid while its policy
1323
+ // name is not. Nobody can publish a record at an unrepresentable name —
1324
+ // including its owner — so this is "no record exists here", the same as an
1325
+ // empty answer, and the walk continues to the ancestors.
1326
+ //
1327
+ // Treating it as a lookup failure aborted the whole evaluation with a
1328
+ // temperror and never asked them, so a `p=reject` one label up was neither
1329
+ // found nor applied.
1330
+ if (("_dmarc." + targets[i]).length > DMARC_MAX_QNAME_OCTETS) continue;
1331
+ try {
1332
+ queried += 1;
1333
+ raw = await _fetchDmarcRecord(targets[i], dnsLookup);
1334
+ if (!raw) continue;
1335
+ parsed = _parseDmarcRecord(raw);
1336
+ } catch (e) {
1337
+ if (i === 0) throw e;
1338
+ if (_isPermanentDmarcError(e)) {
1339
+ // Any record already found is enough, not only one at the Author
1340
+ // Domain: the walk runs most-specific first, so a malformed record at
1341
+ // a name ABOVE the closest one it read cannot carry a policy that
1342
+ // outranks it. Discarding a clean `p=reject` over a broken record
1343
+ // published higher in the tree hands a parent zone the power to turn
1344
+ // its children's policy into no policy at all.
1345
+ // `skipped` is NOT set here. It means "a name went past without being
1346
+ // read", and such a name may publish `psd=n` — a boundary narrower than
1347
+ // the Public Suffix List — which is why it withholds relaxed alignment.
1348
+ // A malformed record is the opposite: the walk READ it, and a record
1349
+ // that does not parse declares no boundary at all. Counting it as
1350
+ // unread forces both alignment modes to strict and fails mail that
1351
+ // aligns correctly under the closer record's own relaxed policy.
1352
+ if (found.length > 0) continue;
1353
+ throw e;
1354
+ }
1355
+ transient = true;
1356
+ skipped = true;
1357
+ if (found.length === 0) unreadBelowClosestRecord = true;
1358
+ continue;
1359
+ }
1360
+ found.push({ domain: targets[i], policy: parsed, labels: targets[i].split(".").length });
1361
+ if (parsed.psd === "n" || parsed.psd === "y") break;
1362
+ }
1363
+ return { found: found, transient: transient, start: start, skipped: skipped,
1364
+ queried: queried, unreadBelowClosestRecord: unreadBelowClosestRecord };
1365
+ }
1366
+
1367
+ // RFC 9989 §4.10.1 — a syntactically invalid or policy-less record is a
1368
+ // PERMANENT error (permerror); only a DNS resolution failure is transient.
1369
+ // These are the codes `_parseDmarcRecord` actually raises, and the single place
1370
+ // that decides: the walk asks it to know whether to keep going, and evaluate()
1371
+ // asks it to choose between permerror and temperror. Two copies of this list
1372
+ // drift, and a code that matches neither reads as transient — which would let a
1373
+ // malformed record at one name be skipped and a policy from another applied.
1374
+ function _isPermanentDmarcError(e) {
1375
+ return !!(e && typeof e.code === "string" &&
1376
+ (e.code === "mail-auth/dmarc-bad-version" ||
1377
+ e.code === "mail-auth/dmarcbis-bad-tag" ||
1378
+ e.code === "mail-auth/dmarc-missing-policy"));
1379
+ }
1380
+
1381
+ // The walk's record for one name, or null.
1382
+ function _dmarcRecordAt(found, domain) {
1383
+ return found.filter(function (r) { return r.domain === domain; })[0] || null;
1384
+ }
1385
+
1386
+ // RFC 9989 §4.10.2 — which of the records the walk produced names the
1387
+ // Organizational Domain, in the spec's order of precedence.
1388
+ function _dmarcOrganizationalDomain(found, startDomain) {
1389
+ var i;
1390
+ for (i = 0; i < found.length; i += 1) {
1391
+ if (found[i].policy.psd === "n") return { domain: found[i].domain, via: "psd-n" };
1392
+ }
1393
+ for (i = 0; i < found.length; i += 1) {
1394
+ if (found[i].policy.psd !== "y") continue;
1395
+ // A `psd=y` record AT the starting domain has nothing below it to name —
1396
+ // the walk started there — and it shares an organization with NOBODY.
1397
+ //
1398
+ // The record says this name is a public suffix, which makes each immediate
1399
+ // child a separately registrable name belonging to whoever registered it.
1400
+ // So the declaring name and everything under it are different
1401
+ // organizations, and relaxed alignment — whose whole job is to join names
1402
+ // within one organization — has nothing to join. Reporting the name as an
1403
+ // organizational boundary would be worse than reporting none: every
1404
+ // registrant under the suffix would sit inside it and could authenticate
1405
+ // mail claiming to come from the registry itself.
1406
+ //
1407
+ // Falling through to the fewest-labels inference is equally wrong, because
1408
+ // that reduces both sides to the Public Suffix List answer and lets an
1409
+ // authenticated sibling satisfy the `p=reject` the declaring domain
1410
+ // published about itself. RFC 9989 §4.10 stops the walk on either psd
1411
+ // value, so no ancestor is queried that could supply a boundary later.
1412
+ //
1413
+ // What is left is exact alignment, and `evaluate` reads this `via` to
1414
+ // require it.
1415
+ if (found[i].domain === startDomain) {
1416
+ return { domain: startDomain, via: "psd-y-self" };
1417
+ }
1418
+ var below = _labelBelow(startDomain, found[i].domain);
1419
+ if (below) return { domain: below, via: "psd-y" };
1420
+ }
1421
+ var fewest = null;
1422
+ for (i = 0; i < found.length; i += 1) {
1423
+ if (fewest === null || found[i].labels < fewest.labels) fewest = found[i];
1424
+ }
1425
+ return fewest ? { domain: fewest.domain, via: "fewest-labels" } : null;
1426
+ }
1427
+
1428
+ // The name one label below `ancestor` on the path from `startDomain`, or null
1429
+ // when `ancestor` is not an ancestor of it.
1430
+ // Both arguments are walk targets, which carry no empty label — so no label is
1431
+ // dropped here. Comparing the labels as they are keeps it that way: silently
1432
+ // removing one would make a name read as an ancestor of a domain it is not on
1433
+ // the path of.
1434
+ function _labelBelow(startDomain, ancestor) {
1435
+ var start = String(startDomain).toLowerCase().split(".");
1436
+ var anc = String(ancestor).toLowerCase().split(".");
1437
+ if (anc.length >= start.length) return null;
1438
+ if (start.slice(start.length - anc.length).join(".") !== anc.join(".")) return null;
1439
+ return start.slice(start.length - anc.length - 1).join(".");
1440
+ }
1441
+
1154
1442
  // RFC 9989 (DMARCbis) base policy keys
1155
1443
  // extensions:
1156
1444
  // np=<none|quarantine|reject> policy for non-existent subdomains
@@ -1232,7 +1520,15 @@ function _parseDmarcRecord(text) {
1232
1520
  return policy;
1233
1521
  }
1234
1522
 
1235
- function _alignmentCheck(fromDomain, authDomain, mode) {
1523
+ // `boundary`, when given, is the Organizational Domain the RFC 9989 tree walk
1524
+ // established — a name that published `psd=n`, which is a domain declaring
1525
+ // itself the organizational boundary. Relaxed alignment must respect it: with
1526
+ // the boundary at `b.example.com`, mail from `a.b.example.com` does NOT align
1527
+ // with an authenticated `evil.example.com`, even though the Public Suffix List
1528
+ // reduces both to `example.com`. Honouring the record for policy while ignoring
1529
+ // it here would let a sibling outside the declared boundary satisfy a policy
1530
+ // published inside it.
1531
+ function _alignmentCheck(fromDomain, authDomain, mode, boundary) {
1236
1532
  if (!fromDomain || !authDomain) return false;
1237
1533
  // Canonicalize both domains identically (lowercase + trailing-dot strip + IDN
1238
1534
  // A-label) so strict alignment compares the same host form the relaxed PSL
@@ -1249,6 +1545,13 @@ function _alignmentCheck(fromDomain, authDomain, mode) {
1249
1545
  // aligned even though they're separately registered. PSL lookup
1250
1546
  // closes the gap.
1251
1547
  if (f === a) return true;
1548
+ if (boundary) {
1549
+ // Both sides must sit at or under the declared boundary. Reducing to the
1550
+ // PSL organizational domain here would step over it.
1551
+ var b = publicSuffix.canonicalDomain(boundary);
1552
+ if (!b) return false;
1553
+ return _isAtOrUnder(f, b) && _isAtOrUnder(a, b);
1554
+ }
1252
1555
  var fOrg = null;
1253
1556
  var aOrg = null;
1254
1557
  try { fOrg = publicSuffix.organizationalDomain(f); } catch (_e) { fOrg = null; }
@@ -1257,6 +1560,17 @@ function _alignmentCheck(fromDomain, authDomain, mode) {
1257
1560
  return false;
1258
1561
  }
1259
1562
 
1563
+ // True when `domain` IS `ancestor` or a subdomain of it — compared label by
1564
+ // label, so `evil-b.example.com` does not read as a subdomain of
1565
+ // `b.example.com` the way a text-suffix test would.
1566
+ function _isAtOrUnder(domain, ancestor) {
1567
+ if (domain === ancestor) return true;
1568
+ var d = String(domain).split(".");
1569
+ var a = String(ancestor).split(".");
1570
+ if (d.length <= a.length) return false;
1571
+ return d.slice(d.length - a.length).join(".") === a.join(".");
1572
+ }
1573
+
1260
1574
  async function dmarcEvaluate(opts) {
1261
1575
  opts = opts || {};
1262
1576
  validateOpts(opts, ["from", "spf", "dkim", "dnsLookup", "domainExists",
@@ -1281,6 +1595,13 @@ async function dmarcEvaluate(opts) {
1281
1595
  "dmarc.evaluate: opts.from is missing the @domain part");
1282
1596
  }
1283
1597
  fromDomain = fromDomain.toLowerCase();
1598
+ // An empty label makes the name malformed. It is refused here rather than
1599
+ // normalized away, so no evaluation can end up reporting the policy of a
1600
+ // neighbouring domain that the repair happened to produce.
1601
+ if (_dmarcAuthorDomainLabels(fromDomain) === null) {
1602
+ throw new MailAuthError("mail-auth/dmarc-bad-from",
1603
+ "dmarc.evaluate: the From domain has an empty label (not a valid domain name)");
1604
+ }
1284
1605
 
1285
1606
  // RFC 9989 replaces the legacy "drop one
1286
1607
  // label" org-domain heuristic with a proper Public Suffix List lookup.
@@ -1295,72 +1616,136 @@ async function dmarcEvaluate(opts) {
1295
1616
  var policyOriginDomain = null;
1296
1617
  var orgDomainPolicyApplied = false;
1297
1618
  var psdPolicyApplied = false;
1619
+ // Set only when the walk found a record declaring itself the organizational
1620
+ // boundary (`psd=n`). Relaxed alignment is then bounded by that name instead
1621
+ // of by the Public Suffix List, which would reach past it.
1622
+ var alignmentBoundary = null;
1623
+ // Set when the walk passed over a name it could not read. The exemptions below
1624
+ // keep the Author Domain's policy across such a gap; they do not decide
1625
+ // alignment, which a boundary at the unread name could have narrowed.
1626
+ var walkSkipped = false;
1627
+ // Set when the Author Domain declared ITSELF a public suffix. It then shares
1628
+ // an organization with no other name, so relaxed alignment has nothing to
1629
+ // join and only an exact match can hold.
1630
+ var alignmentExactOnly = false;
1298
1631
  try {
1299
- var rec = await _fetchDmarcRecord(fromDomain, opts.dnsLookup);
1300
- if (rec) {
1301
- policy = _parseDmarcRecord(rec);
1302
- policyOriginDomain = fromDomain;
1303
- } else if (orgDomain && orgDomain !== fromDomain) {
1304
- // Fall through to the organizational domain. When the org-domain
1305
- // record sets sp= it applies to this subdomain; otherwise p= is the
1306
- // operative policy.
1632
+ // RFC 9989 §4.10 the DNS tree walk. Every ancestor is queried in turn,
1633
+ // which finds a policy published at an INTERMEDIATE label; the two-step
1634
+ // lookup this replaces (exact domain, then the PSL organizational domain)
1635
+ // skipped those, so a `p=reject` at `b.example.com` evaluated as `none`
1636
+ // for `a.b.example.com` and mail the domain owner meant to reject was
1637
+ // delivered.
1638
+ var walk = await _dmarcTreeWalk(fromDomain, opts.dnsLookup);
1639
+ var found = walk.found;
1640
+ var atStart = _dmarcRecordAt(found, walk.start);
1641
+ // A lookup that never completed means the walk cannot say what policy
1642
+ // applies — only that it could not tell. Answering with whatever it did
1643
+ // find would apply a policy from higher in the tree while a name it failed
1644
+ // to read may publish a stricter one, so an unavailable ancestor would
1645
+ // downgrade `p=reject` to a `p=none` published above it.
1646
+ //
1647
+ // The exception is a record at the Author Domain itself. That is the most
1648
+ // specific name there is and its `p=` applies directly, so no name the walk
1649
+ // failed to read can be more authoritative for the policy. Without this,
1650
+ // any flaky parent zone would temperror mail whose own policy resolved
1651
+ // cleanly. RFC 9989 §4.10 leaves the handling of a DNS error during the
1652
+ // walk to the receiver, so this is a choice the specification allows —
1653
+ // about the policy alone. What an unread name could still have changed is
1654
+ // the alignment boundary, and `walkSkipped` withholds relaxed alignment for
1655
+ // exactly that reason.
1656
+ walkSkipped = walk.skipped === true;
1657
+ // Only a name the walk could not read that is MORE SPECIFIC than the
1658
+ // closest record it found can change which policy applies — the record at
1659
+ // the Author Domain is just the strongest case of that, not a special one.
1660
+ // A failure above a record found at an intermediate name used to discard
1661
+ // that record and answer temperror, throwing away a `p=reject` the walk had
1662
+ // already read because a name that could not have outranked it timed out.
1663
+ if (walk.unreadBelowClosestRecord) {
1664
+ throw new MailAuthError("mail-auth/dmarc-lookup-failed",
1665
+ "DMARC tree walk could not complete for " + fromDomain +
1666
+ " — a name below the record that would apply did not resolve");
1667
+ }
1668
+ // RFC 9989 §4.10.2 picks which of the found records applies. `orgDomain`
1669
+ // keeps the PSL answer: that is what the field has always reported and what
1670
+ // callers surface, and the name the walk selects is reported separately as
1671
+ // `policyOriginDomain`. Overwriting one with the other would silently
1672
+ // change the meaning of an existing result field.
1673
+ var selected = _dmarcOrganizationalDomain(found, walk.start);
1674
+ // Both `psd` values DECLARE a boundary, and each names a different one:
1675
+ // `psd=n` says "this name is the Organizational Domain", `psd=y` says "this
1676
+ // name is a public suffix", which puts the Organizational Domain one label
1677
+ // below it. `_dmarcOrganizationalDomain` has already resolved either into
1678
+ // the name itself, so both constrain alignment the same way.
1679
+ //
1680
+ // The `psd=y` case is the one the Public Suffix List is most likely to get
1681
+ // wrong — a multi-label PSD the vendored list does not carry — so reducing
1682
+ // to the list for alignment there puts every tenant of a platform in one
1683
+ // organization: a `p=reject` at `platform.example` was satisfied by an
1684
+ // authenticated `evil.platform.example` for mail from
1685
+ // `tenant.platform.example`.
1686
+ //
1687
+ // The fewest-labels fallback is NOT a declaration. It is an inference from
1688
+ // what happened to be published, and narrowing alignment on an inference
1689
+ // would refuse mail that aligns perfectly well today.
1690
+ if (selected && (selected.via === "psd-n" || selected.via === "psd-y")) {
1691
+ alignmentBoundary = selected.domain;
1692
+ }
1693
+ // `psd-y-self` — the Author Domain declared ITSELF a public suffix, so it
1694
+ // shares an organization with nothing and there is no boundary to be inside
1695
+ // of. Exact alignment is the only kind that can hold: a descendant is a
1696
+ // separate registrant, and a sibling is a separate name entirely.
1697
+ if (selected && selected.via === "psd-y-self") alignmentExactOnly = true;
1698
+
1699
+ if (atStart) {
1700
+ // A record at the Author Domain itself: `p=` is the operative policy,
1701
+ // because no subdomain rule is in play.
1702
+ policy = atStart.policy;
1703
+ policyOriginDomain = atStart.domain;
1704
+ } else if (found.length > 0) {
1705
+ // Which name is the Organizational Domain and which record supplies the
1706
+ // policy are two different questions, and `selected` only answers the
1707
+ // first. The policy comes from the CLOSEST record the walk found — the
1708
+ // walk queries from the Author Domain upward, so that is the first one.
1307
1709
  //
1308
- // This is the RFC 7489 §6.6.3 two-step lookup — the exact domain, then
1309
- // the PSL organizational domain NOT RFC 9989 §4.10's DNS tree walk,
1310
- // which queries each ancestor in turn and so can find a policy at an
1311
- // intermediate label this path skips. For `a.b.example.com` the tree
1312
- // walk would consider `_dmarc.b.example.com`; this does not.
1313
- var orgRec = await _fetchDmarcRecord(orgDomain, opts.dnsLookup);
1314
- if (orgRec) {
1315
- var orgPolicy = _parseDmarcRecord(orgRec);
1316
- orgPolicy.p = orgPolicy.sp || orgPolicy.p;
1317
- policy = orgPolicy;
1318
- policyOriginDomain = orgDomain;
1319
- orgDomainPolicyApplied = true;
1320
- }
1321
- }
1322
-
1323
- // RFC 9989 §4.7 when the org-domain record carries `psd=y`, OR
1324
- // the published record sits at the public suffix itself (TLD
1325
- // operator), the receiver continues lookup at the public suffix
1326
- // for downstream DSP cooperation. We honor the `psd=y` opt-in by
1327
- // surfacing the tag so operators can route on it; the explicit
1328
- // suffix walk below covers the suffix-record case.
1329
- if (!policy) {
1330
- var suffix = null;
1331
- try { suffix = publicSuffix.publicSuffix(fromDomain); }
1332
- catch (_e) { suffix = null; }
1333
- if (suffix && suffix !== fromDomain && suffix !== orgDomain) {
1334
- var psdRec = await _fetchDmarcRecord(suffix, opts.dnsLookup);
1335
- if (psdRec) {
1336
- var psdPolicy = _parseDmarcRecord(psdRec);
1337
- if (psdPolicy.psd === "y") {
1338
- psdPolicy.p = psdPolicy.sp || psdPolicy.p;
1339
- policy = psdPolicy;
1340
- policyOriginDomain = suffix;
1341
- psdPolicyApplied = true;
1342
- }
1343
- }
1344
- }
1710
+ // Answering both with `selected` recreates exactly the downgrade this
1711
+ // walk exists to stop: with `p=reject` at `b.example.com` and
1712
+ // `p=none; psd=n` at `example.com`, mail from `a.b.example.com` would
1713
+ // take the `p=none` published two labels up. The boundary is still the
1714
+ // `psd=n` name; the policy is still the closer record.
1715
+ var chosen = found[0];
1716
+ // The record governs a subdomain here, so `sp=` is the operative policy
1717
+ // when it is set.
1718
+ chosen.policy.p = chosen.policy.sp || chosen.policy.p;
1719
+ policy = chosen.policy;
1720
+ policyOriginDomain = chosen.domain;
1721
+ // Which flag is reported describes the record that was APPLIED, not the
1722
+ // boundary. A `psd=y` record two labels up can be the boundary while an
1723
+ // ordinary domain closer in supplies the policy — that is an
1724
+ // organizational-domain policy, not a public-suffix operator's.
1725
+ if (chosen.policy.psd === "y") psdPolicyApplied = true;
1726
+ else orgDomainPolicyApplied = true;
1345
1727
  }
1346
1728
  } catch (e) {
1347
- // RFC 9989 §4.10.1a syntactically invalid / policy-less record is a
1348
- // PERMANENT error (permerror); only transient DNS resolution failures
1349
- // are temperror. The DMARC parser raises typed MailAuthError codes for
1350
- // the permanent cases (bad version, unrecognized tag value, missing
1351
- // required p=); anything else (DNS lookup) is transient. Either way the
1352
- // disposition is fail-closed — neither path yields recommendedAction
1353
- // "deliver".
1354
- var permanent = e && typeof e.code === "string" &&
1355
- (e.code === "mail-auth/dmarc-bad-version" ||
1356
- e.code === "mail-auth/dmarcbis-bad-tag" ||
1357
- e.code === "mail-auth/dmarc-missing-policy");
1729
+ // Either disposition is fail-closed neither yields recommendedAction
1730
+ // "deliver". Which one is _isPermanentDmarcError's single answer.
1731
+ var permanent = _isPermanentDmarcError(e);
1358
1732
  return { result: permanent ? "permerror" : "temperror", explanation: e.message,
1359
1733
  policy: null, alignment: { spf: false, dkim: false },
1360
1734
  orgDomain: orgDomain };
1361
1735
  }
1362
1736
  if (!policy) {
1363
- return { result: "none", explanation: "no DMARC record at _dmarc." + fromDomain,
1737
+ // Naming one domain was accurate when discovery queried one domain. The
1738
+ // walk queries up to eight, under the normalized name — so reporting the
1739
+ // raw From domain would name a name that was never asked for, which for a
1740
+ // U-label address is a different name entirely.
1741
+ //
1742
+ // The count is reported rather than "every ancestor", because for a domain
1743
+ // of eight or more labels the walk deliberately skips the names between the
1744
+ // start and the seven-label suffix. Claiming those were searched would be
1745
+ // the same kind of overstatement in the other direction.
1746
+ return { result: "none",
1747
+ explanation: "no DMARC record at any of the " + walk.queried +
1748
+ " name(s) queried from _dmarc." + walk.start + " upward",
1364
1749
  policy: null, alignment: { spf: false, dkim: false },
1365
1750
  orgDomain: orgDomain };
1366
1751
  }
@@ -1385,13 +1770,28 @@ async function dmarcEvaluate(opts) {
1385
1770
  var spfDomain = (opts.spf && opts.spf.domain) || null;
1386
1771
  var dkimResults = Array.isArray(opts.dkim) ? opts.dkim : (opts.dkim ? [opts.dkim] : []);
1387
1772
 
1773
+ // Applying the Author Domain's own policy across a name the walk could not
1774
+ // read decides the POLICY, not the alignment. That name may publish `psd=n`,
1775
+ // a boundary narrower than the Public Suffix List, so relaxed alignment
1776
+ // computed without it can admit an authenticated sibling the boundary exists
1777
+ // to separate — a `p=reject` published inside the boundary satisfied from
1778
+ // outside it.
1779
+ //
1780
+ // Relaxed is therefore withheld until the walk completes. Strict is not: every
1781
+ // boundary the walk could have found lies at or above the Author Domain, so an
1782
+ // exact match is aligned under all of them and under none of them. The message
1783
+ // that cannot be decided is the one that is refused.
1784
+ var relaxedUnavailable = walkSkipped || alignmentExactOnly;
1785
+ var alignSpf = relaxedUnavailable ? "s" : policy.aspf;
1786
+ var alignDkim = relaxedUnavailable ? "s" : policy.adkim;
1787
+
1388
1788
  var spfAligned = opts.spf && opts.spf.result === "pass" &&
1389
- _alignmentCheck(fromDomain, spfDomain, policy.aspf);
1789
+ _alignmentCheck(fromDomain, spfDomain, alignSpf, alignmentBoundary);
1390
1790
  var dkimAligned = false;
1391
1791
  for (var i = 0; i < dkimResults.length; i += 1) {
1392
1792
  var d = dkimResults[i];
1393
1793
  if (d && d.result === "pass" &&
1394
- _alignmentCheck(fromDomain, d.d || d.domain, policy.adkim)) {
1794
+ _alignmentCheck(fromDomain, d.d || d.domain, alignDkim, alignmentBoundary)) {
1395
1795
  dkimAligned = true;
1396
1796
  break;
1397
1797
  }