mailauth 7.1.1 → 7.3.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/.c8rc.json +11 -0
- package/.prettierignore +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +68 -0
- package/README.md +131 -31
- package/bin/mailauth.js +62 -0
- package/cli.md +150 -8
- package/docs/arc.md +62 -54
- package/docs/bimi.md +153 -54
- package/docs/dkim.md +37 -36
- package/docs/dkim2.md +154 -0
- package/docs/dmarc.md +4 -1
- package/docs/mta-sts.md +12 -9
- package/docs/spf.md +14 -13
- package/index.d.ts +583 -42
- package/lib/arc/index.d.ts +6 -2
- package/lib/arc/index.js +45 -15
- package/lib/bimi/index.js +362 -102
- package/lib/bimi/validate-svg.js +493 -60
- package/lib/commands/dkim2-hash.js +25 -0
- package/lib/commands/dkim2-sign.js +77 -0
- package/lib/commands/dkim2-verify.js +59 -0
- package/lib/commands/report.js +10 -35
- package/lib/commands/resolver.js +51 -0
- package/lib/commands/seal.js +10 -38
- package/lib/commands/spf.js +4 -35
- package/lib/dkim/dkim-signer.js +50 -30
- package/lib/dkim/dkim-verifier.js +79 -27
- package/lib/dkim/header/simple.js +2 -2
- package/lib/dkim/sign.js +7 -2
- package/lib/dkim/syntax.js +11 -0
- package/lib/dkim/verify.js +1 -0
- package/lib/dkim2/dkim2-parser.js +52 -0
- package/lib/dkim2/dkim2-signer.js +334 -0
- package/lib/dkim2/dkim2-verifier.js +572 -0
- package/lib/dkim2/fields.js +570 -0
- package/lib/dkim2/hash.d.ts +3 -0
- package/lib/dkim2/hash.js +51 -0
- package/lib/dkim2/key.js +203 -0
- package/lib/dkim2/message-state.js +181 -0
- package/lib/dkim2/recipe.js +134 -0
- package/lib/dkim2/sign.d.ts +3 -0
- package/lib/dkim2/sign.js +38 -0
- package/lib/dkim2/verify.d.ts +3 -0
- package/lib/dkim2/verify.js +19 -0
- package/lib/dmarc/author-domain.js +270 -5
- package/lib/dmarc/get-dmarc-record.js +24 -11
- package/lib/dmarc/verify.js +7 -3
- package/lib/mailauth.js +57 -14
- package/lib/mta-sts.js +37 -11
- package/lib/parse-received.js +214 -25
- package/lib/spf/index.js +21 -4
- package/lib/spf/macro.js +5 -3
- package/lib/spf/spf-verify.js +14 -5
- package/lib/spf/syntax.js +5 -0
- package/lib/tools.js +160 -21
- package/package.json +15 -6
- package/stryker.config.json +16 -0
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
const { formatDomain, toALabel } = require('../tools');
|
|
4
4
|
|
|
5
5
|
// Characters that can not appear in a domain name taken from an address. Anything like this
|
|
6
|
-
// means the address was malformed and the text after the "@" is not a domain.
|
|
7
|
-
|
|
6
|
+
// means the address was malformed and the text after the "@" is not a domain. Control characters
|
|
7
|
+
// (Cc, including DEL) and invisible format characters (Cf, such as U+200B or the soft hyphen) are
|
|
8
|
+
// not valid in a U-label (RFC 5890 2.3.2.1, RFC 5892), so such a domain has no A-label. U+200C and
|
|
9
|
+
// U+200D are left out, IDNA2008 allows them in some scripts (CONTEXTJ).
|
|
10
|
+
const INVALID_DOMAIN_CHARS = /[\s"<>(),;:\\@[\]\p{Cc}]|[^\P{Cf}\u200c\u200d]/u;
|
|
8
11
|
|
|
9
12
|
// A domain literal (RFC 5322 3.4.1). It is kept as is, it can not publish a DMARC record.
|
|
10
13
|
const DOMAIN_LITERAL = /^\[[^[\]\\]*\]$/;
|
|
@@ -137,17 +140,31 @@ const getAddressDomains = address => {
|
|
|
137
140
|
// "fromFields" is the number of From header fields the addresses came from, if known. RFC 5322
|
|
138
141
|
// allows only one, and with several the DKIM signature can cover a different field than the one
|
|
139
142
|
// a mail client shows, so their addresses are not merged into one Author Domain.
|
|
140
|
-
|
|
143
|
+
// "opts.fromSyntax" is the syntax of the From header field (see parseFromHeader). An "invalid"
|
|
144
|
+
// field, or a "lax" one with "opts.strict", gives no Author Domain, as the addresses may not be
|
|
145
|
+
// the ones a reader sees. For backward compatibility "opts" can also be a boolean that is true
|
|
146
|
+
// for a From header field that could not be parsed.
|
|
147
|
+
const getAuthorDomain = (headerFrom, fromFields, opts) => {
|
|
148
|
+
let fromInvalid = opts;
|
|
149
|
+
if (opts && typeof opts === 'object') {
|
|
150
|
+
fromInvalid = opts.fromSyntax === 'invalid' || (!!opts.strict && opts.fromSyntax === 'lax');
|
|
151
|
+
}
|
|
152
|
+
|
|
141
153
|
let addresses = [].concat(headerFrom === undefined || headerFrom === null ? [] : headerFrom);
|
|
142
154
|
|
|
143
155
|
let found = new Map();
|
|
144
156
|
let invalid = false;
|
|
157
|
+
// a mailbox has a domain part, but it is not a domain name
|
|
158
|
+
let malformed = !!fromInvalid;
|
|
145
159
|
for (let address of addresses) {
|
|
146
160
|
let entries = getAddressDomains(address);
|
|
147
161
|
if (!entries.length) {
|
|
148
162
|
// a mailbox without a usable domain is not skipped, the domain a reader sees for it
|
|
149
163
|
// is unknown, so the Author Domain can not be determined
|
|
150
164
|
invalid = true;
|
|
165
|
+
if (typeof address === 'string' && address.indexOf('@') >= 0 && address.slice(address.lastIndexOf('@') + 1).replace(/^\s*\.?\s*$/, '')) {
|
|
166
|
+
malformed = true;
|
|
167
|
+
}
|
|
151
168
|
}
|
|
152
169
|
for (let entry of entries) {
|
|
153
170
|
if (!found.has(entry.authorDomain)) {
|
|
@@ -162,7 +179,7 @@ const getAuthorDomain = (headerFrom, fromFields) => {
|
|
|
162
179
|
return { reason: 'multiple-from-fields', domains: Array.from(found.keys()), mailboxes };
|
|
163
180
|
}
|
|
164
181
|
|
|
165
|
-
if (invalid && found.size) {
|
|
182
|
+
if (malformed || (invalid && found.size)) {
|
|
166
183
|
return { reason: 'invalid-author-domain', domains: Array.from(found.keys()), mailboxes };
|
|
167
184
|
}
|
|
168
185
|
|
|
@@ -178,4 +195,252 @@ const getAuthorDomain = (headerFrom, fromFields) => {
|
|
|
178
195
|
};
|
|
179
196
|
};
|
|
180
197
|
|
|
181
|
-
|
|
198
|
+
// RFC 5322 3.2.3 atext, with the UTF-8 of RFC 6532 3.2
|
|
199
|
+
const ATEXT = /[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~\u0080-]/;
|
|
200
|
+
|
|
201
|
+
// ASCII control characters other than whitespace. RFC 5322 4.1 only allows them (obs-NO-WS-CTL)
|
|
202
|
+
// in comments, quoted strings and domain literals
|
|
203
|
+
const ASCII_CTL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
204
|
+
|
|
205
|
+
class FromSyntaxError extends Error {}
|
|
206
|
+
|
|
207
|
+
// Splits a From header field body into atoms, quoted strings, domain literals and single special
|
|
208
|
+
// characters. Whitespace and comments (CFWS) are dropped: where RFC 5322 allows CFWS, including
|
|
209
|
+
// between the atoms of an obs-domain (4.4), it has no meaning, and where it is not allowed, the
|
|
210
|
+
// tokens around it are not valid next to each other anyway.
|
|
211
|
+
const tokenizeFrom = value => {
|
|
212
|
+
let tokens = [];
|
|
213
|
+
let i = 0;
|
|
214
|
+
|
|
215
|
+
// reads a quoted string, a comment or a domain literal that starts at "i"
|
|
216
|
+
let readEnclosed = (close, nests) => {
|
|
217
|
+
let start = i;
|
|
218
|
+
let depth = 0;
|
|
219
|
+
for (; i < value.length; i++) {
|
|
220
|
+
let c = value.charAt(i);
|
|
221
|
+
if (c === '\\' && close !== ']') {
|
|
222
|
+
i++;
|
|
223
|
+
} else if (nests && c === '(') {
|
|
224
|
+
depth++;
|
|
225
|
+
} else if (c === close && (!nests || !--depth)) {
|
|
226
|
+
i++;
|
|
227
|
+
return value.slice(start, i);
|
|
228
|
+
} else if (close === ']' && i > start && (c === '[' || c === '\\')) {
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
throw new FromSyntaxError('unterminated');
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
while (i < value.length) {
|
|
236
|
+
let c = value.charAt(i);
|
|
237
|
+
if (c === ' ' || c === '\t' || c === '\r' || c === '\n') {
|
|
238
|
+
i++;
|
|
239
|
+
} else if (c === '(') {
|
|
240
|
+
readEnclosed(')', true);
|
|
241
|
+
} else if (c === '"') {
|
|
242
|
+
i++;
|
|
243
|
+
tokens.push({ type: 'quoted', value: '"' + readEnclosed('"') });
|
|
244
|
+
} else if (c === '[') {
|
|
245
|
+
tokens.push({ type: 'literal', value: readEnclosed(']') });
|
|
246
|
+
} else if (ATEXT.test(c)) {
|
|
247
|
+
let start = i;
|
|
248
|
+
while (i < value.length && ATEXT.test(value.charAt(i))) {
|
|
249
|
+
i++;
|
|
250
|
+
}
|
|
251
|
+
tokens.push({ type: 'atom', value: value.slice(start, i) });
|
|
252
|
+
} else if (ASCII_CTL.test(c)) {
|
|
253
|
+
throw new FromSyntaxError('control character');
|
|
254
|
+
} else {
|
|
255
|
+
// one of the specials ) < > ] : ; @ \ , .
|
|
256
|
+
tokens.push({ type: c });
|
|
257
|
+
i++;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return tokens;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Parses the body of a From header field (RFC 5322 3.6.2 and 4.5.2, a mailbox-list or, by
|
|
265
|
+
// RFC 6854, an address-list) and returns the addr-spec of every mailbox, with CFWS removed and
|
|
266
|
+
// without a source route. "syntax" is "valid", "lax" for a field that only the lenient parsing
|
|
267
|
+
// accepts, or "invalid". An invalid field gives no reliable Author Domain (RFC 9989 4.4), so a
|
|
268
|
+
// domain that is not the one a reader sees can not be evaluated in its place.
|
|
269
|
+
//
|
|
270
|
+
// The lenient parsing accepts:
|
|
271
|
+
// - a display name without an address, as in "Doe, John <john@example.com>"
|
|
272
|
+
// - a group that is not closed with ";" at the end of the field
|
|
273
|
+
// - a domain literal or a leading "." in a display name
|
|
274
|
+
// - a domain with a trailing dot
|
|
275
|
+
// - an addr-spec in place of a display name, as in "a@example.com <a@example.com>". Its address
|
|
276
|
+
// is returned too when it differs from the angle-addr, so that a reader who sees it as the
|
|
277
|
+
// author does not get the other domain evaluated
|
|
278
|
+
// Anything else that is not RFC 5322 syntax, such as text after an angle-addr or two addresses
|
|
279
|
+
// without a comma between them, makes the field invalid.
|
|
280
|
+
const parseFromHeader = value => {
|
|
281
|
+
let addresses = [];
|
|
282
|
+
let lax = false;
|
|
283
|
+
let tokens;
|
|
284
|
+
let pos = 0;
|
|
285
|
+
|
|
286
|
+
let peek = () => tokens[pos]?.type;
|
|
287
|
+
let expect = type => {
|
|
288
|
+
if (peek() !== type) {
|
|
289
|
+
throw new FromSyntaxError(`expected "${type}"`);
|
|
290
|
+
}
|
|
291
|
+
return tokens[pos++];
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// domain = dot-atom / domain-literal / obs-domain
|
|
295
|
+
let parseDomain = () => {
|
|
296
|
+
if (peek() === 'literal') {
|
|
297
|
+
return tokens[pos++].value;
|
|
298
|
+
}
|
|
299
|
+
let domain = expect('atom').value;
|
|
300
|
+
while (peek() === '.') {
|
|
301
|
+
pos++;
|
|
302
|
+
if (peek() !== 'atom') {
|
|
303
|
+
// a trailing dot, as in "user@example.com."
|
|
304
|
+
lax = true;
|
|
305
|
+
return domain + '.';
|
|
306
|
+
}
|
|
307
|
+
domain += '.' + tokens[pos++].value;
|
|
308
|
+
}
|
|
309
|
+
return domain;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// local-part = dot-atom / quoted-string / obs-local-part (word *("." word))
|
|
313
|
+
let toLocalPart = words => {
|
|
314
|
+
if (!words.length || words.length % 2 === 0) {
|
|
315
|
+
throw new FromSyntaxError('invalid local-part');
|
|
316
|
+
}
|
|
317
|
+
for (let i = 0; i < words.length; i++) {
|
|
318
|
+
// words and dots alternate, anything else between two words (such as a domain
|
|
319
|
+
// literal) is not a local-part
|
|
320
|
+
let valid = i % 2 === 0 ? words[i].type === 'atom' || words[i].type === 'quoted' : words[i].type === '.';
|
|
321
|
+
if (!valid) {
|
|
322
|
+
throw new FromSyntaxError('invalid local-part');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return words.map(word => (word.type === '.' ? '.' : word.value)).join('');
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
let parseAddrSpec = () => {
|
|
329
|
+
let words = [];
|
|
330
|
+
while (['atom', 'quoted', '.'].includes(peek())) {
|
|
331
|
+
words.push(tokens[pos++]);
|
|
332
|
+
}
|
|
333
|
+
let localPart = toLocalPart(words);
|
|
334
|
+
expect('@');
|
|
335
|
+
return `${localPart}@${parseDomain()}`;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// angle-addr, or obs-angle-addr with a source route that is dropped
|
|
339
|
+
let parseAngleAddr = () => {
|
|
340
|
+
expect('<');
|
|
341
|
+
if (peek() === '@') {
|
|
342
|
+
// obs-route = obs-domain-list ":"
|
|
343
|
+
while (peek() === '@' || peek() === ',') {
|
|
344
|
+
if (tokens[pos++].type === '@') {
|
|
345
|
+
parseDomain();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
expect(':');
|
|
349
|
+
}
|
|
350
|
+
let address = parseAddrSpec();
|
|
351
|
+
expect('>');
|
|
352
|
+
return address;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
let parseMailbox = inGroup => {
|
|
356
|
+
// display-name (phrase or obs-phrase), or the local-part of an addr-spec
|
|
357
|
+
let words = [];
|
|
358
|
+
while (['atom', 'quoted', '.', 'literal'].includes(peek())) {
|
|
359
|
+
words.push(tokens[pos++]);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let next = peek();
|
|
363
|
+
|
|
364
|
+
if (next === '@') {
|
|
365
|
+
let localPart = toLocalPart(words);
|
|
366
|
+
pos++;
|
|
367
|
+
let address = `${localPart}@${parseDomain()}`;
|
|
368
|
+
if (peek() !== '<') {
|
|
369
|
+
addresses.push(address);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// an addr-spec used as a display name
|
|
373
|
+
lax = true;
|
|
374
|
+
let angleAddress = parseAngleAddr();
|
|
375
|
+
if (address.toLowerCase() !== angleAddress.toLowerCase()) {
|
|
376
|
+
addresses.push(address);
|
|
377
|
+
}
|
|
378
|
+
addresses.push(angleAddress);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (words.some(word => word.type === 'literal') || words[0]?.type === '.') {
|
|
383
|
+
lax = true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (next === '<') {
|
|
387
|
+
addresses.push(parseAngleAddr());
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (next === ':' && !inGroup && words.length) {
|
|
392
|
+
// group = display-name ":" [group-list] ";" [CFWS]
|
|
393
|
+
pos++;
|
|
394
|
+
for (;;) {
|
|
395
|
+
while (peek() === ',') {
|
|
396
|
+
pos++;
|
|
397
|
+
}
|
|
398
|
+
if (peek() === ';') {
|
|
399
|
+
pos++;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (!peek()) {
|
|
403
|
+
lax = true;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
parseMailbox(true);
|
|
407
|
+
if (peek() !== ',' && peek() !== ';' && peek()) {
|
|
408
|
+
throw new FromSyntaxError('unexpected text in group');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!words.length) {
|
|
414
|
+
throw new FromSyntaxError('unexpected character');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// a display name without an address
|
|
418
|
+
lax = true;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
tokens = tokenizeFrom((value || '').toString());
|
|
423
|
+
for (;;) {
|
|
424
|
+
// obs-mbox-list allows empty list elements
|
|
425
|
+
while (peek() === ',') {
|
|
426
|
+
pos++;
|
|
427
|
+
}
|
|
428
|
+
if (!peek()) {
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
parseMailbox(false);
|
|
432
|
+
if (peek() && peek() !== ',') {
|
|
433
|
+
throw new FromSyntaxError('unexpected text after address');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} catch (err) {
|
|
437
|
+
if (!(err instanceof FromSyntaxError)) {
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
return { addresses, syntax: 'invalid' };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return { addresses, syntax: lax ? 'lax' : 'valid' };
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
module.exports = { getAuthorDomain, getAddressDomains, parseFromHeader };
|
|
@@ -102,15 +102,12 @@ const parseDmarcRecord = (txt, opts) => {
|
|
|
102
102
|
let entries = txt
|
|
103
103
|
.split(';')
|
|
104
104
|
.map(e => e.trim())
|
|
105
|
-
|
|
105
|
+
// a fragment without "=" or without a tag name is not a tag, and syntax errors are discarded
|
|
106
|
+
// (RFC 9989 4.8), so it can not override a valid tag of the same name. The version tag
|
|
107
|
+
// always has a name and "=", the record matched DMARC_VERSION_RE.
|
|
108
|
+
.filter(e => e.indexOf('=') > 0)
|
|
106
109
|
.map((e, i) => {
|
|
107
110
|
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
111
|
// whitespace is allowed around "=" (*WSP in the ABNF), so the value must be trimmed
|
|
115
112
|
let name = e.substr(0, splitPos).trim();
|
|
116
113
|
let key = name.toLowerCase();
|
|
@@ -156,11 +153,23 @@ const fetchRecord = async (name, resolver, opts) => {
|
|
|
156
153
|
return false;
|
|
157
154
|
}
|
|
158
155
|
|
|
159
|
-
let records;
|
|
156
|
+
let records = [];
|
|
157
|
+
// set when a record only starts with the version tag after leading whitespace is removed
|
|
158
|
+
let trimmed = false;
|
|
160
159
|
try {
|
|
161
160
|
let txt = await resolver(`_dmarc.${name}`, 'TXT');
|
|
162
|
-
// a custom resolver might return plain strings instead of lists of character-strings
|
|
163
|
-
|
|
161
|
+
// a custom resolver might return plain strings instead of lists of character-strings.
|
|
162
|
+
// Trailing whitespace belongs to the last tag value, which is trimmed anyway.
|
|
163
|
+
for (let row of (txt || []).map(row => (Array.isArray(row) ? row.join('') : String(row)).trimEnd())) {
|
|
164
|
+
if (DMARC_VERSION_RE.test(row)) {
|
|
165
|
+
records.push(row);
|
|
166
|
+
} else if (!opts?.strict && DMARC_VERSION_RE.test(row.trimStart())) {
|
|
167
|
+
// a record must start with the "v" tag (RFC 9989 4.10), the default mode
|
|
168
|
+
// also accepts one with whitespace before it
|
|
169
|
+
records.push(row.trimStart());
|
|
170
|
+
trimmed = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
164
173
|
} catch (err) {
|
|
165
174
|
if (NO_RECORD_CODES.has(err.code)) {
|
|
166
175
|
return false;
|
|
@@ -169,7 +178,11 @@ const fetchRecord = async (name, resolver, opts) => {
|
|
|
169
178
|
}
|
|
170
179
|
|
|
171
180
|
// a record that is not a valid tag-list is discarded like one that is not a DMARC record
|
|
172
|
-
|
|
181
|
+
let record = (records.length === 1 && parseDmarcRecord(records[0], opts)) || false;
|
|
182
|
+
if (record && trimmed) {
|
|
183
|
+
opts?.warnings?.add('record-whitespace');
|
|
184
|
+
}
|
|
185
|
+
return record;
|
|
173
186
|
};
|
|
174
187
|
|
|
175
188
|
// Tree Walks for one message share most of their names, so each name is queried only once.
|
package/lib/dmarc/verify.js
CHANGED
|
@@ -22,7 +22,7 @@ const shareParent = (a, b) => a.split('.').pop() === b.split('.').pop();
|
|
|
22
22
|
const setHidden = (obj, key, value) => Object.defineProperty(obj, key, { value, enumerable: false, configurable: true, writable: true });
|
|
23
23
|
|
|
24
24
|
const evaluateDmarc = async opts => {
|
|
25
|
-
let { headerFrom, fromFields, spfDomains, dkimDomains, resolver, arcResult, strict } = opts;
|
|
25
|
+
let { headerFrom, fromFields, fromSyntax, spfDomains, dkimDomains, resolver, arcResult, strict } = opts;
|
|
26
26
|
|
|
27
27
|
strict = !!strict;
|
|
28
28
|
resolver = resolver || dns.resolve;
|
|
@@ -30,7 +30,8 @@ const evaluateDmarc = async opts => {
|
|
|
30
30
|
// RFC 9989 5.3.1: several mailboxes are fine if they share one domain, but with zero or more
|
|
31
31
|
// than one Author Domain DMARC validation is not possible. The response stays false, as it
|
|
32
32
|
// always was for these messages, and "reason" says why.
|
|
33
|
-
|
|
33
|
+
// a From header field that only the lenient parsing accepts is invalid in strict mode
|
|
34
|
+
const author = getAuthorDomain(headerFrom, fromFields, { fromSyntax, strict });
|
|
34
35
|
if (!author.authorDomain) {
|
|
35
36
|
return { response: false, reason: author.reason, authorDomains: author.domains };
|
|
36
37
|
}
|
|
@@ -41,6 +42,9 @@ const evaluateDmarc = async opts => {
|
|
|
41
42
|
|
|
42
43
|
// what the default mode accepted that strict mode would not
|
|
43
44
|
const warnings = new Set();
|
|
45
|
+
if (fromSyntax === 'lax') {
|
|
46
|
+
warnings.add('from-syntax');
|
|
47
|
+
}
|
|
44
48
|
|
|
45
49
|
let formatResponse = response => {
|
|
46
50
|
if (warnings.size) {
|
|
@@ -64,7 +68,7 @@ const evaluateDmarc = async opts => {
|
|
|
64
68
|
comment: false,
|
|
65
69
|
// ptype properties
|
|
66
70
|
header: {
|
|
67
|
-
// RFC
|
|
71
|
+
// RFC 9989 9.1: header.from is the domain of the RFC5322.From address,
|
|
68
72
|
// not the organizational domain the policy may have been inherited from
|
|
69
73
|
from: domain
|
|
70
74
|
}
|
package/lib/mailauth.js
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
const { dkimSign, DkimSignStream } = require('./dkim/sign');
|
|
4
4
|
const { dkimVerify } = require('./dkim/verify');
|
|
5
|
+
const { dkim2Sign, Dkim2SignStream } = require('./dkim2/sign');
|
|
6
|
+
const { dkim2Verify } = require('./dkim2/verify');
|
|
7
|
+
const { dkim2Hash } = require('./dkim2/hash');
|
|
5
8
|
const { spf } = require('./spf');
|
|
6
9
|
const { dmarc, evaluateDmarc } = require('./dmarc');
|
|
7
10
|
const { arc, createSeal, sealMessage, getARChain, verifyASChain } = require('./arc');
|
|
8
11
|
const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
|
|
9
12
|
const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
|
|
10
|
-
const { parseReceived, getClientAddress } = require('./parse-received');
|
|
13
|
+
const { parseReceived, getClientAddress, getClientHelo } = require('./parse-received');
|
|
11
14
|
const { formatDomain, getAlignment, formatAuthservId } = require('./tools');
|
|
15
|
+
const { buffer: readStream } = require('node:stream/consumers');
|
|
12
16
|
const libmime = require('libmime');
|
|
13
17
|
const os = require('node:os');
|
|
14
18
|
const { isIP } = require('net');
|
|
@@ -33,6 +37,9 @@ const { isIP } = require('net');
|
|
|
33
37
|
* @param {Boolean} [opts.disableDmarc=false] If true then do not perform DMARC check
|
|
34
38
|
* @param {Boolean} [opts.disableBimi=false] If true then do not perform BIMI check
|
|
35
39
|
* @param {Boolean} [opts.strict=false] If true then every check follows its RFC exactly instead of the lenient defaults
|
|
40
|
+
* @param {Boolean} [opts.dkim2=false] If true then DKIM2 header fields are verified as well (draft-ietf-dkim-dkim2-spec-06)
|
|
41
|
+
* @param {String|String[]} [opts.rcptTo] RCPT TO addresses of the delivery, checked against the DKIM2 chain of custody
|
|
42
|
+
* @param {Object} [opts.dkim2Options] Extra options for the DKIM2 verifier (maxSignatureAge, maxFutureTime, maxInstances, checkReplay, curTime)
|
|
36
43
|
* @returns Authentication result
|
|
37
44
|
*/
|
|
38
45
|
const authenticate = async (input, opts) => {
|
|
@@ -45,6 +52,11 @@ const authenticate = async (input, opts) => {
|
|
|
45
52
|
// quoted, or throws under strict, before any work is done
|
|
46
53
|
const authservId = formatAuthservId(opts.mta, opts.strict);
|
|
47
54
|
|
|
55
|
+
if (opts.dkim2 && typeof input?.on === 'function') {
|
|
56
|
+
// DKIM2 reads the message a second time, so a stream is read into memory first
|
|
57
|
+
input = await readStream(input);
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
const dkimResult = await dkimVerify(input, {
|
|
49
61
|
resolver: opts.resolver,
|
|
50
62
|
sender: opts.sender, // defaults to Return-Path header
|
|
@@ -60,17 +72,20 @@ const authenticate = async (input, opts) => {
|
|
|
60
72
|
if (opts.trustReceived) {
|
|
61
73
|
let rcvd = receivedChain?.find(row => row.from?.value);
|
|
62
74
|
if (rcvd) {
|
|
63
|
-
let helo = rcvd
|
|
75
|
+
let helo = getClientHelo(rcvd);
|
|
64
76
|
// the address of the connection from the TCP-info part of the from clause
|
|
65
77
|
// (RFC 5321 section 4.4), never an address the client claimed in its HELO
|
|
66
|
-
let ip = getClientAddress(rcvd
|
|
78
|
+
let ip = getClientAddress(rcvd);
|
|
79
|
+
|
|
80
|
+
// the HELO name of the header belongs to its connection, it is not combined with
|
|
81
|
+
// an address the caller provided
|
|
82
|
+
let callerIp = !!opts.ip;
|
|
67
83
|
|
|
68
|
-
if (ip && !
|
|
84
|
+
if (ip && !callerIp) {
|
|
69
85
|
opts.ip = ip;
|
|
70
86
|
}
|
|
71
87
|
|
|
72
|
-
if (helo && !opts.helo && !
|
|
73
|
-
// if IP was provided then do not use helo even if it is missing
|
|
88
|
+
if (helo && !opts.helo && !callerIp) {
|
|
74
89
|
opts.helo = helo;
|
|
75
90
|
}
|
|
76
91
|
|
|
@@ -94,7 +109,24 @@ const authenticate = async (input, opts) => {
|
|
|
94
109
|
opts.helo = `[${opts.helo}]`;
|
|
95
110
|
}
|
|
96
111
|
|
|
112
|
+
// DKIM2 runs alongside SPF. The delivery is compared with the highest DKIM2-Signature only
|
|
113
|
+
// when it is known
|
|
114
|
+
const dkim2Verification =
|
|
115
|
+
opts.dkim2 &&
|
|
116
|
+
dkim2Verify(
|
|
117
|
+
input,
|
|
118
|
+
Object.assign({}, opts.dkim2Options, {
|
|
119
|
+
resolver: opts.resolver,
|
|
120
|
+
mailFrom: typeof opts.sender === 'string' ? opts.sender : undefined,
|
|
121
|
+
rcptTo: opts.rcptTo
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
// a rejection is handled by the await below, this keeps it from being reported as unhandled
|
|
125
|
+
// while SPF runs
|
|
126
|
+
dkim2Verification?.catch?.(() => false);
|
|
127
|
+
|
|
97
128
|
const spfResult = await spf(opts);
|
|
129
|
+
const dkim2Result = await dkim2Verification;
|
|
98
130
|
|
|
99
131
|
let arcResult;
|
|
100
132
|
if (!opts.disableArc) {
|
|
@@ -115,6 +147,12 @@ const authenticate = async (input, opts) => {
|
|
|
115
147
|
arHeader.push(`${libmime.foldLines(row.info, 160)}`);
|
|
116
148
|
});
|
|
117
149
|
|
|
150
|
+
if (dkim2Result?.info) {
|
|
151
|
+
// draft-gondwana-dkim2-authres-00: the method is not registered yet. DKIM2 results do not
|
|
152
|
+
// take part in DMARC, that is an open question (draft-ietf-dkim-dkim2-bcp-01 section 7.2)
|
|
153
|
+
arHeader.push(libmime.foldLines(dkim2Result.info, 160));
|
|
154
|
+
}
|
|
155
|
+
|
|
118
156
|
if (spfResult) {
|
|
119
157
|
arHeader.push(libmime.foldLines(spfResult.info, 160));
|
|
120
158
|
receivedSpfHeader = spfResult.header;
|
|
@@ -135,6 +173,7 @@ const authenticate = async (input, opts) => {
|
|
|
135
173
|
} = await evaluateDmarc({
|
|
136
174
|
headerFrom: dkimResult.headerFrom,
|
|
137
175
|
fromFields: dkimResult.fromFields,
|
|
176
|
+
fromSyntax: dkimResult.fromSyntax,
|
|
138
177
|
spfDomains: [].concat((spfResult && spfResult.status.result === 'pass' && spfResult.domain) || []),
|
|
139
178
|
dkimDomains: (dkimResult.results || [])
|
|
140
179
|
.filter(r => r.status.result === 'pass')
|
|
@@ -210,14 +249,13 @@ const authenticate = async (input, opts) => {
|
|
|
210
249
|
let sealResult = await createSeal(false, {
|
|
211
250
|
headers: dkimResult.headers,
|
|
212
251
|
arc: dkimResult.arc,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
),
|
|
252
|
+
// the chain status, the AAR and the instance always come from this message, never
|
|
253
|
+
// from the caller's seal options (RFC 8617 section 5.1 step 4)
|
|
254
|
+
seal: Object.assign({ signTime: new Date() }, dkimResult.seal, {
|
|
255
|
+
cv: arcResult.status.result,
|
|
256
|
+
authResults: arcResult.authResults,
|
|
257
|
+
i: undefined
|
|
258
|
+
}),
|
|
221
259
|
strict: opts.strict
|
|
222
260
|
});
|
|
223
261
|
|
|
@@ -234,6 +272,7 @@ const authenticate = async (input, opts) => {
|
|
|
234
272
|
|
|
235
273
|
const result = {
|
|
236
274
|
dkim: dkimResult,
|
|
275
|
+
dkim2: dkim2Result || false,
|
|
237
276
|
spf: spfResult,
|
|
238
277
|
dmarc: dmarcResult || false,
|
|
239
278
|
arc: arcResult || false,
|
|
@@ -257,6 +296,10 @@ module.exports = {
|
|
|
257
296
|
dkimSign,
|
|
258
297
|
DkimSignStream,
|
|
259
298
|
dkimVerify,
|
|
299
|
+
dkim2Sign,
|
|
300
|
+
Dkim2SignStream,
|
|
301
|
+
dkim2Verify,
|
|
302
|
+
dkim2Hash,
|
|
260
303
|
spf,
|
|
261
304
|
dmarc,
|
|
262
305
|
arc,
|
package/lib/mta-sts.js
CHANGED
|
@@ -633,11 +633,45 @@ const getPolicy = async (domain, knownPolicy, opts) => {
|
|
|
633
633
|
return formatResult(Object.assign({}, knownPolicy), 'renewed', warnings);
|
|
634
634
|
}
|
|
635
635
|
|
|
636
|
+
// The cached policy that stays in use when the policy for the new ID can not be fetched. A valid
|
|
637
|
+
// cached "none" policy (or an earlier retry placeholder) enforces nothing, so it is replaced with
|
|
638
|
+
// a retry placeholder for the new policy ID instead of being reused
|
|
639
|
+
const canUseCached = () => {
|
|
640
|
+
if (cacheValid && knownPolicy.mode !== 'none') {
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
if (!strict && knownPolicy?.id && ['enforce', 'testing'].includes(knownPolicy.mode)) {
|
|
644
|
+
// lax mode keeps applying an expired policy instead of falling back to no policy
|
|
645
|
+
warnings.push('expired-cache');
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
return false;
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
// RFC 8461 3.3: after a failed fetch, further attempts for the same policy ID are limited. The
|
|
652
|
+
// cached policy that is returned instead records the ID and when it can be fetched again
|
|
653
|
+
const useCachedAfterFailure = err =>
|
|
654
|
+
formatResult(
|
|
655
|
+
Object.assign({}, knownPolicy, {
|
|
656
|
+
error: err,
|
|
657
|
+
retryId: policyId,
|
|
658
|
+
retryAfter: new Date(Date.now() + ERROR_RETRY_DELAY).toISOString()
|
|
659
|
+
}),
|
|
660
|
+
'errored',
|
|
661
|
+
warnings
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
let retryAfter = knownPolicy?.retryId === policyId ? new Date(knownPolicy.retryAfter).getTime() : NaN;
|
|
665
|
+
if (retryAfter > Date.now() && canUseCached()) {
|
|
666
|
+
// fetching this policy ID failed recently, keep using the cached policy without a new attempt
|
|
667
|
+
return formatResult(Object.assign({}, knownPolicy), 'errored', warnings);
|
|
668
|
+
}
|
|
669
|
+
|
|
636
670
|
try {
|
|
637
671
|
let policy = await fetchPolicyFile(domain, opts, warnings);
|
|
638
672
|
if (!policy) {
|
|
639
673
|
if (cacheValid) {
|
|
640
|
-
return
|
|
674
|
+
return useCachedAfterFailure(createError(`Policy host mta-sts.${domain} has no address records`, 'policy_host_not_found'));
|
|
641
675
|
}
|
|
642
676
|
return formatResult({ id: false, mode: 'none' }, 'not_found', warnings);
|
|
643
677
|
}
|
|
@@ -650,17 +684,9 @@ const getPolicy = async (domain, knownPolicy, opts) => {
|
|
|
650
684
|
warnings
|
|
651
685
|
);
|
|
652
686
|
} catch (err) {
|
|
653
|
-
|
|
654
|
-
// replaced with a retry placeholder for the new policy ID below instead of being reused
|
|
655
|
-
if (cacheValid && knownPolicy.mode !== 'none') {
|
|
687
|
+
if (canUseCached()) {
|
|
656
688
|
// re-use existing policy on error
|
|
657
|
-
return
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
if (!strict && knownPolicy?.id && ['enforce', 'testing'].includes(knownPolicy.mode)) {
|
|
661
|
-
// lax mode keeps applying an expired policy instead of falling back to no policy
|
|
662
|
-
warnings.push('expired-cache');
|
|
663
|
-
return useCached(err);
|
|
689
|
+
return useCachedAfterFailure(err);
|
|
664
690
|
}
|
|
665
691
|
|
|
666
692
|
// continue as if the domain had no policy, and do not retry fetching this policy ID for a while
|