@blamejs/core 0.18.38 → 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/CHANGELOG.md +121 -1
- package/NOTICE +2 -2
- package/README.md +1 -1
- package/lib/app.js +14 -5
- package/lib/file-upload.js +17 -3
- package/lib/gate-contract.js +159 -5
- package/lib/guard-filename.js +93 -18
- package/lib/guard-sql.js +7 -1
- package/lib/guard-yaml.js +64 -0
- package/lib/mail-auth.js +473 -73
- package/lib/network-dns-resolver.js +23 -23
- package/lib/network-dns.js +205 -43
- package/lib/public-suffix.js +110 -24
- package/lib/vendor/MANIFEST.json +15 -15
- package/lib/vendor/blamejs-pki.cjs +2120 -508
- package/lib/vendor/public-suffix-list.dat +4 -3
- package/lib/vendor/public-suffix-list.data.js +2201 -2201
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
var
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
1794
|
+
_alignmentCheck(fromDomain, d.d || d.domain, alignDkim, alignmentBoundary)) {
|
|
1395
1795
|
dkimAligned = true;
|
|
1396
1796
|
break;
|
|
1397
1797
|
}
|