mailauth 3.0.3 → 4.0.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/README.md CHANGED
@@ -48,7 +48,8 @@ Where
48
48
  - **selector** (_string_) ARC key selector
49
49
  - **privateKey** (_string_ or _buffer_) Private key for signing. Either an RSA or an Ed25519 key
50
50
  - **resolver** (_async function_) is an optional async function for DNS requests. Defaults to [dns.promises.resolve](https://nodejs.org/api/dns.html#dns_dnspromises_resolve_hostname_rrtype)
51
- - **maxResolveCount** (_number_ defaults to _50_) is the DNS lookup limit for SPF. [RFC7208](https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4) requires this limit to be 10. Mailauth is less strict and defaults to 50.
51
+ - **maxResolveCount** (_number_ defaults to _10_) is the DNS lookup limit for SPF. [RFC7208](https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4) requires this limit to be 10.
52
+ - **maxVoidCount** (_number_ defaults to _2_) is the DNS lookup limit for SPF that produce an empty result. [RFC7208](https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4) requires this limit to be 2.
52
53
 
53
54
  **Example**
54
55
 
@@ -403,7 +404,6 @@ The function returns a boolean. If it is `true`, then MX hostname is allowed to
403
404
 
404
405
  [OpenSPF test suite](http://www.openspf.org/Test_Suite) ([archive.org mirror](https://web.archive.org/web/20190130131432/http://www.openspf.org/Test_Suite)) with the following differences:
405
406
 
406
- - No PTR support in `mailauth`. All PTR related tests are ignored
407
407
  - Less strict whitespace checks (`mailauth` accepts multiple spaces between tags etc.)
408
408
  - Some macro tests are skipped (macro expansion is supported _in most parts_)
409
409
  - Some tests where the invalid component is listed after a matching part (mailauth processes from left to right and returns on the first match found)
package/bin/mailauth.js CHANGED
@@ -52,7 +52,13 @@ const argv = yargs(hideBin(process.argv))
52
52
  alias: 'x',
53
53
  type: 'number',
54
54
  description: 'Maximum allowed DNS lookups',
55
- default: 50
55
+ default: 10
56
+ })
57
+ .option('max-void-lookups', {
58
+ alias: 'z',
59
+ type: 'number',
60
+ description: 'Maximum allowed empty DNS lookups',
61
+ default: 2
56
62
  });
57
63
  yargs.positional('email', {
58
64
  describe: 'Path to the email message file in EML format. If not specified then content is read from stdin'
@@ -275,7 +281,13 @@ const argv = yargs(hideBin(process.argv))
275
281
  alias: 'x',
276
282
  type: 'number',
277
283
  description: 'Maximum allowed DNS lookups',
278
- default: 50
284
+ default: 10
285
+ })
286
+ .option('max-void-lookups', {
287
+ alias: 'z',
288
+ type: 'number',
289
+ description: 'Maximum allowed empty DNS lookups',
290
+ default: 2
279
291
  });
280
292
  },
281
293
  argv => {
package/cli.md CHANGED
@@ -59,7 +59,8 @@ Where
59
59
  - `--mta hostname` or `-m hostname` is the server hostname doing the validation checks. Defaults to `os.hostname()`
60
60
  - `--dns-cache /path/to/dns.json` or `-n path` is the path to a file with cached DNS query responses. If this file is provided then no actual DNS requests are performed, only cached values from this file are used.
61
61
  - `--verbose` or `-v` if this flag is set then mailauth writes some debugging info to standard error
62
- - `--max-lookups nr` or `-x nr` defines the allowed DNS lookup limit for SPF checks. Defaults to 50.
62
+ - `--max-lookups nr` or `-x nr` defines the allowed DNS lookup limit for SPF checks. Defaults to 10.
63
+ - `--max-void-lookups nr` or `-z nr` defines the allowed DNS lookup limit for SPF checks. Defaults to 2.
63
64
 
64
65
  **Example**
65
66
 
@@ -187,14 +188,15 @@ Where
187
188
  - `--dns-cache /path/to/dns.json` or `-n path` is the path to a file with cached DNS query responses. If this file is provided then no actual DNS requests are performed, only cached values from this file are used.
188
189
  - `--verbose` or `-v` if this flag is set then mailauth writes some debugging info to standard error
189
190
  - `--headers-only` or `-o` If set return SPF authentication header only. Default is to return a JSON structure.
190
- - `--max-lookups nr` or `-x nr` defines the allowed DNS lookup limit for SPF checks. Defaults to 50.
191
+ - `--max-lookups nr` or `-x nr` defines the allowed DNS lookup limit for SPF checks. Defaults to 10.
192
+ - `--max-void-lookups nr` or `-z nr` defines the allowed DNS lookup limit for SPF checks. Defaults to 2.
191
193
 
192
194
  **Example**
193
195
 
194
196
  ```
195
197
  $ mailauth spf --verbose -f andris@wildduck.email -i 217.146.76.20
196
198
  Checking SPF for andris@wildduck.email
197
- Maximum DNS lookups: 50
199
+ Maximum DNS lookups: 10
198
200
  --------
199
201
  DNS query for TXT wildduck.email: [["v=spf1 mx a -all"]]
200
202
  DNS query for MX wildduck.email: [{"exchange":"mail.wildduck.email","priority":1}]
@@ -35,6 +35,10 @@ const cmd = async argv => {
35
35
  opts.maxResolveCount = argv.maxLookups;
36
36
  }
37
37
 
38
+ if (argv.maxVoidLookups) {
39
+ opts.maxVoidCount = argv.maxVoidLookups;
40
+ }
41
+
38
42
  for (let key of ['mta', 'helo', 'sender']) {
39
43
  if (argv[key]) {
40
44
  opts[key] = argv[key];
@@ -28,6 +28,10 @@ const cmd = async argv => {
28
28
  opts.maxResolveCount = argv.maxLookups;
29
29
  }
30
30
 
31
+ if (argv.maxVoidLookups) {
32
+ opts.maxVoidCount = argv.maxVoidLookups;
33
+ }
34
+
31
35
  for (let key of ['sender', 'helo', 'mta']) {
32
36
  if (argv[key]) {
33
37
  opts[key] = argv[key];
@@ -266,27 +266,3 @@ class RelaxedHash {
266
266
  }
267
267
 
268
268
  module.exports = { RelaxedHash };
269
-
270
- /*
271
- let fs = require('fs');
272
-
273
- const getBody = message => {
274
- message = message.toString('binary');
275
- let match = message.match(/\r?\n\r?\n/);
276
- if (match) {
277
- message = message.substr(match.index + match[0].length);
278
- }
279
- return Buffer.from(message, 'binary');
280
- };
281
-
282
- let s = fs.readFileSync(process.argv[2]);
283
-
284
- let k = new RelaxedHash('rsa-sha256', -1);
285
-
286
- for (let byte of getBody(s)) {
287
- k.update(Buffer.from([byte]));
288
- }
289
-
290
- console.error(k.digest('base64'));
291
- console.error(k.byteLength, k.bodyHashedBytes);
292
- */
package/lib/spf/index.js CHANGED
@@ -8,7 +8,8 @@ const Joi = require('joi');
8
8
  const domainSchema = Joi.string().domain({ allowUnicode: false, tlds: false });
9
9
  const { formatAuthHeaderRow, escapeCommentValue } = require('../tools');
10
10
 
11
- const MAX_RESOLVE_COUNT = 50;
11
+ const MAX_RESOLVE_COUNT = 10;
12
+ const MAX_VOID_COUNT = 2;
12
13
 
13
14
  const formatHeaders = result => {
14
15
  let header = `Received-SPF: ${result.status.result}${result.status.comment ? ` (${escapeCommentValue(result.status.comment)})` : ''} client-ip=${
@@ -19,23 +20,24 @@ const formatHeaders = result => {
19
20
  };
20
21
 
21
22
  // DNS resolver method
22
- let limitedResolver = (resolver, maxResolveCount) => {
23
+ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) => {
23
24
  let resolveCount = 0;
24
- maxResolveCount = maxResolveCount || MAX_RESOLVE_COUNT;
25
+ let voidCount = 0;
25
26
 
26
- return async (domain, type) => {
27
- if (!domain && type === 'resolveCount') {
28
- // special condition to get the counter
29
- return resolveCount;
30
- }
27
+ let subResolveCounts = {};
28
+ let firstCounted = !ignoreFirst;
31
29
 
32
- if (!domain && type === 'resolveLimit') {
33
- // special condition to get the limit
34
- return maxResolveCount;
35
- }
30
+ maxResolveCount = maxResolveCount || MAX_RESOLVE_COUNT;
31
+ maxVoidCount = maxVoidCount || MAX_VOID_COUNT;
36
32
 
33
+ let resolverFunc = async (domain, type) => {
37
34
  // do not allow to make more that MAX_RESOLVE_COUNT DNS requests per SPF check
38
- resolveCount++;
35
+
36
+ if (firstCounted) {
37
+ resolveCount++;
38
+ } else {
39
+ firstCounted = true;
40
+ }
39
41
 
40
42
  if (resolveCount > maxResolveCount) {
41
43
  let error = new Error('Too many DNS requests');
@@ -65,8 +67,17 @@ let limitedResolver = (resolver, maxResolveCount) => {
65
67
  } catch (err) {
66
68
  switch (err.code) {
67
69
  case 'ENOTFOUND':
68
- case 'ENODATA':
70
+ case 'ENODATA': {
71
+ voidCount++;
72
+ if (voidCount > maxVoidCount) {
73
+ err.spfResult = {
74
+ error: 'permerror',
75
+ text: 'Too many void DNS results'
76
+ };
77
+ throw err;
78
+ }
69
79
  return [];
80
+ }
70
81
 
71
82
  case 'ETIMEOUT':
72
83
  err.spfResult = {
@@ -80,6 +91,21 @@ let limitedResolver = (resolver, maxResolveCount) => {
80
91
  }
81
92
  }
82
93
  };
94
+
95
+ resolverFunc.updateSubQueries = (type, count) => {
96
+ if (!subResolveCounts[type]) {
97
+ subResolveCounts[type] = count;
98
+ } else {
99
+ subResolveCounts[type] += count;
100
+ }
101
+ };
102
+
103
+ resolverFunc.getResolveCount = () => resolveCount;
104
+ resolverFunc.getResolveLimit = () => maxResolveCount;
105
+ resolverFunc.getSubResolveCounts = () => subResolveCounts;
106
+ resolverFunc.getVoidCount = () => voidCount;
107
+
108
+ return resolverFunc;
83
109
  };
84
110
 
85
111
  /**
@@ -89,10 +115,11 @@ let limitedResolver = (resolver, maxResolveCount) => {
89
115
  * @param {String} opts.ip Client IP address
90
116
  * @param {String} opts.helo Client EHLO/HELO hostname
91
117
  * @param {String} [opts.mta] Hostname of the MTA or MX server that processes the message
92
- * @param {String} [opts.maxResolveCount=50] Maximum DNS lookups allowed
118
+ * @param {String} [opts.maxResolveCount=10] Maximum DNS lookups allowed
119
+ * @param {String} [opts.maxVoidCount=2] Maximum empty DNS lookups allowed
93
120
  */
94
121
  const verify = async opts => {
95
- let { sender, ip, helo, mta, maxResolveCount, resolver } = opts || {};
122
+ let { sender, ip, helo, mta, maxResolveCount, maxVoidCount, resolver } = opts || {};
96
123
 
97
124
  mta = mta || os.hostname();
98
125
 
@@ -125,7 +152,7 @@ const verify = async opts => {
125
152
  }
126
153
  };
127
154
 
128
- let verifyResolver = limitedResolver(resolver, maxResolveCount);
155
+ let verifyResolver = limitedResolver(resolver, maxResolveCount, maxVoidCount, true);
129
156
 
130
157
  let result;
131
158
  try {
@@ -146,7 +173,10 @@ const verify = async opts => {
146
173
  helo,
147
174
 
148
175
  // generate DNS handler
149
- resolver: verifyResolver
176
+ resolver: verifyResolver,
177
+
178
+ // allow to create sub resolvers
179
+ createSubResolver: () => limitedResolver(resolver, maxResolveCount, maxVoidCount)
150
180
  });
151
181
  } catch (err) {
152
182
  if (err.spfResult) {
@@ -161,8 +191,10 @@ const verify = async opts => {
161
191
 
162
192
  if (result && typeof result === 'object') {
163
193
  result.lookups = {
164
- limit: await verifyResolver(false, 'resolveLimit'),
165
- count: await verifyResolver(false, 'resolveCount')
194
+ limit: verifyResolver.getResolveLimit(),
195
+ count: verifyResolver.getResolveCount(),
196
+ void: verifyResolver.getVoidCount(),
197
+ subqueries: verifyResolver.getSubResolveCounts()
166
198
  };
167
199
  }
168
200
 
@@ -5,6 +5,9 @@ const net = require('net');
5
5
  const macro = require('./macro');
6
6
  const dns = require('dns').promises;
7
7
  const ipaddr = require('ipaddr.js');
8
+ const { getPtrHostname, formatDomain } = require('../tools');
9
+
10
+ const LIMIT_PTR_RESOLVE_RECORDS = 10;
8
11
 
9
12
  const matchIp = (addr, range) => {
10
13
  if (/\/\d+$/.test(range)) {
@@ -357,18 +360,27 @@ const spfVerify = async (domain, opts) => {
357
360
 
358
361
  let mxList = await resolver(mxDomain, 'MX');
359
362
  if (mxList) {
360
- mxList = mxList.sort((a, b) => a.priority - b.priority);
361
- for (let mx of mxList) {
362
- if (mx.exchange) {
363
- let responses = await resolver(mx.exchange, net.isIPv6(opts.ip) ? 'AAAA' : 'A');
364
- if (responses) {
365
- for (let a of responses) {
366
- if (matchIp(addr, a + cidr)) {
367
- return { type, val: mx.exchange, qualifier };
363
+ // MX resolver has separate counter
364
+ let subResolver = typeof opts.createSubResolver === 'function' ? opts.createSubResolver() : resolver;
365
+ try {
366
+ mxList = mxList.sort((a, b) => a.priority - b.priority);
367
+ for (let mx of mxList) {
368
+ if (mx.exchange) {
369
+ let responses = await subResolver(mx.exchange, net.isIPv6(opts.ip) ? 'AAAA' : 'A');
370
+ if (responses) {
371
+ for (let a of responses) {
372
+ if (matchIp(addr, a + cidr)) {
373
+ return { type, val: mx.exchange, qualifier };
374
+ }
368
375
  }
369
376
  }
370
377
  }
371
378
  }
379
+ } finally {
380
+ if (typeof resolver.updateSubQueries === 'function') {
381
+ resolver.updateSubQueries('mx', subResolver.getResolveCount());
382
+ resolver.updateSubQueries('mx:void', subResolver.getVoidCount());
383
+ }
372
384
  }
373
385
  }
374
386
  }
@@ -391,7 +403,73 @@ const spfVerify = async (domain, opts) => {
391
403
  break;
392
404
 
393
405
  case 'ptr':
394
- // ignore, not supported
406
+ {
407
+ let { cidr4, cidr6 } = parseCidrValue(val, false, type);
408
+ if (cidr4 || cidr6) {
409
+ let err = new Error('SPF failure');
410
+ err.spfResult = { error: 'permerror', text: `invalid domain-spec definition: ${val}` };
411
+ throw err;
412
+ }
413
+
414
+ let ptrDomain;
415
+ if (val) {
416
+ ptrDomain = macro(val, opts);
417
+ } else {
418
+ ptrDomain = macro('%{d}', opts);
419
+ }
420
+ ptrDomain = formatDomain(ptrDomain);
421
+
422
+ // Step 1. Resolve PTR hostnames
423
+ let ptrValues;
424
+ if (opts._resolvedPtr) {
425
+ ptrValues = opts._resolvedPtr;
426
+ } else {
427
+ let responses = await resolver(getPtrHostname(addr), 'PTR');
428
+ opts._resolvedPtr = ptrValues = responses && responses.length ? responses : [];
429
+ }
430
+
431
+ // PTR resolver has separate counter
432
+ let subResolver = typeof opts.createSubResolver === 'function' ? opts.createSubResolver() : resolver;
433
+
434
+ let resolvers = [];
435
+ for (let ptrValue of ptrValues) {
436
+ if (resolvers.length < LIMIT_PTR_RESOLVE_RECORDS) {
437
+ // resolve up to 10 PTR A/AAAA records
438
+ // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4
439
+ resolvers.push(subResolver(ptrValue, net.isIPv6(opts.ip) ? 'AAAA' : 'A'));
440
+ }
441
+ }
442
+
443
+ // Step 2. Validate PTR hostnames by reverse resolving these
444
+ let validatedPtrRecords = [];
445
+ let results = await Promise.allSettled(resolvers);
446
+
447
+ if (typeof resolver.updateSubQueries === 'function') {
448
+ resolver.updateSubQueries('ptr', subResolver.getResolveCount());
449
+ resolver.updateSubQueries('ptr:void', subResolver.getVoidCount());
450
+ }
451
+
452
+ for (let i = 0; i < results.length; i++) {
453
+ let result = results[i];
454
+ let ptrHostname = ptrValues[i];
455
+ if (
456
+ result.status === 'fulfilled' &&
457
+ Array.isArray(result.value) &&
458
+ result.value.map(val => ipaddr.parse(val).toNormalizedString()).includes(addr.toNormalizedString())
459
+ ) {
460
+ validatedPtrRecords.push(ptrHostname);
461
+ }
462
+ }
463
+
464
+ // Step 3. Check subdomain alignment
465
+ for (let ptrRecord of validatedPtrRecords) {
466
+ let formattedPtrRecord = formatDomain(ptrRecord);
467
+
468
+ if (formattedPtrRecord === ptrDomain || formattedPtrRecord.substr(-(ptrDomain.length + 1)) === `.${ptrDomain}`) {
469
+ return { type, val: ptrRecord, qualifier };
470
+ }
471
+ }
472
+ }
395
473
  break;
396
474
  }
397
475
  }
package/lib/tools.js CHANGED
@@ -470,6 +470,21 @@ const validateAlgorithm = (algorithm, strict) => {
470
470
  }
471
471
  };
472
472
 
473
+ const getPtrHostname = parsedAddr => {
474
+ let bytes = parsedAddr.toByteArray();
475
+ if (bytes.length === 4) {
476
+ return `${bytes
477
+ .map(a => a.toString(10))
478
+ .reverse()
479
+ .join('.')}.in-addr.arpa`;
480
+ } else {
481
+ return `${bytes
482
+ .flatMap(a => a.toString(16).padStart(2, '0').split(''))
483
+ .reverse()
484
+ .join('.')}.ip6.arpa`;
485
+ }
486
+ };
487
+
473
488
  module.exports = {
474
489
  writeToStream,
475
490
  parseHeaders,
@@ -491,5 +506,7 @@ module.exports = {
491
506
  getAlignment,
492
507
 
493
508
  formatRelaxedLine,
494
- formatDomain
509
+ formatDomain,
510
+
511
+ getPtrHostname
495
512
  };
package/licenses.txt CHANGED
@@ -1,12 +1,12 @@
1
1
  name license type link installed version author
2
2
  ---- ------------ ---- ----------------- ------
3
- @postalsys/vmc MIT https://registry.npmjs.org/@postalsys/vmc/-/vmc-1.0.4.tgz 1.0.4 Postal Systems OÜ
4
- fast-xml-parser MIT git+https://github.com/NaturalIntelligence/fast-xml-parser.git 4.0.9 Amit Gupta https://amitkumargupta.work/
5
- ipaddr.js MIT git://github.com/whitequark/ipaddr.js.git 2.0.1 whitequark whitequark@whitequark.org
6
- joi BSD-3-Clause git://github.com/sideway/joi.git 17.6.0
7
- libmime MIT git://github.com/andris9/libmime.git 5.1.0 Andris Reinman andris@kreata.ee
3
+ @postalsys/vmc MIT https://registry.npmjs.org/@postalsys/vmc/-/vmc-1.0.5.tgz 1.0.5 Postal Systems OÜ
4
+ fast-xml-parser MIT git+https://github.com/NaturalIntelligence/fast-xml-parser.git 4.0.9 Amit Gupta (https://amitkumargupta.work/)
5
+ ipaddr.js MIT git://github.com/whitequark/ipaddr.js.git 2.0.1 whitequark <whitequark@whitequark.org>
6
+ joi BSD-3-Clause git://github.com/sideway/joi.git 17.6.0 n/a
7
+ libmime MIT git://github.com/andris9/libmime.git 5.1.0 Andris Reinman <andris@kreata.ee>
8
8
  node-forge (BSD-3-Clause OR GPL-2.0) git+https://github.com/digitalbazaar/forge.git 1.3.1 Digital Bazaar, Inc. support@digitalbazaar.com http://digitalbazaar.com/
9
9
  nodemailer MIT git+https://github.com/nodemailer/nodemailer.git 6.7.7 Andris Reinman
10
- psl MIT git+ssh://git@github.com/lupomontero/psl.git 1.9.0 Lupo Montero lupomontero@gmail.com https://lupomontero.com/
10
+ psl MIT git+ssh://git@github.com/lupomontero/psl.git 1.9.0 Lupo Montero <lupomontero@gmail.com> (https://lupomontero.com/)
11
11
  punycode MIT git+https://github.com/bestiejs/punycode.js.git 2.1.1 Mathias Bynens https://mathiasbynens.be/
12
- yargs MIT git+https://github.com/yargs/yargs.git 17.5.1
12
+ yargs MIT git+https://github.com/yargs/yargs.git 17.5.1 n/a
package/man/mailauth.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH "MAILAUTH" "1" "July 2022" "v3.0.2" "Mailauth Help"
1
+ .TH "MAILAUTH" "1" "July 2022" "v3.0.3" "Mailauth Help"
2
2
  .SH "NAME"
3
3
  \fBmailauth\fR
4
4
  .QP
package/man/man.md CHANGED
@@ -103,7 +103,10 @@ content is read from standard input.
103
103
  Return signing headers only. By default, the entire message is printed to the console. (`sign`, `seal`, `spf`)
104
104
 
105
105
  - `--max-lookups`, `-x`
106
- How many DNS lookups allowed for SPF validation. Defaults to 50. (`report`, `spf`)
106
+ How many DNS lookups allowed for SPF validation. Defaults to 10. (`report`, `spf`)
107
+
108
+ - `--max-void-lookups`, `-z`
109
+ How many empty DNS lookups allowed for SPF validation. Defaults to 2. (`report`, `spf`)
107
110
 
108
111
  ## DNS CACHE
109
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailauth",
3
- "version": "3.0.3",
3
+ "version": "4.0.0",
4
4
  "description": "Email authentication library for Node.js",
5
5
  "main": "lib/mailauth.js",
6
6
  "scripts": {
@@ -37,7 +37,7 @@
37
37
  "eslint-config-nodemailer": "1.2.0",
38
38
  "eslint-config-prettier": "8.5.0",
39
39
  "js-yaml": "4.1.0",
40
- "license-report": "5.0.2",
40
+ "license-report": "6.0.0",
41
41
  "marked": "0.7.0",
42
42
  "marked-man": "0.7.0",
43
43
  "mbox-reader": "1.1.5",