mailauth 5.0.3 → 7.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +117 -41
- package/bin/mailauth.js +14 -6
- package/cli.md +8 -3
- package/docs/arc.md +126 -43
- package/docs/bimi.md +54 -37
- package/docs/dkim.md +79 -40
- package/docs/dmarc.md +82 -31
- package/docs/mta-sts.md +140 -42
- package/docs/spf.md +69 -4
- package/index.d.ts +499 -45
- package/lib/arc/index.d.ts +10 -8
- package/lib/arc/index.js +505 -124
- package/lib/bimi/index.js +162 -81
- package/lib/commands/report.js +2 -1
- package/lib/commands/seal.js +35 -5
- package/lib/commands/sign.js +8 -1
- package/lib/commands/spf.js +8 -0
- package/lib/dkim/body/relaxed.js +6 -5
- package/lib/dkim/body/simple.js +1 -3
- package/lib/dkim/dkim-signer.js +219 -33
- package/lib/dkim/dkim-verifier.js +574 -231
- package/lib/dkim/header/relaxed.js +6 -43
- package/lib/dkim/header/signature-opts.js +87 -0
- package/lib/dkim/header/simple.js +6 -43
- package/lib/dkim/message-parser.js +18 -4
- package/lib/dkim/sign.d.ts +13 -7
- package/lib/dkim/sign.js +18 -2
- package/lib/dkim/syntax.js +31 -0
- package/lib/dkim/verify.js +1 -0
- package/lib/dmarc/author-domain.js +181 -0
- package/lib/dmarc/get-dmarc-record.js +267 -53
- package/lib/dmarc/index.d.ts +29 -2
- package/lib/dmarc/index.js +2 -1
- package/lib/dmarc/verify.js +205 -53
- package/lib/mailauth.js +86 -40
- package/lib/mta-sts.d.ts +77 -11
- package/lib/mta-sts.js +497 -182
- package/lib/parse-dkim-headers.js +376 -191
- package/lib/parse-received.js +104 -32
- package/lib/spf/index.d.ts +37 -1
- package/lib/spf/index.js +260 -28
- package/lib/spf/macro.js +50 -13
- package/lib/spf/spf-verify.js +440 -204
- package/lib/spf/syntax.js +366 -0
- package/lib/tools.js +524 -118
- package/package.json +12 -12
package/lib/mta-sts.js
CHANGED
|
@@ -1,146 +1,324 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
|
-
const punycode = require('punycode.js');
|
|
5
4
|
const dns = require('node:dns');
|
|
6
|
-
const parseDkimHeaders = require('./parse-dkim-headers');
|
|
7
5
|
const https = require('node:https');
|
|
8
|
-
|
|
9
|
-
const
|
|
6
|
+
const tls = require('node:tls');
|
|
7
|
+
const { X509Certificate } = require('node:crypto');
|
|
8
|
+
const { toALabel, createError } = require('./tools');
|
|
9
|
+
|
|
10
|
+
// socket idle timeout for the policy request
|
|
11
|
+
const HTTP_IDLE_TIMEOUT = 15 * 1000;
|
|
12
|
+
// overall time limit for the policy request, RFC 8461 3.3 suggests one minute
|
|
13
|
+
const HTTP_REQUEST_TIMEOUT = 60 * 1000;
|
|
14
|
+
// maximum accepted policy body size, RFC 8461 3.3 suggests 64 kilobytes
|
|
15
|
+
const MAX_POLICY_SIZE = 64 * 1024;
|
|
16
|
+
// how long to wait before retrying a failed policy fetch when there is no cached policy to fall back to
|
|
17
|
+
const ERROR_RETRY_DELAY = 1 * 3600 * 1000;
|
|
18
|
+
// maximum allowed max_age value
|
|
19
|
+
const MAX_AGE_LIMIT = 31557600;
|
|
20
|
+
|
|
21
|
+
// RFC 8461 3.1 sts-ext-name and 3.2 sts-policy-ext-name share one grammar:
|
|
22
|
+
// (ALPHA / DIGIT) 0*31(ALPHA / DIGIT / "_" / "-" / ".")
|
|
23
|
+
const EXT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$/;
|
|
24
|
+
|
|
25
|
+
// RFC 8461 3.1 TXT record syntax
|
|
26
|
+
const TXT_RECORD_PREFIX = /^v=STSv1[ \t]*;/;
|
|
27
|
+
const TXT_RECORD_PREFIX_LAX = /^v=STSv1[ \t]*;/i;
|
|
28
|
+
const TXT_ID = /^[A-Za-z0-9]{1,32}$/;
|
|
29
|
+
const TXT_EXT_VALUE = /^[\x21-\x3a\x3c\x3e-\x7e]+$/;
|
|
30
|
+
|
|
31
|
+
// RFC 8461 3.2 policy file syntax
|
|
32
|
+
const POLICY_MAX_AGE = /^[0-9]{1,10}$/;
|
|
33
|
+
// ["*."] Domain, where Domain is defined in RFC 5321 4.1.2
|
|
34
|
+
const POLICY_MX_PATTERN = /^(?:\*\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/i;
|
|
10
35
|
|
|
11
36
|
/**
|
|
12
|
-
*
|
|
37
|
+
* Normalizes a domain name or an email address into a lowercase A-label domain name without a trailing dot
|
|
13
38
|
* @param {String} address Either email address or a domain name
|
|
14
|
-
* @
|
|
15
|
-
* @param {Function} [opts.resolver] Optional async DNS resolver function
|
|
16
|
-
* @returns {String|Boolean} Either string ID or false if policy was not defined in DNS
|
|
39
|
+
* @returns {String} domain name
|
|
17
40
|
*/
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
let
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
const normalizeDomain = address => {
|
|
42
|
+
let domain = (address || '').toString().trim();
|
|
43
|
+
let atPos = domain.lastIndexOf('@');
|
|
44
|
+
if (atPos >= 0) {
|
|
45
|
+
domain = domain.substr(atPos + 1);
|
|
46
|
+
}
|
|
47
|
+
domain = domain.trim().toLowerCase().replace(/\.$/, '');
|
|
48
|
+
return toALabel(domain);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parses a MTA-STS TXT record according to the RFC 8461 3.1 ABNF
|
|
53
|
+
* @param {String} record TXT record value (strings already concatenated)
|
|
54
|
+
* @returns {String|Boolean} policy ID or false if the record is not syntactically valid
|
|
55
|
+
*/
|
|
56
|
+
const parseTxtRecordStrict = record => {
|
|
57
|
+
let parts = record.split(';');
|
|
58
|
+
if (parts.length < 2 || !/^v=STSv1[ \t]*$/.test(parts[0])) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let fields = [];
|
|
63
|
+
for (let i = 1; i < parts.length; i++) {
|
|
64
|
+
let isLast = i === parts.length - 1;
|
|
65
|
+
// WSP around a delimiter belongs to the delimiter
|
|
66
|
+
let field = parts[i].replace(/^[ \t]+/, '');
|
|
67
|
+
if (!isLast) {
|
|
68
|
+
field = field.replace(/[ \t]+$/, '');
|
|
69
|
+
} else if (!field) {
|
|
70
|
+
// optional trailing delimiter
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!field) {
|
|
74
|
+
// empty field between two delimiters
|
|
75
|
+
return false;
|
|
32
76
|
}
|
|
77
|
+
fields.push(field);
|
|
33
78
|
}
|
|
34
79
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
err.code = 'multi_sts_records';
|
|
42
|
-
throw err;
|
|
80
|
+
// if a field is duplicated, then the first entry wins and the others only need to be syntactically valid
|
|
81
|
+
let id;
|
|
82
|
+
for (let field of fields) {
|
|
83
|
+
let eqPos = field.indexOf('=');
|
|
84
|
+
if (eqPos < 1) {
|
|
85
|
+
return false;
|
|
43
86
|
}
|
|
44
|
-
|
|
45
|
-
|
|
87
|
+
let name = field.substr(0, eqPos);
|
|
88
|
+
let value = field.substr(eqPos + 1);
|
|
89
|
+
if (name === 'id' && typeof id !== 'string') {
|
|
90
|
+
if (!TXT_ID.test(value)) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
id = value;
|
|
94
|
+
} else if (!EXT_NAME.test(name) || !TXT_EXT_VALUE.test(value)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return typeof id === 'string' ? id : false;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parses a MTA-STS TXT record leniently: the version and field names are case-insensitive,
|
|
104
|
+
* surrounding whitespace is ignored and any non-empty id value is accepted.
|
|
105
|
+
* @param {String} record TXT record value (strings already concatenated)
|
|
106
|
+
* @returns {String|Boolean} policy ID or false if the record does not contain an id
|
|
107
|
+
*/
|
|
108
|
+
const parseTxtRecordLax = record => {
|
|
109
|
+
let parts = record.trim().split(';');
|
|
110
|
+
if (parts.length < 2 || !/^v=STSv1$/i.test(parts[0].trim())) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (let i = 1; i < parts.length; i++) {
|
|
115
|
+
let field = parts[i].trim();
|
|
116
|
+
let eqPos = field.indexOf('=');
|
|
117
|
+
if (eqPos < 1) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
let name = field.substr(0, eqPos).trim().toLowerCase();
|
|
121
|
+
let value = field.substr(eqPos + 1).trim();
|
|
122
|
+
if (name === 'id' && value) {
|
|
123
|
+
// if a field is duplicated, then the first entry wins
|
|
124
|
+
return value;
|
|
46
125
|
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return false;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolves the MTA-STS policy ID for a domain
|
|
133
|
+
* @param {String} domain Normalized domain name
|
|
134
|
+
* @param {Object} opts
|
|
135
|
+
* @param {Array} warnings Lax acceptance markers are added here
|
|
136
|
+
* @returns {Object} `{ id }` or `{ id: false, reason }`
|
|
137
|
+
*/
|
|
138
|
+
const discoverPolicyId = async (domain, opts, warnings) => {
|
|
139
|
+
const strict = !!opts.strict;
|
|
140
|
+
const resolver = opts.resolver || dns.promises.resolve;
|
|
141
|
+
|
|
142
|
+
let txt;
|
|
143
|
+
try {
|
|
144
|
+
txt = await resolver(`_mta-sts.${domain}`, 'TXT');
|
|
47
145
|
} catch (err) {
|
|
48
146
|
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
|
|
49
|
-
return false;
|
|
147
|
+
return { id: false, reason: 'sts_record_not_found' };
|
|
50
148
|
}
|
|
51
149
|
throw err;
|
|
52
150
|
}
|
|
53
|
-
|
|
54
|
-
|
|
151
|
+
|
|
152
|
+
// records that do not begin with "v=STSv1;" are discarded before checking for multiple records
|
|
153
|
+
let records = (txt || [])
|
|
154
|
+
.map(row => (Array.isArray(row) ? row.join('') : (row || '').toString()))
|
|
155
|
+
.filter(record => (strict ? TXT_RECORD_PREFIX.test(record) : TXT_RECORD_PREFIX_LAX.test(record.trim())));
|
|
156
|
+
|
|
157
|
+
if (records.length > 1) {
|
|
158
|
+
throw createError('Multiple MTA-STS TXT records found', 'multi_sts_records');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!records.length) {
|
|
162
|
+
return { id: false, reason: 'sts_record_not_found' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let strictId = parseTxtRecordStrict(records[0]);
|
|
166
|
+
if (strict) {
|
|
167
|
+
return strictId ? { id: strictId } : { id: false, reason: 'invalid_sts_record' };
|
|
55
168
|
}
|
|
56
169
|
|
|
57
|
-
|
|
170
|
+
let laxId = parseTxtRecordLax(records[0]);
|
|
171
|
+
if (!laxId) {
|
|
172
|
+
return { id: false, reason: 'invalid_sts_record' };
|
|
173
|
+
}
|
|
174
|
+
if (laxId !== strictId) {
|
|
175
|
+
warnings.push('txt-syntax');
|
|
176
|
+
}
|
|
177
|
+
return { id: laxId };
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve MTA-STS policy ID
|
|
182
|
+
* @param {String} address Either email address or a domain name
|
|
183
|
+
* @param {Object} opts
|
|
184
|
+
* @param {Function} [opts.resolver] Optional async DNS resolver function
|
|
185
|
+
* @param {Boolean} [opts.strict=false] If true, then the TXT record must match the RFC 8461 syntax exactly
|
|
186
|
+
* @returns {String|Boolean} Either string ID or false if policy was not defined in DNS
|
|
187
|
+
*/
|
|
188
|
+
const resolvePolicy = async (address, opts) => {
|
|
189
|
+
opts = opts || {};
|
|
190
|
+
let { id } = await discoverPolicyId(normalizeDomain(address), opts, []);
|
|
191
|
+
return id;
|
|
58
192
|
};
|
|
59
193
|
|
|
60
194
|
/**
|
|
61
195
|
* Parses a MTA-STS policy file
|
|
62
196
|
* @param {Buffer|String} file MTA-STS policy
|
|
197
|
+
* @param {Boolean} strict If true, then field names, mode and max_age values must match the RFC 8461 syntax exactly
|
|
198
|
+
* @param {Array} warnings Lax acceptance markers are added here
|
|
63
199
|
* @returns {Object} parsed policy
|
|
64
200
|
*/
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
201
|
+
const parsePolicyFile = (file, strict, warnings) => {
|
|
202
|
+
// "mode" is listed first to keep the property order of the result object stable
|
|
203
|
+
let policy = { mode: undefined };
|
|
204
|
+
let seen = new Set();
|
|
205
|
+
let laxSyntax = false;
|
|
206
|
+
let invalidMx = false;
|
|
207
|
+
|
|
208
|
+
for (let line of (file || '').toString().split(/\r?\n/)) {
|
|
209
|
+
let colonPos = line.indexOf(':');
|
|
210
|
+
if (colonPos < 0) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
70
213
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
214
|
+
let key, value;
|
|
215
|
+
if (strict) {
|
|
216
|
+
key = line.substr(0, colonPos);
|
|
217
|
+
if (!EXT_NAME.test(key)) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
value = line.substr(colonPos + 1).replace(/^[ \t]+|[ \t]+$/g, '');
|
|
221
|
+
} else {
|
|
222
|
+
key = line.substr(0, colonPos).toLowerCase().trim();
|
|
223
|
+
value = line.substr(colonPos + 1).trim();
|
|
224
|
+
if (['version', 'mode', 'max_age', 'mx'].includes(key) && key !== line.substr(0, colonPos)) {
|
|
225
|
+
laxSyntax = true;
|
|
78
226
|
}
|
|
227
|
+
}
|
|
79
228
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
229
|
+
if (key !== 'mx') {
|
|
230
|
+
if (seen.has(key)) {
|
|
231
|
+
// if a non-repeated field is duplicated, then all entries except for the first are ignored
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
seen.add(key);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
switch (key) {
|
|
238
|
+
case 'version':
|
|
239
|
+
policy.version = value;
|
|
240
|
+
break;
|
|
241
|
+
case 'mode':
|
|
242
|
+
policy.mode = strict ? value : value.toLowerCase();
|
|
243
|
+
if (policy.mode !== value) {
|
|
244
|
+
laxSyntax = true;
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
case 'max_age':
|
|
248
|
+
if (POLICY_MAX_AGE.test(value)) {
|
|
249
|
+
policy.maxAge = Number(value);
|
|
250
|
+
} else if (strict) {
|
|
251
|
+
policy.maxAge = NaN;
|
|
252
|
+
} else {
|
|
253
|
+
policy.maxAge = Number(value);
|
|
254
|
+
laxSyntax = true;
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
case 'mx': {
|
|
258
|
+
if (!policy.mx) {
|
|
259
|
+
policy.mx = [];
|
|
260
|
+
}
|
|
261
|
+
if (!POLICY_MX_PATTERN.test(value)) {
|
|
262
|
+
if (strict) {
|
|
263
|
+
invalidMx = true;
|
|
264
|
+
} else {
|
|
265
|
+
laxSyntax = true;
|
|
104
266
|
}
|
|
105
|
-
break;
|
|
106
267
|
}
|
|
268
|
+
let mx = value.toLowerCase();
|
|
269
|
+
if (!policy.mx.includes(mx)) {
|
|
270
|
+
policy.mx.push(mx);
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
107
273
|
}
|
|
108
|
-
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
109
276
|
|
|
110
277
|
if (!/^STSv1$/.test(policy.version)) {
|
|
111
|
-
|
|
112
|
-
err.code = 'invalid_sts_version';
|
|
113
|
-
throw err;
|
|
278
|
+
throw createError('Invalid version field', 'invalid_sts_version');
|
|
114
279
|
}
|
|
115
280
|
|
|
116
281
|
if (!['testing', 'enforce', 'none'].includes(policy.mode)) {
|
|
117
|
-
|
|
118
|
-
err.code = 'invalid_sts_mode';
|
|
119
|
-
throw err;
|
|
282
|
+
throw createError(policy.mode === undefined ? 'Missing mode field' : 'Invalid mode field', 'invalid_sts_mode');
|
|
120
283
|
}
|
|
121
284
|
|
|
122
|
-
if (isNaN(policy.maxAge) || policy.maxAge < 0 || policy.maxAge >
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
285
|
+
if (typeof policy.maxAge !== 'number' || isNaN(policy.maxAge) || policy.maxAge < 0 || policy.maxAge > MAX_AGE_LIMIT) {
|
|
286
|
+
throw createError('Invalid max_age field', 'invalid_sts_max_age');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (invalidMx) {
|
|
290
|
+
throw createError('Invalid mx field', 'invalid_sts_mx');
|
|
126
291
|
}
|
|
127
292
|
|
|
128
293
|
if (policy.mode !== 'none' && (!policy.mx || !policy.mx.length)) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
294
|
+
throw createError('Missing mx field', 'invalid_sts_mx');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (laxSyntax && warnings) {
|
|
298
|
+
warnings.push('policy-syntax');
|
|
132
299
|
}
|
|
133
300
|
|
|
134
301
|
return policy;
|
|
135
302
|
};
|
|
136
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Parses a MTA-STS policy file
|
|
306
|
+
* @param {Buffer|String} file MTA-STS policy
|
|
307
|
+
* @param {Object} [opts]
|
|
308
|
+
* @param {Boolean} [opts.strict=false] If true, then the policy must match the RFC 8461 syntax exactly
|
|
309
|
+
* @returns {Object} parsed policy
|
|
310
|
+
*/
|
|
311
|
+
const parsePolicy = (file, opts) => parsePolicyFile(file, !!opts?.strict, null);
|
|
312
|
+
|
|
137
313
|
/**
|
|
138
314
|
* Validate mx hostname against MTA-STS policy
|
|
139
315
|
* @param {String} mx MX hostname
|
|
140
316
|
* @param {Object} policy Policy structure from `parsePolicy`
|
|
141
|
-
* @
|
|
317
|
+
* @param {Object} [_opts]
|
|
318
|
+
* @param {Boolean} [_opts.strict=false] Accepted for API consistency, matching rules are the same in both modes
|
|
319
|
+
* @returns {Object} validation result
|
|
142
320
|
*/
|
|
143
|
-
const validateMx = (mx, policy) => {
|
|
321
|
+
const validateMx = (mx, policy, _opts) => {
|
|
144
322
|
policy = policy || { mode: 'none' };
|
|
145
323
|
if (policy.mode === 'none' || !policy.mode) {
|
|
146
324
|
// nothing to check for
|
|
@@ -151,34 +329,32 @@ const validateMx = (mx, policy) => {
|
|
|
151
329
|
};
|
|
152
330
|
}
|
|
153
331
|
|
|
154
|
-
mx = (mx || '').toString().trim().toLowerCase();
|
|
332
|
+
mx = toALabel((mx || '').toString().trim().toLowerCase().replace(/\.$/, ''));
|
|
155
333
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
mx = punycode.toASCII(mx);
|
|
160
|
-
} catch (err) {
|
|
161
|
-
// ignore
|
|
162
|
-
}
|
|
163
|
-
}
|
|
334
|
+
// a hostname never contains a wildcard character
|
|
335
|
+
let patterns = mx && !mx.includes('*') && Array.isArray(policy.mx) ? policy.mx : [];
|
|
164
336
|
|
|
165
|
-
for (let allowed of
|
|
166
|
-
allowed = (allowed || '').toString().trim().toLowerCase();
|
|
337
|
+
for (let allowed of patterns) {
|
|
338
|
+
allowed = (allowed || '').toString().trim().toLowerCase().replace(/\.$/, '');
|
|
339
|
+
if (!allowed) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
167
342
|
if (/^\*\./.test(allowed)) {
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
343
|
+
// the wildcard matches exactly one complete left-most label
|
|
344
|
+
let suffix = allowed.substr(1);
|
|
345
|
+
let label = mx.substr(0, mx.length - suffix.length);
|
|
346
|
+
if (mx.length > suffix.length && mx.substr(-suffix.length) === suffix && !label.includes('.')) {
|
|
171
347
|
return {
|
|
172
348
|
valid: true,
|
|
173
|
-
mode: policy.mode
|
|
174
|
-
match:
|
|
349
|
+
mode: policy.mode,
|
|
350
|
+
match: suffix,
|
|
175
351
|
testing: policy.mode === 'testing'
|
|
176
352
|
};
|
|
177
353
|
}
|
|
178
354
|
} else if (allowed === mx) {
|
|
179
355
|
return {
|
|
180
356
|
valid: true,
|
|
181
|
-
mode: policy.mode
|
|
357
|
+
mode: policy.mode,
|
|
182
358
|
match: allowed,
|
|
183
359
|
testing: policy.mode === 'testing'
|
|
184
360
|
};
|
|
@@ -188,32 +364,47 @@ const validateMx = (mx, policy) => {
|
|
|
188
364
|
// no match found
|
|
189
365
|
return {
|
|
190
366
|
valid: false,
|
|
191
|
-
mode: policy.mode
|
|
367
|
+
mode: policy.mode,
|
|
192
368
|
testing: policy.mode === 'testing'
|
|
193
369
|
};
|
|
194
370
|
};
|
|
195
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Checks the Policy Host certificate identity the way RFC 8461 3.3 requires: only DNS-ID
|
|
374
|
+
* subjectAltName entries are used (no CN fallback) and a wildcard may only be the complete left-most label.
|
|
375
|
+
* @param {String} hostname Policy Host name
|
|
376
|
+
* @param {Object} cert Peer certificate object
|
|
377
|
+
* @returns {Boolean} true if the certificate is valid for the hostname
|
|
378
|
+
*/
|
|
379
|
+
const checkPolicyHostIdentity = (hostname, cert) => {
|
|
380
|
+
try {
|
|
381
|
+
let x509 = new X509Certificate(cert.raw);
|
|
382
|
+
return !!x509.checkHost(hostname, {
|
|
383
|
+
subject: 'never',
|
|
384
|
+
wildcards: true,
|
|
385
|
+
partialWildcards: false,
|
|
386
|
+
multiLabelWildcards: false,
|
|
387
|
+
singleLabelSubdomains: false
|
|
388
|
+
});
|
|
389
|
+
} catch (err) {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
196
394
|
/**
|
|
197
395
|
* Fetches and parses MTA-STS policy file for a domain
|
|
198
396
|
* @param {String} domain
|
|
199
397
|
* @param {Object} opts
|
|
200
|
-
* @param {
|
|
201
|
-
* @returns {Object|Boolean} false if policy
|
|
398
|
+
* @param {Array} warnings Lax acceptance markers are added here
|
|
399
|
+
* @returns {Object|Boolean} false if the policy host has no address or structured policy
|
|
202
400
|
*/
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
401
|
+
const fetchPolicyFile = async (domain, opts, warnings) => {
|
|
402
|
+
const strict = !!opts.strict;
|
|
403
|
+
const resolver = opts.resolver || dns.promises.resolve;
|
|
404
|
+
const timeout = typeof opts.timeout === 'number' && opts.timeout > 0 ? opts.timeout : HTTP_REQUEST_TIMEOUT;
|
|
405
|
+
const maxPolicySize = typeof opts.maxPolicySize === 'number' && opts.maxPolicySize > 0 ? opts.maxPolicySize : MAX_POLICY_SIZE;
|
|
207
406
|
|
|
208
|
-
domain = (domain
|
|
209
|
-
if (/[\x7e-\xff]/.test(domain)) {
|
|
210
|
-
// high bytes, probably U-label
|
|
211
|
-
try {
|
|
212
|
-
domain = punycode.toASCII(domain);
|
|
213
|
-
} catch (err) {
|
|
214
|
-
// ignore
|
|
215
|
-
}
|
|
216
|
-
}
|
|
407
|
+
domain = normalizeDomain(domain);
|
|
217
408
|
|
|
218
409
|
const servername = `mta-sts.${domain}`;
|
|
219
410
|
const path = `/.well-known/mta-sts.txt`;
|
|
@@ -240,6 +431,8 @@ const fetchPolicy = async (domain, opts) => {
|
|
|
240
431
|
return false;
|
|
241
432
|
}
|
|
242
433
|
|
|
434
|
+
let fetchWarnings = [];
|
|
435
|
+
|
|
243
436
|
const options = {
|
|
244
437
|
protocol: 'https:',
|
|
245
438
|
host: addr[0],
|
|
@@ -251,114 +444,236 @@ const fetchPolicy = async (domain, opts) => {
|
|
|
251
444
|
path,
|
|
252
445
|
method: 'GET',
|
|
253
446
|
rejectUnauthorized: true,
|
|
254
|
-
|
|
255
|
-
|
|
447
|
+
checkServerIdentity: (hostname, cert) => {
|
|
448
|
+
let err = tls.checkServerIdentity(hostname, cert);
|
|
449
|
+
if (err) {
|
|
450
|
+
return err;
|
|
451
|
+
}
|
|
452
|
+
if (!checkPolicyHostIdentity(hostname, cert)) {
|
|
453
|
+
if (strict) {
|
|
454
|
+
err = createError(
|
|
455
|
+
`Certificate is not valid for ${hostname}: a DNS-ID subjectAltName must match and a wildcard must be the complete left-most label`,
|
|
456
|
+
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
457
|
+
{ host: hostname, cert }
|
|
458
|
+
);
|
|
459
|
+
return err;
|
|
460
|
+
}
|
|
461
|
+
fetchWarnings.push('cert-identity');
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
// use a fresh connection, so that a connection verified with other options is never reused
|
|
465
|
+
agent: false,
|
|
466
|
+
timeout: Math.min(HTTP_IDLE_TIMEOUT, timeout)
|
|
256
467
|
};
|
|
257
468
|
|
|
258
469
|
let data = await new Promise((resolve, reject) => {
|
|
259
|
-
|
|
470
|
+
let finished = false;
|
|
471
|
+
let req;
|
|
472
|
+
let timer;
|
|
473
|
+
|
|
474
|
+
const done = (err, data) => {
|
|
475
|
+
if (finished) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
finished = true;
|
|
479
|
+
clearTimeout(timer);
|
|
480
|
+
if (err) {
|
|
481
|
+
if (req) {
|
|
482
|
+
req.destroy();
|
|
483
|
+
}
|
|
484
|
+
return reject(err);
|
|
485
|
+
}
|
|
486
|
+
resolve(data);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const tooLarge = () => createError(`Policy file is larger than ${maxPolicySize} bytes`, 'policy_too_large');
|
|
490
|
+
|
|
491
|
+
const handleResponse = res => {
|
|
492
|
+
let statusCode = res.statusCode;
|
|
493
|
+
if (statusCode !== 200) {
|
|
494
|
+
if (strict || !statusCode || statusCode < 200 || statusCode >= 300) {
|
|
495
|
+
// only HTTP 200 is a valid policy response, redirects are not followed
|
|
496
|
+
return done(createError(`Invalid response code ${statusCode || '-'}`, 'http_status_' + (statusCode || 'na')));
|
|
497
|
+
}
|
|
498
|
+
fetchWarnings.push('http-status');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
let mediaType = (res.headers['content-type'] || '').split(';').shift().trim().toLowerCase();
|
|
502
|
+
if (mediaType !== 'text/plain') {
|
|
503
|
+
if (strict) {
|
|
504
|
+
return done(createError(`Invalid Content-Type ${JSON.stringify(res.headers['content-type'] || '')}`, 'invalid_content_type'));
|
|
505
|
+
}
|
|
506
|
+
fetchWarnings.push('content-type');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (Number(res.headers['content-length']) > maxPolicySize) {
|
|
510
|
+
return done(tooLarge());
|
|
511
|
+
}
|
|
512
|
+
|
|
260
513
|
let chunks = [],
|
|
261
514
|
chunklen = 0;
|
|
262
|
-
res.on('
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
515
|
+
res.on('data', chunk => {
|
|
516
|
+
if (finished) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
chunks.push(chunk);
|
|
520
|
+
chunklen += chunk.length;
|
|
521
|
+
if (chunklen > maxPolicySize) {
|
|
522
|
+
done(tooLarge());
|
|
267
523
|
}
|
|
268
524
|
});
|
|
269
|
-
res.on('end', () =>
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return reject(err);
|
|
525
|
+
res.on('end', () => done(null, Buffer.concat(chunks, chunklen)));
|
|
526
|
+
res.on('error', err => done(err));
|
|
527
|
+
res.on('close', () => {
|
|
528
|
+
if (!res.complete) {
|
|
529
|
+
done(createError(`Incomplete response for https://${servername}${path}`, 'http_incomplete_response'));
|
|
275
530
|
}
|
|
276
|
-
resolve(data);
|
|
277
531
|
});
|
|
278
|
-
|
|
279
|
-
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
req = https.request(options, handleResponse);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
return done(err);
|
|
538
|
+
}
|
|
280
539
|
|
|
281
540
|
req.on('timeout', () => {
|
|
282
|
-
|
|
283
|
-
let error = new Error(`Request timeout for https://${servername}${path}`);
|
|
284
|
-
error.code = 'HTTP_SOCKET_TIMEOUT';
|
|
285
|
-
reject(error);
|
|
541
|
+
done(createError(`Request timeout for https://${servername}${path}`, 'HTTP_SOCKET_TIMEOUT'));
|
|
286
542
|
});
|
|
543
|
+
req.on('error', err => done(err));
|
|
544
|
+
|
|
545
|
+
// overall time limit, the socket timeout only covers idle periods
|
|
546
|
+
timer = setTimeout(() => {
|
|
547
|
+
done(createError(`Request timeout for https://${servername}${path}`, 'HTTP_REQUEST_TIMEOUT'));
|
|
548
|
+
}, timeout);
|
|
287
549
|
|
|
288
|
-
req.on('error', err => {
|
|
289
|
-
reject(err);
|
|
290
|
-
});
|
|
291
550
|
req.end();
|
|
292
551
|
});
|
|
293
552
|
|
|
294
|
-
|
|
553
|
+
let policy = parsePolicyFile(data, strict, fetchWarnings);
|
|
554
|
+
warnings.push(...fetchWarnings);
|
|
555
|
+
return policy;
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Fetches and parses MTA-STS policy file for a domain
|
|
560
|
+
* @param {String} domain
|
|
561
|
+
* @param {Object} opts
|
|
562
|
+
* @param {Function} [opts.resolver] Optional async DNS resolver function
|
|
563
|
+
* @param {Boolean} [opts.strict=false] If true, then apply the RFC 8461 rules exactly
|
|
564
|
+
* @param {Number} [opts.timeout=60000] Overall time limit for the HTTPS request in milliseconds
|
|
565
|
+
* @param {Number} [opts.maxPolicySize=65536] Maximum size of the policy file in bytes
|
|
566
|
+
* @returns {Object|Boolean} false if policy file was not found or structured policy
|
|
567
|
+
*/
|
|
568
|
+
const fetchPolicy = async (domain, opts) => fetchPolicyFile(domain, opts || {}, []);
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Checks if a cached policy exists and has not expired yet
|
|
572
|
+
* @param {Object} knownPolicy
|
|
573
|
+
* @returns {Boolean}
|
|
574
|
+
*/
|
|
575
|
+
const isPolicyValid = knownPolicy => {
|
|
576
|
+
if (!knownPolicy || typeof knownPolicy !== 'object' || !knownPolicy.id || !knownPolicy.expires) {
|
|
295
577
|
return false;
|
|
296
578
|
}
|
|
579
|
+
let expires = new Date(knownPolicy.expires).getTime();
|
|
580
|
+
return !isNaN(expires) && expires > Date.now();
|
|
581
|
+
};
|
|
297
582
|
|
|
298
|
-
|
|
583
|
+
const formatResult = (policy, status, warnings) => {
|
|
584
|
+
let result = { policy, status };
|
|
585
|
+
if (warnings.length) {
|
|
586
|
+
result.warnings = Array.from(new Set(warnings));
|
|
587
|
+
}
|
|
588
|
+
return result;
|
|
299
589
|
};
|
|
300
590
|
|
|
301
591
|
/**
|
|
302
592
|
* Resolves and fetches MTA-STS policy for a domain name
|
|
303
593
|
* @param {String} domain Domain name to fetch the policy for
|
|
304
|
-
* @param {Object} [knownPolicy]
|
|
594
|
+
* @param {Object} [knownPolicy] currently known MTA-STS policy
|
|
305
595
|
* @param {Object} [opts]
|
|
306
596
|
* @param {Function} [opts.resolver] Optional async DNS resolver function
|
|
307
|
-
* @
|
|
597
|
+
* @param {Boolean} [opts.strict=false] If true, then apply the RFC 8461 rules exactly
|
|
598
|
+
* @param {Number} [opts.timeout=60000] Overall time limit for the HTTPS request in milliseconds
|
|
599
|
+
* @param {Number} [opts.maxPolicySize=65536] Maximum size of the policy file in bytes
|
|
600
|
+
* @returns {Object} Policy information
|
|
308
601
|
*/
|
|
309
602
|
const getPolicy = async (domain, knownPolicy, opts) => {
|
|
310
|
-
|
|
603
|
+
opts = opts || {};
|
|
604
|
+
const strict = !!opts.strict;
|
|
605
|
+
const warnings = [];
|
|
606
|
+
|
|
607
|
+
domain = normalizeDomain(domain);
|
|
608
|
+
|
|
609
|
+
// RFC 8461 3.3, 5.1: a valid (non-expired) cached policy must be applied if no live policy can be discovered or fetched
|
|
610
|
+
const cacheValid = isPolicyValid(knownPolicy);
|
|
611
|
+
const useCached = err => formatResult(Object.assign({}, knownPolicy, { error: err }), 'errored', warnings);
|
|
612
|
+
|
|
613
|
+
let discovery;
|
|
311
614
|
try {
|
|
312
|
-
|
|
615
|
+
discovery = await discoverPolicyId(domain, opts, warnings);
|
|
313
616
|
} catch (err) {
|
|
314
|
-
|
|
617
|
+
if (cacheValid) {
|
|
618
|
+
return useCached(err);
|
|
619
|
+
}
|
|
620
|
+
return formatResult({ id: false, mode: 'none', error: err }, 'errored', warnings);
|
|
315
621
|
}
|
|
316
622
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
623
|
+
const policyId = discovery.id;
|
|
624
|
+
if (!policyId) {
|
|
625
|
+
if (cacheValid) {
|
|
626
|
+
return useCached(createError(`No usable MTA-STS TXT record found for ${domain}`, discovery.reason));
|
|
320
627
|
}
|
|
628
|
+
return formatResult({ id: false, mode: 'none' }, 'not_found', warnings);
|
|
629
|
+
}
|
|
321
630
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
expires: new Date(Date.now() + knownPolicy.maxAge * 1000).toISOString()
|
|
327
|
-
}),
|
|
328
|
-
status: 'renewed'
|
|
329
|
-
};
|
|
330
|
-
}
|
|
631
|
+
if (cacheValid && knownPolicy.id === policyId) {
|
|
632
|
+
// no changes, not expired, no need to fetch the policy file
|
|
633
|
+
return formatResult(Object.assign({}, knownPolicy), 'renewed', warnings);
|
|
634
|
+
}
|
|
331
635
|
|
|
332
|
-
|
|
636
|
+
try {
|
|
637
|
+
let policy = await fetchPolicyFile(domain, opts, warnings);
|
|
333
638
|
if (!policy) {
|
|
334
|
-
|
|
639
|
+
if (cacheValid) {
|
|
640
|
+
return useCached(createError(`Policy host mta-sts.${domain} has no address records`, 'policy_host_not_found'));
|
|
641
|
+
}
|
|
642
|
+
return formatResult({ id: false, mode: 'none' }, 'not_found', warnings);
|
|
335
643
|
}
|
|
336
644
|
|
|
337
|
-
return
|
|
338
|
-
|
|
645
|
+
return formatResult(
|
|
646
|
+
Object.assign({ id: policyId }, policy, {
|
|
339
647
|
expires: new Date(Date.now() + policy.maxAge * 1000).toISOString()
|
|
340
648
|
}),
|
|
341
|
-
|
|
342
|
-
|
|
649
|
+
'found',
|
|
650
|
+
warnings
|
|
651
|
+
);
|
|
343
652
|
} catch (err) {
|
|
344
|
-
|
|
653
|
+
// A valid cached "none" policy (or an earlier retry placeholder) enforces nothing, so it is
|
|
654
|
+
// replaced with a retry placeholder for the new policy ID below instead of being reused
|
|
655
|
+
if (cacheValid && knownPolicy.mode !== 'none') {
|
|
345
656
|
// re-use existing policy on error
|
|
346
|
-
return
|
|
347
|
-
policy: Object.assign({ error: err }, knownPolicy),
|
|
348
|
-
status: 'errored'
|
|
349
|
-
};
|
|
657
|
+
return useCached(err);
|
|
350
658
|
}
|
|
351
659
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
660
|
+
if (!strict && knownPolicy?.id && ['enforce', 'testing'].includes(knownPolicy.mode)) {
|
|
661
|
+
// lax mode keeps applying an expired policy instead of falling back to no policy
|
|
662
|
+
warnings.push('expired-cache');
|
|
663
|
+
return useCached(err);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// continue as if the domain had no policy, and do not retry fetching this policy ID for a while
|
|
667
|
+
return formatResult(
|
|
668
|
+
{
|
|
355
669
|
id: policyId,
|
|
356
670
|
mode: 'none',
|
|
357
|
-
expires: new Date(Date.now() +
|
|
671
|
+
expires: new Date(Date.now() + ERROR_RETRY_DELAY).toISOString(),
|
|
358
672
|
error: err
|
|
359
673
|
},
|
|
360
|
-
|
|
361
|
-
|
|
674
|
+
'errored',
|
|
675
|
+
warnings
|
|
676
|
+
);
|
|
362
677
|
}
|
|
363
678
|
};
|
|
364
679
|
|