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.
Files changed (58) hide show
  1. package/.c8rc.json +11 -0
  2. package/.prettierignore +3 -0
  3. package/.release-please-manifest.json +1 -1
  4. package/CHANGELOG.md +68 -0
  5. package/README.md +131 -31
  6. package/bin/mailauth.js +62 -0
  7. package/cli.md +150 -8
  8. package/docs/arc.md +62 -54
  9. package/docs/bimi.md +153 -54
  10. package/docs/dkim.md +37 -36
  11. package/docs/dkim2.md +154 -0
  12. package/docs/dmarc.md +4 -1
  13. package/docs/mta-sts.md +12 -9
  14. package/docs/spf.md +14 -13
  15. package/index.d.ts +583 -42
  16. package/lib/arc/index.d.ts +6 -2
  17. package/lib/arc/index.js +45 -15
  18. package/lib/bimi/index.js +362 -102
  19. package/lib/bimi/validate-svg.js +493 -60
  20. package/lib/commands/dkim2-hash.js +25 -0
  21. package/lib/commands/dkim2-sign.js +77 -0
  22. package/lib/commands/dkim2-verify.js +59 -0
  23. package/lib/commands/report.js +10 -35
  24. package/lib/commands/resolver.js +51 -0
  25. package/lib/commands/seal.js +10 -38
  26. package/lib/commands/spf.js +4 -35
  27. package/lib/dkim/dkim-signer.js +50 -30
  28. package/lib/dkim/dkim-verifier.js +79 -27
  29. package/lib/dkim/header/simple.js +2 -2
  30. package/lib/dkim/sign.js +7 -2
  31. package/lib/dkim/syntax.js +11 -0
  32. package/lib/dkim/verify.js +1 -0
  33. package/lib/dkim2/dkim2-parser.js +52 -0
  34. package/lib/dkim2/dkim2-signer.js +334 -0
  35. package/lib/dkim2/dkim2-verifier.js +572 -0
  36. package/lib/dkim2/fields.js +570 -0
  37. package/lib/dkim2/hash.d.ts +3 -0
  38. package/lib/dkim2/hash.js +51 -0
  39. package/lib/dkim2/key.js +203 -0
  40. package/lib/dkim2/message-state.js +181 -0
  41. package/lib/dkim2/recipe.js +134 -0
  42. package/lib/dkim2/sign.d.ts +3 -0
  43. package/lib/dkim2/sign.js +38 -0
  44. package/lib/dkim2/verify.d.ts +3 -0
  45. package/lib/dkim2/verify.js +19 -0
  46. package/lib/dmarc/author-domain.js +270 -5
  47. package/lib/dmarc/get-dmarc-record.js +24 -11
  48. package/lib/dmarc/verify.js +7 -3
  49. package/lib/mailauth.js +57 -14
  50. package/lib/mta-sts.js +37 -11
  51. package/lib/parse-received.js +214 -25
  52. package/lib/spf/index.js +21 -4
  53. package/lib/spf/macro.js +5 -3
  54. package/lib/spf/spf-verify.js +14 -5
  55. package/lib/spf/syntax.js +5 -0
  56. package/lib/tools.js +160 -21
  57. package/package.json +15 -6
  58. package/stryker.config.json +16 -0
@@ -208,49 +208,238 @@ const parseReceived = buf => {
208
208
  const ADDRESS_LITERAL = /\[(?:IPv6:)?([^\]\s]+)\](?::\d+)?/gi;
209
209
  // "helo=" or "ehlo=" right before a literal, the client's own claim, not its address
210
210
  const HELO_PREFIX = /(?:^|[\s(;,])(?:helo|ehlo)=\s*$/i;
211
+ // a "helo=" or "ehlo=" anywhere in a comment, the HELO name as Exim writes it
212
+ const HELO_KEYWORD = /(?:^|[\s(;,])(?:helo|ehlo)=/i;
213
+ // a word of the Received header, anything up to whitespace or a special character
214
+ const WORD = /[^\s()";\\]+/y;
215
+
216
+ /**
217
+ * Splits the from clause of a Received header into its words and comments, up to the "by"
218
+ * keyword that ends it. The receiving server writes the TCP-info comment after the HELO name,
219
+ * and some servers (Postfix) copy the HELO argument as it was given, spaces, parentheses and
220
+ * quotes included. Such a HELO can look like a TCP-info comment and a "by" keyword of its own,
221
+ * so a header that can not be split without guessing returns false: a comment or a quoted
222
+ * string that is not closed, a stray ")" or a backslash, a quoted string in the from clause,
223
+ * more than one top level "by" or ";", or a "by" that is not followed by a host name.
224
+ *
225
+ * @param {String} header Received header
226
+ * @returns {Object|false} { words, comments } of the from clause, or false
227
+ */
228
+ const splitFromClause = header => {
229
+ let value = header.substring(header.indexOf(':') + 1);
230
+
231
+ let tokens = [];
232
+ // where the date-time part starts in `tokens`
233
+ let dateTimeStart = -1;
234
+ let i = 0;
235
+ while (i < value.length) {
236
+ let c = value.charAt(i);
237
+
238
+ if (/\s/.test(c)) {
239
+ i++;
240
+ continue;
241
+ }
242
+
243
+ if (c === ';') {
244
+ // the date-time part. RFC 5322 section 3.6.7 has a single ";", in front of the
245
+ // date-time, so another one came from the HELO argument, and so may the first
246
+ // one. The rest of the header is still read to find such a ";"
247
+ if (dateTimeStart >= 0) {
248
+ return false;
249
+ }
250
+ dateTimeStart = tokens.length;
251
+ i++;
252
+ continue;
253
+ }
254
+
255
+ if (c === ')' || c === '\\') {
256
+ return false;
257
+ }
258
+
259
+ if (c === '(') {
260
+ // RFC 5322 comment, nested comments are part of it
261
+ let start = i;
262
+ let level = 0;
263
+ for (; i < value.length; i++) {
264
+ let cc = value.charAt(i);
265
+ if (cc === '\\') {
266
+ return false;
267
+ }
268
+ if (cc === '(') {
269
+ level++;
270
+ } else if (cc === ')' && --level === 0) {
271
+ break;
272
+ }
273
+ }
274
+ if (level) {
275
+ return false;
276
+ }
277
+ tokens.push({ type: 'comment', value: value.substring(start + 1, i) });
278
+ i++;
279
+ continue;
280
+ }
281
+
282
+ if (c === '"') {
283
+ let end = i + 1;
284
+ while (end < value.length && value.charAt(end) !== '"') {
285
+ // a quoted-pair keeps the next character
286
+ end += value.charAt(end) === '\\' ? 2 : 1;
287
+ }
288
+ if (end >= value.length) {
289
+ return false;
290
+ }
291
+ tokens.push({ type: 'quoted', value: value.substring(i, end + 1) });
292
+ i = end + 1;
293
+ continue;
294
+ }
295
+
296
+ WORD.lastIndex = i;
297
+ let word = WORD.exec(value)[0];
298
+ tokens.push({ type: 'word', value: word });
299
+ i += word.length;
300
+ }
301
+
302
+ if (dateTimeStart >= 0) {
303
+ tokens = tokens.slice(0, dateTimeStart);
304
+ }
305
+
306
+ if (tokens[0]?.type !== 'word' || tokens[0].value.toLowerCase() !== 'from') {
307
+ return false;
308
+ }
309
+
310
+ let byPositions = [];
311
+ tokens.forEach((token, pos) => {
312
+ if (token.type === 'word' && token.value.toLowerCase() === 'by') {
313
+ byPositions.push(pos);
314
+ }
315
+ });
316
+
317
+ if (byPositions.length !== 1 || tokens[byPositions[0] + 1]?.type !== 'word') {
318
+ return false;
319
+ }
320
+
321
+ let clause = tokens.slice(1, byPositions[0]);
322
+ if (clause.some(token => token.type === 'quoted')) {
323
+ return false;
324
+ }
325
+
326
+ return {
327
+ words: clause.filter(token => token.type === 'word').map(token => token.value),
328
+ comments: clause.filter(token => token.type === 'comment').map(token => token.value)
329
+ };
330
+ };
331
+
332
+ // splitFromClause results of parsed Received headers, so that getClientAddress and getClientHelo
333
+ // split each header once. Kept aside, the parsed header object is not modified
334
+ const fromClauseCache = new WeakMap();
335
+
336
+ // The split from clause of a parsed Received header (see splitFromClause), or false
337
+ const getFromClause = received => {
338
+ if (!received || typeof received !== 'object' || typeof received.full !== 'string') {
339
+ return false;
340
+ }
341
+ let cached = fromClauseCache.get(received);
342
+ if (cached?.full !== received.full) {
343
+ cached = { full: received.full, clause: splitFromClause(received.full) };
344
+ fromClauseCache.set(received, cached);
345
+ }
346
+ return cached.clause;
347
+ };
211
348
 
212
349
  /**
213
350
  * Finds the IP address of the client in the from clause of a parsed Received header.
214
351
  *
215
352
  * RFC 5321 section 4.4 puts the address the connection came from into the TCP-info comment
216
- * of the from clause, "from helo.name (rdns.name [192.0.2.1])", so it is the last address
217
- * literal of that comment. The rest of the clause is the client's own claim: the HELO name,
218
- * which can itself be an address literal, and in Exim's format a "helo=" value inside the
219
- * comment. Those are never used. Exim writes "from [192.0.2.1] (helo=...)" for a client
220
- * without reverse DNS, where the literal in front of the comment is the address of the
221
- * connection, which is only used when the comment has no address literal of its own.
353
+ * of the from clause, "from helo.name (rdns.name [192.0.2.1])". The rest of the clause is the
354
+ * client's own claim: the HELO name, which can itself be an address literal or hold
355
+ * parentheses, and in Exim's format a "helo=" value inside the comment. Those are never used.
356
+ * Exim writes "from [192.0.2.1] (helo=...)" for a client without reverse DNS, where the literal
357
+ * in front of the comment is the address of the connection. With a "helo=" comment it is used
358
+ * unless a comment has a different address literal, otherwise only when no comment has an
359
+ * address literal of its own.
222
360
  *
223
- * @param {Object} from The "from" entry of parseReceived(), { value, comment }
361
+ * Only an unambiguous address is returned. A header that can not be split into a from clause
362
+ * and a single "by" (see splitFromClause), a from clause with more than one address in its
363
+ * comments, or Exim's layout with a different address in a comment, returns false instead of
364
+ * a guess.
365
+ *
366
+ * @param {Object} received Parsed Received header, the result of parseReceived()
224
367
  * @returns {String|false} IP address, without an "IPv6:" prefix, or false if none was found
225
368
  */
226
- const getClientAddress = from => {
227
- if (!from || typeof from !== 'object') {
369
+ const getClientAddress = received => {
370
+ let clause = getFromClause(received);
371
+ if (!clause) {
228
372
  return false;
229
373
  }
230
374
 
231
- let comment = typeof from.comment === 'string' ? from.comment : '';
232
- let value = typeof from.value === 'string' ? from.value.trim() : '';
233
-
234
- let address = false;
235
- for (let match of comment.matchAll(ADDRESS_LITERAL)) {
236
- if (HELO_PREFIX.test(comment.substring(0, match.index))) {
237
- continue;
375
+ let addresses = new Set();
376
+ for (let comment of clause.comments) {
377
+ for (let match of comment.matchAll(ADDRESS_LITERAL)) {
378
+ if (!HELO_PREFIX.test(comment.substring(0, match.index)) && isIP(match[1])) {
379
+ addresses.add(match[1]);
380
+ }
238
381
  }
239
- if (isIP(match[1])) {
240
- address = match[1];
382
+ }
383
+
384
+ let valueMatch = clause.comments.length && clause.words.length && clause.words[0].match(/^\[(?:IPv6:)?([^\]\s]+)\](?::\d+)?$/i);
385
+ let valueAddress = valueMatch && isIP(valueMatch[1]) ? valueMatch[1] : false;
386
+
387
+ if (valueAddress && clause.comments.some(comment => HELO_KEYWORD.test(comment))) {
388
+ // Exim's layout for a client without reverse DNS, "from [192.0.2.1] (helo=name)". The
389
+ // literal in front of the comment is the address of the connection, and everything
390
+ // after "helo=" is the client's own text, which can add words, comments and address
391
+ // literals of its own. The same header can also come from Postfix, with
392
+ // "[192.0.2.1] (helo=name)" as the HELO argument and the TCP-info comment after it. So
393
+ // a different address in the comments may be the real one or a forged one, and the
394
+ // header is ambiguous
395
+ if (clause.words.length === 1 && (!addresses.size || (addresses.size === 1 && addresses.has(valueAddress)))) {
396
+ return valueAddress;
241
397
  }
398
+ return false;
242
399
  }
243
400
 
244
- if (address) {
245
- return address;
401
+ if (addresses.size === 1) {
402
+ return addresses.values().next().value;
246
403
  }
247
404
 
248
- let valueMatch = value.match(/^\[(?:IPv6:)?([^\]\s]+)\](?::\d+)?$/i);
249
- if (valueMatch && comment && isIP(valueMatch[1])) {
250
- return valueMatch[1];
405
+ if (addresses.size > 1) {
406
+ // the HELO name holds an address literal of its own, can not tell which one is real
407
+ return false;
408
+ }
409
+
410
+ return clause.words.length === 1 && valueAddress;
411
+ };
412
+
413
+ // the HELO name in the TCP-info comment, "helo=name" as Exim writes it or "HELO name" as qmail does
414
+ const HELO_COMMENT = /(?:^|[\s(;,])(?:helo|ehlo)=([^\s()]+)|^(?:helo|ehlo)\s+([^\s()]+)$/i;
415
+
416
+ /**
417
+ * Finds the HELO name of the client in the from clause of a parsed Received header. Most servers
418
+ * write the HELO name as the from value, "from helo.name (rdns.name [192.0.2.1])", but Exim and
419
+ * qmail write the reverse DNS name there and the HELO name into a comment when the two differ,
420
+ * "from rdns.name ([192.0.2.1] helo=helo.name)" and "from rdns.name (HELO helo.name) (192.0.2.1)".
421
+ *
422
+ * A header that can not be split into a from clause and a single "by" (see splitFromClause)
423
+ * returns false, as the HELO name in it can not be told apart from the rest.
424
+ *
425
+ * @param {Object} received Parsed Received header, the result of parseReceived()
426
+ * @returns {String|false} HELO name or false if none was found
427
+ */
428
+ const getClientHelo = received => {
429
+ let clause = getFromClause(received);
430
+ if (!clause) {
431
+ return false;
432
+ }
433
+
434
+ for (let comment of clause.comments) {
435
+ let match = comment.trim().match(HELO_COMMENT);
436
+ if (match) {
437
+ return match[1] || match[2];
438
+ }
251
439
  }
252
440
 
253
- return false;
441
+ let value = received.from?.value;
442
+ return (typeof value === 'string' && value.trim()) || false;
254
443
  };
255
444
 
256
- module.exports = { parseReceived, getClientAddress };
445
+ module.exports = { parseReceived, getClientAddress, getClientHelo };
package/lib/spf/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { spfVerify } = require('./spf-verify');
4
- const { parseIp, isValidTargetName } = require('./syntax');
4
+ const { parseIp, isValidTargetName, stripRootDot } = require('./syntax');
5
5
  const os = require('node:os');
6
6
  const dns = require('node:dns');
7
7
  const libmime = require('libmime');
@@ -135,7 +135,8 @@ let dualStackResolver = async (resolver, domain, opts, state) => {
135
135
  * @param {Boolean} [options.strict] - If true, follow RFC 7208 exactly (no dual-stack void optimization)
136
136
  * @param {Object} [options.ctx] - Shared evaluation state, lax acceptances are added to ctx.warnings
137
137
  * @param {Number} [options.deadline] - Timestamp after which no more queries are made
138
- * @returns {Function} - Rate-limited resolver function with signature: (domain, type, opts) => Promise<Array>
138
+ * @returns {Function} - Rate-limited resolver function with signature: (domain, type, opts) => Promise<Array>.
139
+ * If `opts.ignoreVoid` is set, the query counts toward the lookup limit but an empty answer is not a void lookup
139
140
  */
140
141
  let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst, options) => {
141
142
  options = options || {};
@@ -148,8 +149,9 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst, opt
148
149
  let subResolveCounts = {};
149
150
  let firstCounted = !ignoreFirst;
150
151
 
151
- maxResolveCount = maxResolveCount || MAX_RESOLVE_COUNT;
152
- maxVoidCount = maxVoidCount || MAX_VOID_COUNT;
152
+ // 0 is a valid limit, the default is used only if the value is not a non-negative number
153
+ maxResolveCount = typeof maxResolveCount === 'number' && maxResolveCount >= 0 ? maxResolveCount : MAX_RESOLVE_COUNT;
154
+ maxVoidCount = typeof maxVoidCount === 'number' && maxVoidCount >= 0 ? maxVoidCount : MAX_VOID_COUNT;
153
155
 
154
156
  let checkRfcVoidCount = () => {
155
157
  if (rfcVoidCount > maxVoidCount && options.ctx && options.ctx.warnings) {
@@ -233,6 +235,10 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst, opt
233
235
  case 'ENOTFOUND': // Domain does not exist
234
236
  case 'ENODATA': {
235
237
  // Domain exists but has no records of this type
238
+ if (opts?.ignoreVoid) {
239
+ // the void limit counts terms, and the term decides with its own query
240
+ return [];
241
+ }
236
242
  // Increment void lookup counter
237
243
  voidCount++;
238
244
  rfcVoidCount++;
@@ -369,6 +375,11 @@ const verify = async opts => {
369
375
 
370
376
  mta = mta || os.hostname();
371
377
 
378
+ // the root dot is removed from the identities used for evaluation, macros and reporting
379
+ if (typeof helo === 'string') {
380
+ helo = stripRootDot(helo);
381
+ }
382
+
372
383
  // a null reverse-path means that the HELO identity is checked (RFC 7208 section 2.4)
373
384
  let nullSender = !sender;
374
385
 
@@ -394,6 +405,12 @@ const verify = async opts => {
394
405
  }
395
406
  atPos = sender.lastIndexOf('@');
396
407
 
408
+ let senderDomain = sender.substr(atPos + 1).trim();
409
+ let relativeDomain = stripRootDot(senderDomain);
410
+ if (relativeDomain !== senderDomain) {
411
+ sender = `${sender.substr(0, atPos + 1)}${relativeDomain}`;
412
+ }
413
+
397
414
  let domain =
398
415
  sender
399
416
  .substr(atPos + 1)
package/lib/spf/macro.js CHANGED
@@ -54,7 +54,7 @@ const macro = (input, values) => {
54
54
  if (m === '%') {
55
55
  // Lone % found
56
56
  let err = new Error('Syntax error on parsing macro');
57
- err.spfResult = { error: 'permerror', message: `Unexpected % in macro` };
57
+ err.spfResult = { error: 'permerror', text: `Unexpected % in macro` };
58
58
  throw err;
59
59
  }
60
60
 
@@ -143,7 +143,7 @@ const macro = (input, values) => {
143
143
 
144
144
  default: {
145
145
  let err = new Error('Syntax error on parsing macro');
146
- err.spfResult = { error: 'permerror', message: `Unknown macro letter "${macroChar}"` };
146
+ err.spfResult = { error: 'permerror', text: `Unknown macro letter "${macroChar}"` };
147
147
  throw err;
148
148
  }
149
149
  }
@@ -184,7 +184,9 @@ const macro = (input, values) => {
184
184
  delimiters = delimiters || '.';
185
185
 
186
186
  if (reversed || nr || delimiters !== '.') {
187
- curval = curval.split(new RegExp(`[${delimiters.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}]`));
187
+ // every delimiter is escaped, an unescaped "-" between two others would make a range
188
+ // in the character class, such as "+-." that also matches ","
189
+ curval = curval.split(new RegExp(`[${delimiters.replace(/./g, '\\$&')}]`));
188
190
 
189
191
  if (reversed) {
190
192
  curval = curval.reverse();
@@ -94,12 +94,13 @@ const parseCidrValue = (val, defaultValue, type) => {
94
94
  * goes through the given resolver, so with the limited resolver every "ptr" term and every
95
95
  * %{p} expansion counts toward the DNS lookup limit (RFC 7208 section 4.6.4).
96
96
  *
97
+ * @param {Object} [ptrOpts] Options for the PTR query, `{ ignoreVoid: true }` if an empty answer is not a void lookup
97
98
  * @returns {Array} Validated names, empty if there are none or the PTR lookup failed
98
99
  */
99
- const getValidatedNames = async (addr, resolver, subResolver) => {
100
+ const getValidatedNames = async (addr, resolver, subResolver, ptrOpts) => {
100
101
  let ptrValues;
101
102
  try {
102
- ptrValues = await resolver(getPtrHostname(addr), 'PTR');
103
+ ptrValues = await resolver(getPtrHostname(addr), 'PTR', ptrOpts);
103
104
  } catch (err) {
104
105
  if (isFatalError(err)) {
105
106
  throw err;
@@ -195,9 +196,11 @@ const spfVerify = async (domain, opts) => {
195
196
  let senderStr = (opts.sender || '').toString();
196
197
  let nonAsciiLocalPart = /[^\x00-\x7F]/.test(senderStr.substr(0, Math.max(senderStr.lastIndexOf('@'), 0)));
197
198
 
198
- // The macro values with %{p}, the validated domain name of the client IP (RFC 7208 section 7.3)
199
+ // The macro values with %{p}, the validated domain name of the client IP (RFC 7208 section 7.3).
200
+ // The PTR query counts toward the lookup limit but not as a void lookup, the void limit counts
201
+ // terms (RFC 7208 section 4.6.4) and the query of the term itself decides if the term was void
199
202
  let withPtr = async (ptrResolver, subResolver) =>
200
- Object.assign({}, macroValues, { p: selectPtrName(await getValidatedNames(addr, ptrResolver, subResolver), domain) });
203
+ Object.assign({}, macroValues, { p: selectPtrName(await getValidatedNames(addr, ptrResolver, subResolver, { ignoreVoid: true }), domain) });
201
204
 
202
205
  /**
203
206
  * Expands a domain-spec into a target name
@@ -643,7 +646,13 @@ const spfVerify = async (domain, opts) => {
643
646
  return false;
644
647
  }
645
648
 
646
- return macro(text, usesMacro(text, 'p') ? await withPtr(expResolver, expResolver) : macroValues);
649
+ let explanation = macro(text, usesMacro(text, 'p') ? await withPtr(expResolver, expResolver) : macroValues);
650
+ // the limit applies to the final, macro-expanded string, %{s}, %{l} and %{h} copy the identities as they are
651
+ if (/[^\x20-\x7E]/.test(explanation)) {
652
+ return false;
653
+ }
654
+
655
+ return explanation;
647
656
  } catch (err) {
648
657
  return false;
649
658
  }
package/lib/spf/syntax.js CHANGED
@@ -356,8 +356,13 @@ const validateTerms = terms => {
356
356
  return false;
357
357
  };
358
358
 
359
+ // A single trailing dot makes a name fully qualified, it is the same DNS name without it (RFC 7208
360
+ // section 4.3). Only a single one is removed, a zero-length label anywhere else is still malformed
361
+ const stripRootDot = name => (/[^.]\.$/.test(name) ? name.slice(0, -1) : name);
362
+
359
363
  module.exports = {
360
364
  parseIp,
365
+ stripRootDot,
361
366
  ipInNetwork,
362
367
  normalizeTargetName,
363
368
  isValidTargetName,