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.
Files changed (48) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +117 -41
  4. package/bin/mailauth.js +14 -6
  5. package/cli.md +8 -3
  6. package/docs/arc.md +126 -43
  7. package/docs/bimi.md +54 -37
  8. package/docs/dkim.md +79 -40
  9. package/docs/dmarc.md +82 -31
  10. package/docs/mta-sts.md +140 -42
  11. package/docs/spf.md +69 -4
  12. package/index.d.ts +499 -45
  13. package/lib/arc/index.d.ts +10 -8
  14. package/lib/arc/index.js +505 -124
  15. package/lib/bimi/index.js +162 -81
  16. package/lib/commands/report.js +2 -1
  17. package/lib/commands/seal.js +35 -5
  18. package/lib/commands/sign.js +8 -1
  19. package/lib/commands/spf.js +8 -0
  20. package/lib/dkim/body/relaxed.js +6 -5
  21. package/lib/dkim/body/simple.js +1 -3
  22. package/lib/dkim/dkim-signer.js +219 -33
  23. package/lib/dkim/dkim-verifier.js +574 -231
  24. package/lib/dkim/header/relaxed.js +6 -43
  25. package/lib/dkim/header/signature-opts.js +87 -0
  26. package/lib/dkim/header/simple.js +6 -43
  27. package/lib/dkim/message-parser.js +18 -4
  28. package/lib/dkim/sign.d.ts +13 -7
  29. package/lib/dkim/sign.js +18 -2
  30. package/lib/dkim/syntax.js +31 -0
  31. package/lib/dkim/verify.js +1 -0
  32. package/lib/dmarc/author-domain.js +181 -0
  33. package/lib/dmarc/get-dmarc-record.js +267 -53
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/index.js +2 -1
  36. package/lib/dmarc/verify.js +205 -53
  37. package/lib/mailauth.js +86 -40
  38. package/lib/mta-sts.d.ts +77 -11
  39. package/lib/mta-sts.js +497 -182
  40. package/lib/parse-dkim-headers.js +376 -191
  41. package/lib/parse-received.js +104 -32
  42. package/lib/spf/index.d.ts +37 -1
  43. package/lib/spf/index.js +260 -28
  44. package/lib/spf/macro.js +50 -13
  45. package/lib/spf/spf-verify.js +440 -204
  46. package/lib/spf/syntax.js +366 -0
  47. package/lib/tools.js +524 -118
  48. 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 HTTP_REQUEST_TIMEOUT = 15 * 1000;
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
- * Resolve MTA-STS policy ID
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
- * @param {Object} opts
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 resolvePolicy = async (address, opts) => {
19
- opts = opts || {};
20
- let { resolver } = opts;
21
- resolver = resolver || dns.promises.resolve;
22
-
23
- address = (address || '').toString();
24
- let atPos = address.indexOf('@');
25
- let domain = atPos < 0 ? address : address.substr(atPos + 1);
26
- if (/[\x7e-\xff]/.test(domain)) {
27
- // high bytes, probably U-label
28
- try {
29
- domain = punycode.toASCII(domain);
30
- } catch (err) {
31
- // ignore
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
- let record;
36
- try {
37
- let txt = await resolver(`_mta-sts.${domain}`, 'TXT');
38
- txt = (txt || []).map(row => row?.join('').trim()).filter(row => /^v=STSv1\b/i.test(row));
39
- if (txt.length > 1) {
40
- let err = new Error('');
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
- if (txt.length === 1) {
45
- record = parseDkimHeaders(txt[0]);
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
- if (!record || !/^STSv1$/i.test(record?.parsed?.v?.value) || !record?.parsed?.id?.value) {
54
- return false;
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
- return record?.parsed?.id?.value;
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 parsePolicy = file => {
66
- let policy = {
67
- // default
68
- mode: 'none'
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
- (file || '')
72
- .toString()
73
- .split(/\r?\n/)
74
- .map(l => {
75
- let colonPos = l.indexOf(':');
76
- if (colonPos < 0) {
77
- return false;
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
- return {
81
- key: l.substr(0, colonPos).toLowerCase().trim(),
82
- value: l.substr(colonPos + 1).trim()
83
- };
84
- })
85
- .filter(l => l)
86
- .forEach(l => {
87
- switch (l.key) {
88
- case 'version':
89
- policy[l.key] = l.value;
90
- break;
91
- case 'mode':
92
- policy[l.key] = l.value.toLowerCase();
93
- break;
94
- case 'max_age':
95
- policy.maxAge = Number(l.value);
96
- break;
97
- case 'mx': {
98
- if (!policy.mx) {
99
- policy.mx = [];
100
- }
101
- let mx = l.value.toLowerCase();
102
- if (!policy.mx.includes(mx)) {
103
- policy.mx.push(mx);
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
- let err = new Error('Invalid version field');
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
- let err = new Error('Invalid mode field');
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 > 31557600) {
123
- let err = new Error('Invalid max_age field');
124
- err.code = 'invalid_sts_max_age';
125
- throw err;
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
- let err = new Error('Missing mx field');
130
- err.code = 'invalid_sts_mx';
131
- throw err;
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
- * @returns {Boolean} true if validation succeeded
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
- if (/[\x7e-\xff]/.test(mx)) {
157
- // high bytes, probably U-label
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 policy.mx) {
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
- // remove wildcard
169
- allowed = allowed.substr(1);
170
- if (mx.substr(-allowed.length) === allowed) {
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 || 'none',
174
- match: allowed,
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 || 'none',
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 || 'none',
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 {Function} [opts.resolver] Optional async DNS resolver function
201
- * @returns {Object|Boolean} false if policy file was not found or structured 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 fetchPolicy = async (domain, opts) => {
204
- opts = opts || {};
205
- let { resolver } = opts;
206
- resolver = resolver || dns.promises.resolve;
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 || '').toString().toLowerCase();
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
- timeout: HTTP_REQUEST_TIMEOUT
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
- const req = https.request(options, res => {
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('readable', () => {
263
- let chunk;
264
- while ((chunk = res.read()) !== null) {
265
- chunks.push(chunk);
266
- chunklen += chunk.length;
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
- let data = Buffer.concat(chunks, chunklen);
271
- if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
272
- let err = new Error(`Invalid response code ${res.statusCode || '-'}`);
273
- err.code = 'http_status_' + (res.statusCode || 'na');
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
- res.on('error', err => reject(err));
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
- req.destroy(); // cancel request
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
- if (!data) {
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
- return parsePolicy(data, opts);
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] currenlty known MTA-STS policy
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
- * @returns {Object|Boolean} Policy information or false
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
- let policyId;
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
- policyId = await resolvePolicy(domain, opts);
615
+ discovery = await discoverPolicyId(domain, opts, warnings);
313
616
  } catch (err) {
314
- return { policy: { id: false, mode: 'none', error: err }, status: 'errored' };
617
+ if (cacheValid) {
618
+ return useCached(err);
619
+ }
620
+ return formatResult({ id: false, mode: 'none', error: err }, 'errored', warnings);
315
621
  }
316
622
 
317
- try {
318
- if (!policyId) {
319
- return { policy: { id: false, mode: 'none' }, status: 'not_found' };
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
- if (knownPolicy && knownPolicy.id === policyId && !(knownPolicy?.expires && new Date(knownPolicy?.expires) > new Date())) {
323
- // no changes, not expired
324
- return {
325
- policy: Object.assign({}, knownPolicy, {
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
- let policy = await fetchPolicy(domain, opts);
636
+ try {
637
+ let policy = await fetchPolicyFile(domain, opts, warnings);
333
638
  if (!policy) {
334
- return { policy: { id: false, mode: 'none' }, status: 'not_found' };
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
- policy: Object.assign({ id: policyId }, policy, {
645
+ return formatResult(
646
+ Object.assign({ id: policyId }, policy, {
339
647
  expires: new Date(Date.now() + policy.maxAge * 1000).toISOString()
340
648
  }),
341
- status: 'found'
342
- };
649
+ 'found',
650
+ warnings
651
+ );
343
652
  } catch (err) {
344
- if (knownPolicy) {
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
- // prevent loading the policy for the next 1 hour and default to "none"
353
- return {
354
- policy: {
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() + 1 * 3600 * 1000).toISOString(),
671
+ expires: new Date(Date.now() + ERROR_RETRY_DELAY).toISOString(),
358
672
  error: err
359
673
  },
360
- status: 'errored'
361
- };
674
+ 'errored',
675
+ warnings
676
+ );
362
677
  }
363
678
  };
364
679