mailauth 6.0.0 → 7.1.0
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +113 -39
- package/bin/mailauth.js +15 -6
- package/cli.md +9 -3
- package/docs/arc.md +126 -43
- package/docs/bimi.md +54 -37
- package/docs/dkim.md +79 -40
- package/docs/dmarc.md +80 -33
- package/docs/mta-sts.md +140 -42
- package/docs/spf.md +69 -4
- package/index.d.ts +508 -43
- package/lib/arc/index.d.ts +10 -8
- package/lib/arc/index.js +505 -124
- package/lib/bimi/index.js +162 -81
- package/lib/commands/report.js +3 -1
- package/lib/commands/seal.js +35 -5
- package/lib/commands/sign.js +8 -1
- package/lib/commands/spf.js +8 -0
- package/lib/dkim/body/relaxed.js +6 -5
- package/lib/dkim/body/simple.js +1 -3
- package/lib/dkim/dkim-signer.js +219 -33
- package/lib/dkim/dkim-verifier.js +577 -231
- package/lib/dkim/header/relaxed.js +6 -43
- package/lib/dkim/header/signature-opts.js +87 -0
- package/lib/dkim/header/simple.js +6 -43
- package/lib/dkim/message-parser.js +18 -4
- package/lib/dkim/sign.d.ts +13 -7
- package/lib/dkim/sign.js +18 -2
- package/lib/dkim/syntax.js +31 -0
- package/lib/dkim/verify.js +1 -0
- package/lib/dmarc/author-domain.js +181 -0
- package/lib/dmarc/get-dmarc-record.js +154 -47
- package/lib/dmarc/index.d.ts +29 -2
- package/lib/dmarc/verify.js +120 -44
- package/lib/mailauth.js +77 -36
- package/lib/mta-sts.d.ts +77 -11
- package/lib/mta-sts.js +497 -182
- package/lib/parse-dkim-headers.js +376 -191
- package/lib/parse-received.js +104 -32
- package/lib/spf/index.d.ts +37 -1
- package/lib/spf/index.js +260 -28
- package/lib/spf/macro.js +50 -13
- package/lib/spf/spf-verify.js +440 -204
- package/lib/spf/syntax.js +366 -0
- package/lib/tools.js +522 -116
- package/package.json +1 -1
|
@@ -24,38 +24,125 @@ const WALK_MAX_LABELS = 7;
|
|
|
24
24
|
// A domain literal or an IP address is not a DNS name and can not publish a DMARC record
|
|
25
25
|
const isAddress = domain => domain.startsWith('[') || net.isIP(domain) !== 0;
|
|
26
26
|
|
|
27
|
-
// Whether
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
// Whether a name can exist in the DNS: no empty labels, labels of at most 63 octets and a
|
|
28
|
+
// name of at most 253 octets (RFC 1035 2.3.4). Anything else would only produce a resolver
|
|
29
|
+
// error that looks like a transient failure.
|
|
30
|
+
const isValidName = name => name.length <= 253 && name.split('.').every(label => label.length && label.length <= 63);
|
|
31
|
+
|
|
32
|
+
const POLICY_VALUES = new Set(['none', 'quarantine', 'reject']);
|
|
33
|
+
|
|
34
|
+
// A reporting URI (RFC 9989 4.8 dmarc-uri): an RFC 3986 URI, so a scheme, a ":" and a possibly
|
|
35
|
+
// empty rest made of URI characters, where commas and "!" must be percent-encoded. "mailto:" is
|
|
36
|
+
// therefore valid and a bare address is not. An obsolete "!size" suffix does not change that.
|
|
37
|
+
const REPORTING_URI = /^[a-z][a-z0-9+.-]*:(?:[a-z0-9\-._~:/?#[\]@$&'()*+;=]|%[0-9a-f]{2})*$/i;
|
|
38
|
+
const OBS_REPORT_SIZE = /![0-9]+[kmgt]?$/i;
|
|
39
|
+
|
|
40
|
+
// "rua" lists at least one syntactically valid reporting URI (RFC 9989 4.10.1)
|
|
41
|
+
const hasReportingUri = record =>
|
|
42
|
+
typeof record.rua === 'string' && record.rua.split(',').some(uri => REPORTING_URI.test(uri.trim().replace(OBS_REPORT_SIZE, '')));
|
|
43
|
+
|
|
44
|
+
// Returns the policy tags a record is applied with (RFC 9989 4.10.1). A record without a
|
|
45
|
+
// valid "p", or with an invalid "sp" or "np", acts as p=none when it asks for aggregate
|
|
46
|
+
// reports, and gets no DMARC processing at all (null) otherwise.
|
|
47
|
+
const getPolicyTags = record => {
|
|
48
|
+
const isValid = value => value === undefined || POLICY_VALUES.has(value);
|
|
49
|
+
if (POLICY_VALUES.has(record.p) && isValid(record.sp) && isValid(record.np)) {
|
|
50
|
+
return { p: record.p, sp: record.sp, np: record.np };
|
|
51
|
+
}
|
|
52
|
+
return hasReportingUri(record) ? { p: 'none' } : null;
|
|
33
53
|
};
|
|
34
54
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
// Domain existence test (RFC 9989 A.4): any RR makes a name exist, only NXDOMAIN means it
|
|
56
|
+
// does not. A query for A tells the two apart, NODATA is an existing name without A records.
|
|
57
|
+
// A query for A follows a CNAME and gets the target's NXDOMAIN when the target is gone (RFC 6604 3),
|
|
58
|
+
// so after an NXDOMAIN the name's own CNAME, which is not followed, is looked up as well.
|
|
59
|
+
const domainExists = async (domain, resolver) => {
|
|
60
|
+
if (!isValidName(domain)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
resolver = resolver || dns.resolve;
|
|
64
|
+
try {
|
|
65
|
+
await resolver(domain, 'A');
|
|
66
|
+
return true;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err.code === 'ENODATA') {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (err.code === 'EBADNAME') {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (err.code !== 'ENOTFOUND') {
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return ((await resolver(domain, 'CNAME')) || []).length > 0;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (NO_RECORD_CODES.has(err.code)) {
|
|
82
|
+
// no CNAME, so the NXDOMAIN for A was the name's own
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Tag names registered for DMARC (RFC 9989 9.3), including the historic ones
|
|
90
|
+
const KNOWN_TAGS = new Set(['v', 'p', 'sp', 'np', 'adkim', 'aspf', 'fo', 'rua', 'ruf', 'rf', 'ri', 'pct', 't', 'psd']);
|
|
91
|
+
|
|
92
|
+
// Parses a DMARC Policy Record. DMARC uses the DKIM tag-list syntax (RFC 9989 4.7), where tag names
|
|
93
|
+
// are case sensitive and a duplicated tag makes the whole tag-list invalid (RFC 6376 3.2).
|
|
94
|
+
// By default tag names are case-folded and the last of duplicated tags wins, which is reported in
|
|
95
|
+
// "warnings". With "strict" the RFC rules apply and a record with a duplicated tag is invalid (null).
|
|
96
|
+
const parseDmarcRecord = (txt, opts) => {
|
|
97
|
+
const { strict, warnings } = opts || {};
|
|
98
|
+
|
|
99
|
+
let seen = new Set();
|
|
100
|
+
let duplicate = false;
|
|
101
|
+
|
|
102
|
+
let entries = txt
|
|
103
|
+
.split(';')
|
|
104
|
+
.map(e => e.trim())
|
|
105
|
+
.filter(e => e)
|
|
106
|
+
.map((e, i) => {
|
|
107
|
+
let splitPos = e.indexOf('=');
|
|
108
|
+
if (splitPos < 0) {
|
|
109
|
+
// not a tag, a syntax error that is ignored
|
|
110
|
+
return [e.toLowerCase().trim(), false];
|
|
111
|
+
} else if (splitPos === 0) {
|
|
112
|
+
return [false, e];
|
|
113
|
+
}
|
|
114
|
+
// whitespace is allowed around "=" (*WSP in the ABNF), so the value must be trimmed
|
|
115
|
+
let name = e.substr(0, splitPos).trim();
|
|
116
|
+
let key = name.toLowerCase();
|
|
117
|
+
if (strict && i > 0) {
|
|
118
|
+
// the version tag is matched case-insensitively by its own ABNF rule, other names are not
|
|
119
|
+
key = name;
|
|
120
|
+
} else if (key !== name && KNOWN_TAGS.has(key) && i > 0) {
|
|
121
|
+
warnings?.add('tag-case');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (seen.has(key)) {
|
|
125
|
+
duplicate = true;
|
|
126
|
+
}
|
|
127
|
+
seen.add(key);
|
|
128
|
+
|
|
129
|
+
let val = e.substr(splitPos + 1).trim();
|
|
130
|
+
if (['pct', 'ri'].includes(key)) {
|
|
131
|
+
val = parseInt(val, 10) || 0;
|
|
132
|
+
} else if (CASE_INSENSITIVE_TAGS.has(key)) {
|
|
133
|
+
val = val.toLowerCase();
|
|
134
|
+
}
|
|
135
|
+
return [key, val];
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (duplicate) {
|
|
139
|
+
if (strict) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
warnings?.add('duplicate-tag');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let parsed = Object.fromEntries(entries);
|
|
59
146
|
|
|
60
147
|
parsed.rr = txt;
|
|
61
148
|
|
|
@@ -64,15 +151,16 @@ const parseDmarcRecord = txt => {
|
|
|
64
151
|
|
|
65
152
|
// Returns the DMARC Policy Record published at "_dmarc.<name>", or false. A set of several
|
|
66
153
|
// records counts as no record, it is discarded (RFC 9989 4.10 steps 2 and 6).
|
|
67
|
-
const fetchRecord = async (name, resolver) => {
|
|
68
|
-
if (!
|
|
154
|
+
const fetchRecord = async (name, resolver, opts) => {
|
|
155
|
+
if (!isValidName(`_dmarc.${name}`)) {
|
|
69
156
|
return false;
|
|
70
157
|
}
|
|
71
158
|
|
|
72
159
|
let records;
|
|
73
160
|
try {
|
|
74
161
|
let txt = await resolver(`_dmarc.${name}`, 'TXT');
|
|
75
|
-
|
|
162
|
+
// a custom resolver might return plain strings instead of lists of character-strings
|
|
163
|
+
records = (txt || []).map(row => (Array.isArray(row) ? row.join('') : String(row)).trim()).filter(row => DMARC_VERSION_RE.test(row));
|
|
76
164
|
} catch (err) {
|
|
77
165
|
if (NO_RECORD_CODES.has(err.code)) {
|
|
78
166
|
return false;
|
|
@@ -80,16 +168,19 @@ const fetchRecord = async (name, resolver) => {
|
|
|
80
168
|
throw err;
|
|
81
169
|
}
|
|
82
170
|
|
|
83
|
-
|
|
171
|
+
// a record that is not a valid tag-list is discarded like one that is not a DMARC record
|
|
172
|
+
return (records.length === 1 && parseDmarcRecord(records[0], opts)) || false;
|
|
84
173
|
};
|
|
85
174
|
|
|
86
|
-
// Tree Walks for one message share most of their names, so each name is queried only once
|
|
87
|
-
|
|
175
|
+
// Tree Walks for one message share most of their names, so each name is queried only once.
|
|
176
|
+
// opts.strict parses records by the RFC 6376 tag-list rules, opts.warnings is a Set that collects
|
|
177
|
+
// what the default parsing accepted and strict parsing would not.
|
|
178
|
+
const createRecordLookup = (resolver, opts) => {
|
|
88
179
|
resolver = resolver || dns.resolve;
|
|
89
180
|
let cache = new Map();
|
|
90
181
|
return name => {
|
|
91
182
|
if (!cache.has(name)) {
|
|
92
|
-
cache.set(name, fetchRecord(name, resolver));
|
|
183
|
+
cache.set(name, fetchRecord(name, resolver, opts));
|
|
93
184
|
}
|
|
94
185
|
return cache.get(name);
|
|
95
186
|
};
|
|
@@ -154,20 +245,33 @@ const getOrgDomain = async (domain, lookup) => {
|
|
|
154
245
|
return selectOrgDomain(domain, found);
|
|
155
246
|
};
|
|
156
247
|
|
|
248
|
+
// The record that holds the policy of an Organizational Domain: its own record, or else the record
|
|
249
|
+
// of the PSD above it. Returns false when there is neither.
|
|
250
|
+
const findOrgRecord = (orgDomain, found) => {
|
|
251
|
+
let last = found[found.length - 1];
|
|
252
|
+
let entry = found.find(e => e.domain === orgDomain) || (last?.record.psd === 'y' ? last : null);
|
|
253
|
+
return entry || false;
|
|
254
|
+
};
|
|
255
|
+
|
|
157
256
|
// DMARC Policy Discovery (RFC 9989 4.10.1) for a normalized author domain. Returns
|
|
158
|
-
// { record, recordDomain, orgDomain, error }, where "record" is false when no policy
|
|
159
|
-
//
|
|
160
|
-
//
|
|
257
|
+
// { record, recordDomain, orgDomain, orgRecord, error }, where "record" is false when no policy
|
|
258
|
+
// applies and "orgRecord" is the record that holds the Organizational Domain's policy (see findOrgRecord).
|
|
259
|
+
// When the Tree Walk fails after a record was found at the author domain, "orgDomain" and
|
|
260
|
+
// "orgRecord" are null and "error" says why. Throws on DNS errors that leave the policy unknown.
|
|
161
261
|
const discoverPolicy = async (domain, lookup) => {
|
|
162
262
|
if (isAddress(domain)) {
|
|
163
|
-
return { record: false, recordDomain: null, orgDomain: domain };
|
|
263
|
+
return { record: false, recordDomain: null, orgDomain: domain, orgRecord: false };
|
|
164
264
|
}
|
|
165
265
|
|
|
166
266
|
let { found, error } = await treeWalk(domain, lookup);
|
|
167
267
|
|
|
168
268
|
if (found[0]?.domain === domain) {
|
|
169
269
|
// the author domain's own record applies regardless of what is above it
|
|
170
|
-
|
|
270
|
+
if (error) {
|
|
271
|
+
return { record: found[0].record, recordDomain: domain, orgDomain: null, orgRecord: null, error };
|
|
272
|
+
}
|
|
273
|
+
let orgDomain = selectOrgDomain(domain, found);
|
|
274
|
+
return { record: found[0].record, recordDomain: domain, orgDomain, orgRecord: findOrgRecord(orgDomain, found).record || false };
|
|
171
275
|
}
|
|
172
276
|
|
|
173
277
|
if (error) {
|
|
@@ -178,17 +282,17 @@ const discoverPolicy = async (domain, lookup) => {
|
|
|
178
282
|
|
|
179
283
|
// otherwise the record of the Organizational Domain, and failing that the record of the PSD.
|
|
180
284
|
// A walk only stops at a psd tag, so a psd=y record can only be the last one found.
|
|
181
|
-
let
|
|
182
|
-
let entry = found.find(e => e.domain === orgDomain) || (last?.record.psd === 'y' ? last : null);
|
|
285
|
+
let entry = findOrgRecord(orgDomain, found);
|
|
183
286
|
|
|
184
|
-
return { record: entry ? entry.record : false, recordDomain: entry ? entry.domain : null, orgDomain };
|
|
287
|
+
return { record: entry ? entry.record : false, recordDomain: entry ? entry.domain : null, orgDomain, orgRecord: entry ? entry.record : false };
|
|
185
288
|
};
|
|
186
289
|
|
|
187
290
|
// Fetches the DMARC Policy Record that applies to a domain. Returns the parsed record or false.
|
|
188
|
-
|
|
291
|
+
// opts.strict parses records by the RFC 6376 tag-list rules (see parseDmarcRecord).
|
|
292
|
+
const getDmarcRecord = async (domain, resolver, opts) => {
|
|
189
293
|
domain = formatDomain(domain || '');
|
|
190
294
|
|
|
191
|
-
let { record, recordDomain, orgDomain } = await discoverPolicy(domain, createRecordLookup(resolver));
|
|
295
|
+
let { record, recordDomain, orgDomain } = await discoverPolicy(domain, createRecordLookup(resolver, { strict: !!opts?.strict }));
|
|
192
296
|
if (!record) {
|
|
193
297
|
return false;
|
|
194
298
|
}
|
|
@@ -205,3 +309,6 @@ module.exports = getDmarcRecord;
|
|
|
205
309
|
module.exports.createRecordLookup = createRecordLookup;
|
|
206
310
|
module.exports.discoverPolicy = discoverPolicy;
|
|
207
311
|
module.exports.getOrgDomain = getOrgDomain;
|
|
312
|
+
module.exports.getPolicyTags = getPolicyTags;
|
|
313
|
+
module.exports.domainExists = domainExists;
|
|
314
|
+
module.exports.parseDmarcRecord = parseDmarcRecord;
|
package/lib/dmarc/index.d.ts
CHANGED
|
@@ -6,6 +6,33 @@ import { DMARCOptions, DMARCResult } from '../../index';
|
|
|
6
6
|
* Verifies DMARC policy for a message
|
|
7
7
|
*
|
|
8
8
|
* @param opts - DMARC verification options
|
|
9
|
-
* @returns DMARC verification result
|
|
9
|
+
* @returns DMARC verification result, or false when the From header has no domain or more than one domain
|
|
10
10
|
*/
|
|
11
|
-
export function dmarc(opts: DMARCOptions): Promise<DMARCResult | false>;
|
|
11
|
+
export function dmarc(opts: DMARCOptions & { strict?: boolean }): Promise<DMARCResult | false>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Verifies DMARC policy for a message, with details that dmarc() does not return
|
|
15
|
+
*
|
|
16
|
+
* @param opts - DMARC verification options
|
|
17
|
+
*/
|
|
18
|
+
export function evaluateDmarc(opts: DMARCOptions & { strict?: boolean }): Promise<{
|
|
19
|
+
/**
|
|
20
|
+
* The dmarc() result
|
|
21
|
+
*/
|
|
22
|
+
response: DMARCResult | false;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Signing domains (normalized) of the DKIM signatures that aligned
|
|
26
|
+
*/
|
|
27
|
+
dkimAligned?: Set<string>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Why DMARC validation was not possible (RFC 9989 5.3.1), set when response is false
|
|
31
|
+
*/
|
|
32
|
+
reason?: 'no-author-domain' | 'multiple-author-domains' | 'invalid-author-domain' | 'multiple-from-fields';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The Author Domains found, set when response is false
|
|
36
|
+
*/
|
|
37
|
+
authorDomains?: string[];
|
|
38
|
+
}>;
|
package/lib/dmarc/verify.js
CHANGED
|
@@ -1,46 +1,61 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const dns = require('node:dns').promises;
|
|
4
4
|
const { formatAuthHeaderRow, formatDomain } = require('../tools');
|
|
5
|
-
const { createRecordLookup, discoverPolicy, getOrgDomain } = require('./get-dmarc-record');
|
|
5
|
+
const { createRecordLookup, discoverPolicy, getOrgDomain, getPolicyTags, domainExists } = require('./get-dmarc-record');
|
|
6
|
+
const { getAuthorDomain } = require('./author-domain');
|
|
6
7
|
|
|
7
8
|
// Every Tree Walk is capped at eight queries, but the number of walks is not (RFC 9989 4.10.2),
|
|
8
|
-
// so a message can only make this many identifiers walk for relaxed alignment.
|
|
9
|
+
// so a message can only make this many identifiers walk for relaxed alignment. An identifier
|
|
10
|
+
// past the limit is not known to align or not, so it is treated like one whose walk failed.
|
|
9
11
|
const MAX_ALIGNMENT_WALKS = 10;
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (Array.isArray(headerFrom)) {
|
|
15
|
-
if (headerFrom.length === 1) {
|
|
16
|
-
headerFrom = headerFrom[0];
|
|
17
|
-
} else {
|
|
18
|
-
// invalid number of FROM addresses found
|
|
19
|
-
return { response: false };
|
|
20
|
-
}
|
|
21
|
-
}
|
|
13
|
+
// t=y asks for the policy one level below the published one (RFC 9989 4.7 "t")
|
|
14
|
+
const TEST_MODE_POLICY = { reject: 'quarantine', quarantine: 'none', none: 'none' };
|
|
22
15
|
|
|
23
|
-
|
|
24
|
-
let domain = atPos >= 0 ? headerFrom.substr(atPos + 1) : headerFrom;
|
|
16
|
+
const POLICY_STRENGTH = { none: 0, quarantine: 1, reject: 2 };
|
|
25
17
|
|
|
26
|
-
|
|
18
|
+
// Two domains can only have the same Organizational Domain if they have a common parent. With
|
|
19
|
+
// no common label at all no Tree Walk result could make them align.
|
|
20
|
+
const shareParent = (a, b) => a.split('.').pop() === b.split('.').pop();
|
|
27
21
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
const setHidden = (obj, key, value) => Object.defineProperty(obj, key, { value, enumerable: false, configurable: true, writable: true });
|
|
23
|
+
|
|
24
|
+
const evaluateDmarc = async opts => {
|
|
25
|
+
let { headerFrom, fromFields, spfDomains, dkimDomains, resolver, arcResult, strict } = opts;
|
|
26
|
+
|
|
27
|
+
strict = !!strict;
|
|
28
|
+
resolver = resolver || dns.resolve;
|
|
29
|
+
|
|
30
|
+
// RFC 9989 5.3.1: several mailboxes are fine if they share one domain, but with zero or more
|
|
31
|
+
// than one Author Domain DMARC validation is not possible. The response stays false, as it
|
|
32
|
+
// always was for these messages, and "reason" says why.
|
|
33
|
+
const author = getAuthorDomain(headerFrom, fromFields);
|
|
34
|
+
if (!author.authorDomain) {
|
|
35
|
+
return { response: false, reason: author.reason, authorDomains: author.domains };
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
//
|
|
35
|
-
|
|
38
|
+
// "domain" is reported in header.from. "authorDomain" is the name to query and compare,
|
|
39
|
+
// where "example.com." and "example.com" are the same domain.
|
|
40
|
+
const { domain, authorDomain } = author;
|
|
41
|
+
|
|
42
|
+
// what the default mode accepted that strict mode would not
|
|
43
|
+
const warnings = new Set();
|
|
36
44
|
|
|
37
45
|
let formatResponse = response => {
|
|
38
|
-
|
|
46
|
+
if (warnings.size) {
|
|
47
|
+
response.warnings = Array.from(warnings);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
response.info = formatAuthHeaderRow('dmarc', response.status, { strict });
|
|
39
51
|
|
|
40
52
|
if (typeof response.status.comment === 'boolean') {
|
|
41
53
|
delete response.status.comment;
|
|
42
54
|
}
|
|
43
55
|
|
|
56
|
+
// for BIMI (draft-brand-indicators-for-message-identification 7.1), not part of the result
|
|
57
|
+
setHidden(response, 'authorMailboxes', author.mailboxes);
|
|
58
|
+
|
|
44
59
|
return response;
|
|
45
60
|
};
|
|
46
61
|
|
|
@@ -55,7 +70,8 @@ const evaluateDmarc = async opts => {
|
|
|
55
70
|
}
|
|
56
71
|
};
|
|
57
72
|
|
|
58
|
-
|
|
73
|
+
// the strict parsing rejects what the default parsing would note, so it adds no warnings
|
|
74
|
+
const lookup = createRecordLookup(resolver, { strict, warnings });
|
|
59
75
|
|
|
60
76
|
let policyDiscovery;
|
|
61
77
|
try {
|
|
@@ -66,7 +82,7 @@ const evaluateDmarc = async opts => {
|
|
|
66
82
|
return { response: formatResponse({ status, domain: authorDomain, error: err.message }) };
|
|
67
83
|
}
|
|
68
84
|
|
|
69
|
-
let { record: dmarcRecord, recordDomain, orgDomain } = policyDiscovery;
|
|
85
|
+
let { record: dmarcRecord, recordDomain, orgDomain, orgRecord } = policyDiscovery;
|
|
70
86
|
|
|
71
87
|
if (!dmarcRecord) {
|
|
72
88
|
// nothing to do here
|
|
@@ -75,18 +91,57 @@ const evaluateDmarc = async opts => {
|
|
|
75
91
|
return { response: formatResponse({ status, domain: orgDomain }) };
|
|
76
92
|
}
|
|
77
93
|
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
const policyTags = getPolicyTags(dmarcRecord);
|
|
95
|
+
if (!policyTags) {
|
|
96
|
+
// no valid policy and nowhere to report to, the record gets no DMARC processing (RFC 9989 4.10.1)
|
|
97
|
+
status.result = 'none';
|
|
98
|
+
return { response: formatResponse({ status, domain: orgDomain || authorDomain }) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!strict) {
|
|
102
|
+
// The domain the applied policy record was published at, the DMARC Policy Domain. This
|
|
103
|
+
// property is not registered for dmarc (RFC 9989 9.1), so strict mode leaves it out and
|
|
104
|
+
// the domain is only reported in "policyDomain".
|
|
105
|
+
status.header.d = recordDomain;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let policy = policyTags.p;
|
|
109
|
+
// set when the existence query that decides between "sp" and "np" failed
|
|
110
|
+
let existenceError = null;
|
|
111
|
+
if (recordDomain !== authorDomain) {
|
|
112
|
+
// A record inherited by a subdomain applies "np" if the subdomain does not exist and "sp"
|
|
113
|
+
// if it does, both falling back to "p" (RFC 9989 4.10.1). Existence only matters with "np".
|
|
114
|
+
policy = policyTags.sp || policyTags.p;
|
|
115
|
+
if (policyTags.np) {
|
|
116
|
+
try {
|
|
117
|
+
if (!(await domainExists(authorDomain, resolver))) {
|
|
118
|
+
policy = policyTags.np;
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (policyTags.np !== policy) {
|
|
122
|
+
// The policy is one of the two, but which one is not known. This only matters
|
|
123
|
+
// for a message that does not pass. Until then the weaker one is assumed, so
|
|
124
|
+
// that nothing (such as BIMI) treats the domain as more enforcing than it is.
|
|
125
|
+
existenceError = err;
|
|
126
|
+
if (POLICY_STRENGTH[policyTags.np] < POLICY_STRENGTH[policy]) {
|
|
127
|
+
policy = policyTags.np;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const testMode = dmarcRecord.t === 'y';
|
|
135
|
+
if (testMode) {
|
|
136
|
+
policy = TEST_MODE_POLICY[policy];
|
|
137
|
+
}
|
|
80
138
|
|
|
81
139
|
status.comment = []
|
|
82
|
-
.concat(
|
|
83
|
-
.concat(
|
|
140
|
+
.concat(`p=${policyTags.p.toUpperCase()}`)
|
|
141
|
+
.concat(policyTags.sp ? `sp=${policyTags.sp.toUpperCase()}` : [])
|
|
84
142
|
.concat(arcResult?.status?.result ? `arc=${arcResult?.status?.result}` : [])
|
|
85
143
|
.join(' ');
|
|
86
144
|
|
|
87
|
-
// use "sp" if the record was inherited by a subdomain and "sp" is set, otherwise use "p"
|
|
88
|
-
const policy = recordDomain !== authorDomain && dmarcRecord.sp ? dmarcRecord.sp : dmarcRecord.p;
|
|
89
|
-
|
|
90
145
|
const adkimStrict = dmarcRecord.adkim === 's';
|
|
91
146
|
const aspfStrict = dmarcRecord.aspf === 's';
|
|
92
147
|
|
|
@@ -94,13 +149,17 @@ const evaluateDmarc = async opts => {
|
|
|
94
149
|
// An identifier's Organizational Domain is always the identifier itself or one of its
|
|
95
150
|
// parents, so only identifiers at or below the author's Organizational Domain need a walk.
|
|
96
151
|
let orgDomains = new Map();
|
|
97
|
-
// throws when a Tree Walk the answer depends on failed
|
|
152
|
+
// throws when a Tree Walk the answer depends on failed, or was not made because of the limit
|
|
98
153
|
const alignsRelaxed = async entryDomain => {
|
|
99
154
|
if (entryDomain === authorDomain) {
|
|
100
155
|
return true;
|
|
101
156
|
}
|
|
102
157
|
if (!orgDomain) {
|
|
103
|
-
//
|
|
158
|
+
// The author's own Tree Walk failed, so there is nothing to compare against,
|
|
159
|
+
// unless the two domains have no parent in common and can never align
|
|
160
|
+
if (!shareParent(entryDomain, authorDomain)) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
104
163
|
throw policyDiscovery.error;
|
|
105
164
|
}
|
|
106
165
|
if (entryDomain !== orgDomain && !entryDomain.endsWith(`.${orgDomain}`)) {
|
|
@@ -108,7 +167,9 @@ const evaluateDmarc = async opts => {
|
|
|
108
167
|
}
|
|
109
168
|
if (!orgDomains.has(entryDomain)) {
|
|
110
169
|
if (orgDomains.size >= MAX_ALIGNMENT_WALKS) {
|
|
111
|
-
|
|
170
|
+
let err = new Error(`Alignment of ${entryDomain} not checked, limit of ${MAX_ALIGNMENT_WALKS} Tree Walks per message reached`);
|
|
171
|
+
err.code = 'EWALKLIMIT';
|
|
172
|
+
throw err;
|
|
112
173
|
}
|
|
113
174
|
orgDomains.set(entryDomain, getOrgDomain(entryDomain, lookup));
|
|
114
175
|
}
|
|
@@ -118,7 +179,7 @@ const evaluateDmarc = async opts => {
|
|
|
118
179
|
// Returns every entry that aligns, most preferred first, and the first walk error seen.
|
|
119
180
|
// Signatures with an l= tag come last, so that a signature covering the whole body is
|
|
120
181
|
// reported when several align.
|
|
121
|
-
const findAligned = async (entries,
|
|
182
|
+
const findAligned = async (entries, strictAlignment) => {
|
|
122
183
|
let list = []
|
|
123
184
|
.concat(entries || [])
|
|
124
185
|
.map(entry => (typeof entry === 'string' ? { domain: entry } : entry))
|
|
@@ -134,7 +195,7 @@ const evaluateDmarc = async opts => {
|
|
|
134
195
|
continue;
|
|
135
196
|
}
|
|
136
197
|
try {
|
|
137
|
-
if (
|
|
198
|
+
if (strictAlignment ? entryDomain === authorDomain : await alignsRelaxed(entryDomain)) {
|
|
138
199
|
aligned.push(entry);
|
|
139
200
|
}
|
|
140
201
|
} catch (err) {
|
|
@@ -162,10 +223,14 @@ const evaluateDmarc = async opts => {
|
|
|
162
223
|
let response = {
|
|
163
224
|
status,
|
|
164
225
|
domain: orgDomain || authorDomain,
|
|
226
|
+
// the DMARC Policy Domain (RFC 9989 3.2.5), where the applied record was published.
|
|
227
|
+
// This is the domain RFC 9990 aggregate reports list as the published policy's domain.
|
|
228
|
+
policyDomain: recordDomain,
|
|
165
229
|
policy,
|
|
166
|
-
p:
|
|
167
|
-
sp:
|
|
168
|
-
|
|
230
|
+
p: policyTags.p,
|
|
231
|
+
sp: policyTags.sp || policyTags.p,
|
|
232
|
+
np: policyTags.np,
|
|
233
|
+
testMode,
|
|
169
234
|
rr: dmarcRecord.rr,
|
|
170
235
|
|
|
171
236
|
alignment: {
|
|
@@ -177,15 +242,26 @@ const evaluateDmarc = async opts => {
|
|
|
177
242
|
if (dkimAlignment || spfAlignment) {
|
|
178
243
|
// pass
|
|
179
244
|
status.result = 'pass';
|
|
180
|
-
} else if (walkError) {
|
|
181
|
-
// an identifier might have aligned if its Tree Walk had not failed
|
|
245
|
+
} else if (walkError || existenceError) {
|
|
246
|
+
// an identifier might have aligned if its Tree Walk had not failed, or the
|
|
247
|
+
// policy to apply is not known because the author domain's existence is not
|
|
182
248
|
status.result = 'temperror';
|
|
183
|
-
response.error = walkError.message;
|
|
249
|
+
response.error = (walkError || existenceError).message;
|
|
184
250
|
} else {
|
|
185
251
|
// fail
|
|
186
252
|
status.result = 'fail';
|
|
187
253
|
}
|
|
188
254
|
|
|
255
|
+
if (status.result !== 'temperror' && !existenceError) {
|
|
256
|
+
// RFC 9989 9.1: the evaluated policy after the policy options have been processed
|
|
257
|
+
status.policy = { dmarc: policy };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// for BIMI (draft-brand-indicators-for-message-identification 7.1), not part of the result:
|
|
261
|
+
// the applied record and the record holding the Organizational Domain's policy (null if unknown)
|
|
262
|
+
setHidden(response, 'record', dmarcRecord);
|
|
263
|
+
setHidden(response, 'orgRecord', orgRecord);
|
|
264
|
+
|
|
189
265
|
return { response: formatResponse(response), dkimAligned: new Set(dkim.aligned.map(entry => formatDomain(entry.domain))) };
|
|
190
266
|
};
|
|
191
267
|
|