mailauth 7.1.0 → 7.2.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +60 -0
- package/README.md +32 -31
- package/cli.md +1 -1
- package/docs/arc.md +62 -54
- package/docs/bimi.md +153 -54
- package/docs/dkim.md +37 -36
- package/docs/dmarc.md +4 -1
- package/docs/mta-sts.md +12 -9
- package/docs/spf.md +14 -13
- package/index.d.ts +224 -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/report.js +2 -2
- package/lib/commands/seal.js +8 -6
- package/lib/commands/spf.js +2 -2
- 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/syntax.js +11 -0
- package/lib/dkim/verify.js +1 -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 +17 -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 +159 -21
- package/package.json +18 -9
- 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
|
@@ -7,7 +7,7 @@ const { dmarc, evaluateDmarc } = require('./dmarc');
|
|
|
7
7
|
const { arc, createSeal, sealMessage, getARChain, verifyASChain } = require('./arc');
|
|
8
8
|
const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
|
|
9
9
|
const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
|
|
10
|
-
const { parseReceived, getClientAddress } = require('./parse-received');
|
|
10
|
+
const { parseReceived, getClientAddress, getClientHelo } = require('./parse-received');
|
|
11
11
|
const { formatDomain, getAlignment, formatAuthservId } = require('./tools');
|
|
12
12
|
const libmime = require('libmime');
|
|
13
13
|
const os = require('node:os');
|
|
@@ -60,17 +60,20 @@ const authenticate = async (input, opts) => {
|
|
|
60
60
|
if (opts.trustReceived) {
|
|
61
61
|
let rcvd = receivedChain?.find(row => row.from?.value);
|
|
62
62
|
if (rcvd) {
|
|
63
|
-
let helo = rcvd
|
|
63
|
+
let helo = getClientHelo(rcvd);
|
|
64
64
|
// the address of the connection from the TCP-info part of the from clause
|
|
65
65
|
// (RFC 5321 section 4.4), never an address the client claimed in its HELO
|
|
66
|
-
let ip = getClientAddress(rcvd
|
|
66
|
+
let ip = getClientAddress(rcvd);
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
// the HELO name of the header belongs to its connection, it is not combined with
|
|
69
|
+
// an address the caller provided
|
|
70
|
+
let callerIp = !!opts.ip;
|
|
71
|
+
|
|
72
|
+
if (ip && !callerIp) {
|
|
69
73
|
opts.ip = ip;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
if (helo && !opts.helo && !
|
|
73
|
-
// if IP was provided then do not use helo even if it is missing
|
|
76
|
+
if (helo && !opts.helo && !callerIp) {
|
|
74
77
|
opts.helo = helo;
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -135,6 +138,7 @@ const authenticate = async (input, opts) => {
|
|
|
135
138
|
} = await evaluateDmarc({
|
|
136
139
|
headerFrom: dkimResult.headerFrom,
|
|
137
140
|
fromFields: dkimResult.fromFields,
|
|
141
|
+
fromSyntax: dkimResult.fromSyntax,
|
|
138
142
|
spfDomains: [].concat((spfResult && spfResult.status.result === 'pass' && spfResult.domain) || []),
|
|
139
143
|
dkimDomains: (dkimResult.results || [])
|
|
140
144
|
.filter(r => r.status.result === 'pass')
|
|
@@ -210,14 +214,13 @@ const authenticate = async (input, opts) => {
|
|
|
210
214
|
let sealResult = await createSeal(false, {
|
|
211
215
|
headers: dkimResult.headers,
|
|
212
216
|
arc: dkimResult.arc,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
),
|
|
217
|
+
// the chain status, the AAR and the instance always come from this message, never
|
|
218
|
+
// from the caller's seal options (RFC 8617 section 5.1 step 4)
|
|
219
|
+
seal: Object.assign({ signTime: new Date() }, dkimResult.seal, {
|
|
220
|
+
cv: arcResult.status.result,
|
|
221
|
+
authResults: arcResult.authResults,
|
|
222
|
+
i: undefined
|
|
223
|
+
}),
|
|
221
224
|
strict: opts.strict
|
|
222
225
|
});
|
|
223
226
|
|
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
|