mailauth 3.0.1 → 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
@@ -10,10 +10,10 @@
10
10
  - ARC sealing
11
11
  - Sealing on authentication
12
12
  - Sealing after modifications
13
- - **BIMI** resolving
13
+ - **BIMI** resolving and **VMC** validation
14
14
  - **MTA-STS** helpers
15
15
 
16
- Pure JavaScript implementation, no external applications or compilation needed. It runs on any server/device that has Node 14+ installed.
16
+ Pure JavaScript implementation, no external applications or compilation needed. It runs on any server/device that has Node 16+ installed.
17
17
 
18
18
  ## Command line usage
19
19
 
@@ -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
@@ -26,14 +26,6 @@ Download `mailauth` for your platform:
26
26
  - [Windows](https://github.com/postalsys/mailauth/releases/latest/download/mailauth.exe)
27
27
  - Or install from the NPM registry: `npm install -g mailauth`
28
28
 
29
- > **NB!** Downloadable files are quite large because these are packaged Node.js applications
30
-
31
- Alternatively you can install `mailauth` from [npm](https://npmjs.com/package/mailauth).
32
-
33
- ```
34
- npm install -g mailauth
35
- ```
36
-
37
29
  ## Help
38
30
 
39
31
  ```
@@ -67,7 +59,8 @@ Where
67
59
  - `--mta hostname` or `-m hostname` is the server hostname doing the validation checks. Defaults to `os.hostname()`
68
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.
69
61
  - `--verbose` or `-v` if this flag is set then mailauth writes some debugging info to standard error
70
- - `--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.
71
64
 
72
65
  **Example**
73
66
 
@@ -195,14 +188,15 @@ Where
195
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.
196
189
  - `--verbose` or `-v` if this flag is set then mailauth writes some debugging info to standard error
197
190
  - `--headers-only` or `-o` If set return SPF authentication header only. Default is to return a JSON structure.
198
- - `--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.
199
193
 
200
194
  **Example**
201
195
 
202
196
  ```
203
197
  $ mailauth spf --verbose -f andris@wildduck.email -i 217.146.76.20
204
198
  Checking SPF for andris@wildduck.email
205
- Maximum DNS lookups: 50
199
+ Maximum DNS lookups: 10
206
200
  --------
207
201
  DNS query for TXT wildduck.email: [["v=spf1 mx a -all"]]
208
202
  DNS query for MX wildduck.email: [{"exchange":"mail.wildduck.email","priority":1}]
@@ -246,9 +240,6 @@ $ mailauth vmc -a https://amplify.valimail.com/bimi/time-warner/yV3KRIg4nJW-cnn.
246
240
  "logoFile": "<2300B base64 encoded file>",
247
241
  "validHash": true,
248
242
  "certificate": {
249
- "subjectAltName": [
250
- "cnn.com"
251
- ],
252
243
  "subject": {
253
244
  "businessCategory": "Private Organization",
254
245
  "jurisdictionCountryName": "US",
@@ -263,8 +254,13 @@ $ mailauth vmc -a https://amplify.valimail.com/bimi/time-warner/yV3KRIg4nJW-cnn.
263
254
  "trademarkCountryOrRegionName": "US",
264
255
  "trademarkRegistration": "5817930"
265
256
  },
257
+ "subjectAltName": [
258
+ "cnn.com"
259
+ ],
266
260
  "fingerprint": "17:B3:94:97:E6:6B:C8:6B:33:B8:0A:D2:F0:79:6B:08:A2:A6:84:BD",
267
261
  "serialNumber": "0821B8FE0A9CBC3BAC10DA08C088EEF4",
262
+ "validFrom": "2021-08-12T00:00:00.000Z",
263
+ "validTo": "2022-08-12T23:59:59.000Z",
268
264
  "issuer": {
269
265
  "countryName": "US",
270
266
  "organizationName": "DigiCert, Inc.",
@@ -275,7 +271,7 @@ $ mailauth vmc -a https://amplify.valimail.com/bimi/time-warner/yV3KRIg4nJW-cnn.
275
271
  }
276
272
  ```
277
273
 
278
- If the certificate verification fails, then the contents are not returned.
274
+ If the certificate verification fails, then the logo contents are not returned.
279
275
 
280
276
  ```
281
277
  $ mailauth vmc -p /path/to/random/cert-bundle.pem
@@ -284,17 +280,46 @@ $ mailauth vmc -p /path/to/random/cert-bundle.pem
284
280
  "error": {
285
281
  "message": "Self signed certificate in certificate chain",
286
282
  "details": {
287
- "subject": "CN=postal.vmc.local\nO=Postal Systems OU.\nC=EE",
288
- "fingerprint": "CC:49:83:ED:3F:6B:77:45:5B:A5:3B:9E:EC:99:0E:A1:EF:D7:FF:97",
289
- "fingerprint235": "D4:36:6F:B4:EF:2B:4F:9E:84:23:3D:F2:3A:F7:13:21:C6:C3:CF:CB:03:5F:BB:54:5B:69:A4:AC:6A:43:61:7D",
290
- "validFrom": "Jul 9 06:13:33 2022 GMT",
291
- "validTo": "Jul 9 06:13:33 2023 GMT"
283
+ "certificate": {
284
+ "subject": {
285
+ "commonName": "postal.vmc.local",
286
+ "organizationName": "Postal Systems OU.",
287
+ "countryName": "EE"
288
+ },
289
+ "subjectAltName": [],
290
+ "fingerprint": "CC:49:83:ED:3F:6B:77:45:5B:A5:3B:9E:EC:99:0E:A1:EF:D7:FF:97",
291
+ "serialNumber": "B61FBFBA917B15D9",
292
+ "validFrom": "2022-07-09T06:13:33.000Z",
293
+ "validTo": "2023-07-09T06:13:33.000Z",
294
+ "issuer": {
295
+ "commonName": "postal.vmc.local",
296
+ "organizationName": "Postal Systems OU.",
297
+ "countryName": "EE"
298
+ }
299
+ }
292
300
  },
293
301
  "code": "SELF_SIGNED_CERT_IN_CHAIN"
294
302
  }
295
303
  }
296
304
  ```
297
305
 
306
+ The embedded SVG file is also validated.
307
+
308
+ ```
309
+ $ mailauth vmc -p /path/to/vmc-with-invalid-svg.pem
310
+ {
311
+ "success": false,
312
+ "error": {
313
+ "message": "VMC logo SVG validation failed",
314
+ "details": {
315
+ "message": "Not a Tiny PS profile",
316
+ "code": "INVALID_BASE_PROFILE"
317
+ },
318
+ "code": "SVG_VALIDATION_FAILED"
319
+ }
320
+ }
321
+ ```
322
+
298
323
  ### license
299
324
 
300
325
  Display licenses for `mailauth` and included modules.
package/lib/bimi/index.js CHANGED
@@ -12,6 +12,7 @@ const httpsSchema = Joi.string().uri({
12
12
  const https = require('https');
13
13
  const http = require('http');
14
14
  const { vmc } = require('@postalsys/vmc');
15
+ const { validateSvg } = require('./validate-svg');
15
16
 
16
17
  const lookup = async data => {
17
18
  let { dmarc, headers, resolver } = data;
@@ -303,6 +304,12 @@ const validateVMC = async bimiData => {
303
304
  try {
304
305
  let vmcData = await vmc(authorityValue);
305
306
 
307
+ if (!vmcData.logoFile) {
308
+ let error = new Error('VMC does not contain a log file');
309
+ error.code = 'MISSING_VMC_LOGO';
310
+ throw error;
311
+ }
312
+
306
313
  if (vmcData?.mediaType?.toLowerCase() !== 'image/svg+xml') {
307
314
  let error = new Error('Invalid media type for the logo file');
308
315
  error.details = {
@@ -312,6 +319,33 @@ const validateVMC = async bimiData => {
312
319
  throw error;
313
320
  }
314
321
 
322
+ if (!vmcData.validHash) {
323
+ let error = new Error('VMC hash does not match logo file');
324
+ error.details = {
325
+ hashAlgo: vmcData.hashAlgo,
326
+ hashValue: vmcData.hashValue,
327
+ logoFile: vmcData.logoFile
328
+ };
329
+ error.code = 'INVALID_LOGO_HASH';
330
+ throw error;
331
+ }
332
+
333
+ // throws on invalid logo file
334
+ try {
335
+ validateSvg(Buffer.from(vmcData.logoFile, 'base64'));
336
+ } catch (err) {
337
+ let error = new Error('VMC logo SVG validation failed');
338
+ error.details = Object.assign(
339
+ {
340
+ message: err.message
341
+ },
342
+ error.details || {},
343
+ err.code ? { code: err.code } : {}
344
+ );
345
+ error.code = 'SVG_VALIDATION_FAILED';
346
+ throw error;
347
+ }
348
+
315
349
  if (d) {
316
350
  // validate domain
317
351
  let selectorSet = [];
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const { XMLParser } = require('fast-xml-parser');
4
+
5
+ function validateSvg(logo) {
6
+ const parser = new XMLParser({
7
+ ignoreAttributes: false,
8
+ attributeNamePrefix: '@_'
9
+ });
10
+
11
+ let logoObj;
12
+ try {
13
+ logoObj = parser.parse(logo);
14
+ if (!logoObj) {
15
+ throw new Error('Emtpy file');
16
+ }
17
+ } catch (err) {
18
+ let error = new Error('Invalid SVG file');
19
+ error._err = err;
20
+ error.code = 'INVALID_XML_FILE';
21
+ throw error;
22
+ }
23
+
24
+ if (!logoObj.svg) {
25
+ let error = new Error('Invalid SVG file');
26
+ error.code = 'INVALID_SVG_FILE';
27
+ throw error;
28
+ }
29
+
30
+ if (logoObj.svg['@_baseProfile'] !== 'tiny-ps') {
31
+ let error = new Error('Not a Tiny PS profile');
32
+ error.code = 'INVALID_BASE_PROFILE';
33
+ throw error;
34
+ }
35
+
36
+ if (!logoObj.svg.title) {
37
+ let error = new Error('Logo file is missing title');
38
+ error.code = 'LOGO_MISSING_TITLE';
39
+ throw error;
40
+ }
41
+
42
+ if ('@_x' in logoObj.svg || '@_y' in logoObj.svg) {
43
+ let error = new Error('Logo root includes x/y attributes');
44
+ error.code = 'LOGO_INVALID_ROOT_ATTRS';
45
+ throw error;
46
+ }
47
+
48
+ let walkElm = (node, name, path) => {
49
+ if (!node) {
50
+ return;
51
+ }
52
+ if (Array.isArray(node)) {
53
+ for (let entry of node) {
54
+ walkElm(entry, name, path + '.' + name + '[]');
55
+ }
56
+ } else if (typeof node === 'object') {
57
+ if (node['@_xlink:href'] && !/^#/.test(node['@_xlink:href'])) {
58
+ let error = new Error('External reference found from file');
59
+ error.details = {
60
+ element: name,
61
+ link: node['@_xlink:href'],
62
+ path
63
+ };
64
+ error.code = 'LOGO_INCLUDES_REFERENCE';
65
+ throw error;
66
+ }
67
+
68
+ for (let key of Object.keys(node)) {
69
+ if (['script', 'animate', 'animatemotion', 'animatetransform', 'discard', 'set'].includes(key.toLowerCase())) {
70
+ let error = new Error('Unallowed element found from file');
71
+ error.details = {
72
+ element: key,
73
+ path: path + '.' + key
74
+ };
75
+ error.code = 'LOGO_INVALID_ELEMENT';
76
+ throw error;
77
+ }
78
+
79
+ if (Array.isArray(node[key])) {
80
+ for (let entry of node[key]) {
81
+ walkElm(entry, key, path + '.' + key + '[]');
82
+ }
83
+ } else if (node[key] && typeof node[key] === 'object') {
84
+ walkElm(node[key], key, path + '.' + key);
85
+ }
86
+ }
87
+ }
88
+ };
89
+
90
+ walkElm(logoObj, 'root', '');
91
+
92
+ // all validations passed
93
+ return true;
94
+ }
95
+
96
+ module.exports = { validateSvg };
@@ -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/mailauth.js CHANGED
@@ -5,6 +5,7 @@ const { spf } = require('./spf');
5
5
  const { dmarc } = require('./dmarc');
6
6
  const { arc, createSeal } = require('./arc');
7
7
  const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
8
+ const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
8
9
  const { parseReceived } = require('./parse-received');
9
10
  const { sealMessage } = require('./arc');
10
11
  const libmime = require('libmime');
@@ -180,4 +181,9 @@ const authenticate = async (input, opts) => {
180
181
  };
181
182
  };
182
183
 
183
- module.exports = { authenticate, sealMessage, validateBimiVmc };
184
+ module.exports = {
185
+ authenticate,
186
+ sealMessage,
187
+ validateBimiVmc,
188
+ validateBimiSvg
189
+ };
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
- name license type link installed version author
2
- ---- ------------ ---- ----------------- ------
3
- @fidm/x509 MIT git+ssh://git@github.com/fidm/x509.git 1.2.1
4
- @postalsys/vmc MIT https://registry.npmjs.org/@postalsys/vmc/-/vmc-1.0.1.tgz 1.0.1 Postal Systems
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
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
- 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/
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
1
+ name license type link installed version author
2
+ ---- ------------ ---- ----------------- ------
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
+ 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
+ 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/)
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 n/a
package/man/mailauth.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH "MAILAUTH" "1" "July 2022" "v3.0.0" "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.1",
3
+ "version": "4.0.0",
4
4
  "description": "Email authentication library for Node.js",
5
5
  "main": "lib/mailauth.js",
6
6
  "scripts": {
@@ -33,19 +33,20 @@
33
33
  "homepage": "https://github.com/postalsys/mailauth",
34
34
  "devDependencies": {
35
35
  "chai": "4.3.6",
36
- "eslint": "8.19.0",
36
+ "eslint": "8.20.0",
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",
44
44
  "mocha": "10.0.0",
45
- "pkg": "5.7.0"
45
+ "pkg": "5.8.0"
46
46
  },
47
47
  "dependencies": {
48
- "@postalsys/vmc": "1.0.3",
48
+ "@postalsys/vmc": "1.0.5",
49
+ "fast-xml-parser": "4.0.9",
49
50
  "ipaddr.js": "2.0.1",
50
51
  "joi": "17.6.0",
51
52
  "libmime": "5.1.0",