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 +4 -4
- package/bin/mailauth.js +14 -2
- package/cli.md +45 -20
- package/lib/bimi/index.js +34 -0
- package/lib/bimi/validate-svg.js +96 -0
- package/lib/commands/report.js +4 -0
- package/lib/commands/spf.js +4 -0
- package/lib/dkim/body/relaxed.js +0 -24
- package/lib/mailauth.js +7 -1
- package/lib/spf/index.js +52 -20
- package/lib/spf/spf-verify.js +87 -9
- package/lib/tools.js +18 -1
- package/licenses.txt +12 -12
- package/man/mailauth.1 +1 -1
- package/man/man.md +4 -1
- package/package.json +6 -5
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
"
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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 };
|
package/lib/commands/report.js
CHANGED
package/lib/commands/spf.js
CHANGED
package/lib/dkim/body/relaxed.js
CHANGED
|
@@ -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 = {
|
|
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 =
|
|
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
|
-
|
|
25
|
+
let voidCount = 0;
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// special condition to get the counter
|
|
29
|
-
return resolveCount;
|
|
30
|
-
}
|
|
27
|
+
let subResolveCounts = {};
|
|
28
|
+
let firstCounted = !ignoreFirst;
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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=
|
|
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:
|
|
165
|
-
count:
|
|
194
|
+
limit: verifyResolver.getResolveLimit(),
|
|
195
|
+
count: verifyResolver.getResolveCount(),
|
|
196
|
+
void: verifyResolver.getVoidCount(),
|
|
197
|
+
subqueries: verifyResolver.getSubResolveCounts()
|
|
166
198
|
};
|
|
167
199
|
}
|
|
168
200
|
|
package/lib/spf/spf-verify.js
CHANGED
|
@@ -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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
----
|
|
3
|
-
@
|
|
4
|
-
|
|
5
|
-
ipaddr.js
|
|
6
|
-
joi
|
|
7
|
-
libmime
|
|
8
|
-
node-forge
|
|
9
|
-
nodemailer
|
|
10
|
-
psl
|
|
11
|
-
punycode
|
|
12
|
-
yargs
|
|
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
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
|
|
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
|
+
"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.
|
|
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": "
|
|
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.
|
|
45
|
+
"pkg": "5.8.0"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@postalsys/vmc": "1.0.
|
|
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",
|