mailauth 6.0.0 → 7.1.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 (47) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +113 -39
  4. package/bin/mailauth.js +15 -6
  5. package/cli.md +9 -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 +80 -33
  10. package/docs/mta-sts.md +140 -42
  11. package/docs/spf.md +69 -4
  12. package/index.d.ts +508 -43
  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 +3 -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 +577 -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 +154 -47
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/verify.js +120 -44
  36. package/lib/mailauth.js +77 -36
  37. package/lib/mta-sts.d.ts +77 -11
  38. package/lib/mta-sts.js +497 -182
  39. package/lib/parse-dkim-headers.js +376 -191
  40. package/lib/parse-received.js +104 -32
  41. package/lib/spf/index.d.ts +37 -1
  42. package/lib/spf/index.js +260 -28
  43. package/lib/spf/macro.js +50 -13
  44. package/lib/spf/spf-verify.js +440 -204
  45. package/lib/spf/syntax.js +366 -0
  46. package/lib/tools.js +522 -116
  47. package/package.json +1 -1
@@ -1,21 +1,51 @@
1
1
  'use strict';
2
2
 
3
- const punycode = require('punycode.js');
4
3
  const net = require('net');
5
4
  const macro = require('./macro');
6
5
  const dns = require('node:dns').promises;
7
- const ipaddr = require('ipaddr.js');
8
- const { getPtrHostname, formatDomain } = require('../tools');
6
+ const { getPtrHostname, formatDomain, toALabel } = require('../tools');
7
+ const { parseIp, ipInNetwork, normalizeTargetName, isValidTargetName, parseMacroString, validateTerms } = require('./syntax');
9
8
 
10
9
  const LIMIT_PTR_RESOLVE_RECORDS = 10;
11
10
 
11
+ // the lookup limit, the void lookup limit and the time limit end the evaluation, a DNS error
12
+ // in a PTR lookup only makes the "ptr" mechanism (or the %{p} macro) not match
13
+ const isFatalError = err => !!(err && ((err.spfResult && err.spfResult.error === 'permerror') || err.code === 'ESPFTIMELIMIT'));
14
+
15
+ // checks if a macro-string uses one of the given macro letters, "%%" is a literal percent sign
16
+ const usesMacro = (input, letters) =>
17
+ (input || '')
18
+ .toString()
19
+ .replace(/%%/g, '')
20
+ .split('%{')
21
+ .slice(1)
22
+ .some(part => letters.includes(part.charAt(0).toLowerCase()));
23
+
24
+ const permError = text => {
25
+ let err = new Error('SPF failure');
26
+ err.spfResult = { error: 'permerror', text };
27
+ return err;
28
+ };
29
+
12
30
  const matchIp = (addr, range) => {
13
- if (/\/\d+$/.test(range)) {
31
+ let prefix;
32
+ let rangeMatch = range.match(/^(.*)\/(\d+)$/);
33
+ if (rangeMatch) {
14
34
  // seems CIDR
15
- return addr.match(ipaddr.parseCIDR(range));
16
- } else {
17
- return addr.toNormalizedString() === ipaddr.parse(range).toNormalizedString();
35
+ range = rangeMatch[1];
36
+ prefix = Number(rangeMatch[2]);
37
+ }
38
+
39
+ let network = parseIp(range);
40
+ if (!network) {
41
+ throw new Error(`Invalid IP address ${range}`);
42
+ }
43
+
44
+ if (typeof prefix !== 'number') {
45
+ prefix = network.kind() === 'ipv4' ? 32 : 128;
18
46
  }
47
+
48
+ return ipInNetwork(addr, network, prefix);
19
49
  };
20
50
 
21
51
  const parseCidrValue = (val, defaultValue, type) => {
@@ -23,60 +53,199 @@ const parseCidrValue = (val, defaultValue, type) => {
23
53
  let domain = '';
24
54
  let cidr4 = '';
25
55
  let cidr6 = '';
56
+ let dualCidr = false;
26
57
 
27
58
  if (val) {
28
59
  let cidrMatch = val.match(/^(.*?)(\/\d+)?(\/\/\d+)?$/);
29
60
  if (!cidrMatch || /^\/0+[1-9]/.test(cidrMatch[2]) || /^\/\/0+[1-9]/.test(cidrMatch[3])) {
30
- let err = new Error('SPF failure');
31
- err.spfResult = { error: 'permerror', text: `invalid address definition: ${val}` };
32
- throw err;
61
+ throw permError(`invalid address definition: ${val}`);
33
62
  }
34
63
  domain = cidrMatch[1] || '';
35
64
 
36
65
  cidr4 = cidrMatch[2] ? Number(cidrMatch[2].substr(1)) : '';
37
66
  cidr6 = cidrMatch[3] ? Number(cidrMatch[3].substr(2)) : '';
67
+ dualCidr = !!cidrMatch[3];
38
68
 
39
- if (type === 'ip6' && cidr4 && !cidr6) {
40
- // there is no dual cidr for IP addresses
69
+ if (type === 'ip6' && typeof cidr4 === 'number' && cidr6 === '') {
70
+ // there is no dual cidr for IP addresses, a single prefix length of an ip6 term is
71
+ // the IPv6 prefix length, including "/0"
41
72
  cidr6 = cidr4;
42
73
  cidr4 = '';
43
74
  }
44
75
  }
45
76
 
46
- domain = domain.toLowerCase().trim() || defaultValue;
77
+ // the domain-spec is not lower cased here, an uppercase macro letter means URL escaping
78
+ domain = domain.trim() || defaultValue;
47
79
 
48
80
  if ((typeof cidr4 === 'number' && cidr4 > 32 && !net.isIPv6(domain)) || (typeof cidr6 === 'number' && cidr6 > 128)) {
49
- let err = new Error('SPF failure');
50
- err.spfResult = { error: 'permerror', text: `invalid cidr definition: ${val}` };
51
- throw err;
81
+ throw permError(`invalid cidr definition: ${val}`);
52
82
  }
53
83
 
54
84
  return {
55
85
  domain,
56
86
  cidr4: typeof cidr4 === 'number' ? `/${cidr4}` : '',
57
- cidr6: typeof cidr6 === 'number' ? `/${cidr6}` : ''
87
+ cidr6: typeof cidr6 === 'number' ? `/${cidr6}` : '',
88
+ dualCidr
58
89
  };
59
90
  };
60
91
 
92
+ /**
93
+ * Resolves the validated domain names of the client IP (RFC 7208 section 5.5). The PTR query
94
+ * goes through the given resolver, so with the limited resolver every "ptr" term and every
95
+ * %{p} expansion counts toward the DNS lookup limit (RFC 7208 section 4.6.4).
96
+ *
97
+ * @returns {Array} Validated names, empty if there are none or the PTR lookup failed
98
+ */
99
+ const getValidatedNames = async (addr, resolver, subResolver) => {
100
+ let ptrValues;
101
+ try {
102
+ ptrValues = await resolver(getPtrHostname(addr), 'PTR');
103
+ } catch (err) {
104
+ if (isFatalError(err)) {
105
+ throw err;
106
+ }
107
+ // "If a DNS error occurs while doing the PTR RR lookup, then this mechanism fails to match."
108
+ return [];
109
+ }
110
+
111
+ ptrValues = (Array.isArray(ptrValues) ? ptrValues : []).slice(0, LIMIT_PTR_RESOLVE_RECORDS);
112
+
113
+ // resolve up to 10 PTR A/AAAA records
114
+ // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4
115
+ let results = await Promise.allSettled(ptrValues.map(ptrValue => subResolver(ptrValue, addr.kind() === 'ipv6' ? 'AAAA' : 'A')));
116
+
117
+ let timeLimit = results.find(result => result.status === 'rejected' && result.reason && result.reason.code === 'ESPFTIMELIMIT');
118
+ if (timeLimit) {
119
+ throw timeLimit.reason;
120
+ }
121
+
122
+ let kind = addr.kind();
123
+ let normalized = addr.toNormalizedString();
124
+
125
+ let validated = [];
126
+ for (let i = 0; i < results.length; i++) {
127
+ let result = results[i];
128
+ if (
129
+ result.status === 'fulfilled' &&
130
+ Array.isArray(result.value) &&
131
+ result.value.some(val => {
132
+ let parsed = parseIp(val);
133
+ return parsed && parsed.kind() === kind && parsed.toNormalizedString() === normalized;
134
+ })
135
+ ) {
136
+ validated.push(ptrValues[i]);
137
+ }
138
+ }
139
+
140
+ return validated;
141
+ };
142
+
143
+ /**
144
+ * Picks the %{p} value from the validated names (RFC 7208 section 7.3): the <domain> itself,
145
+ * else a subdomain of it, else any validated name, else "unknown"
146
+ */
147
+ const selectPtrName = (validatedNames, domain) => {
148
+ let target = formatDomain(domain || '');
149
+ let names = validatedNames.map(name => ({ name: name.toString().replace(/\.+$/, ''), formatted: formatDomain(name.toString()) }));
150
+
151
+ let match =
152
+ names.find(entry => entry.formatted === target) ||
153
+ names.find(entry => target && entry.formatted.substr(-(target.length + 1)) === `.${target}`) ||
154
+ names[0];
155
+
156
+ return match ? match.name : 'unknown';
157
+ };
158
+
159
+ // Adds the sub resolver's lookup counts of a mx or ptr term to the main resolver's report
160
+ const reportSubQueries = (resolver, subResolver, type) => {
161
+ if (typeof resolver.updateSubQueries === 'function' && typeof subResolver.getResolveCount === 'function') {
162
+ resolver.updateSubQueries(type, subResolver.getResolveCount());
163
+ resolver.updateSubQueries(`${type}:void`, subResolver.getVoidCount());
164
+ }
165
+ };
166
+
61
167
  const spfVerify = async (domain, opts) => {
62
168
  opts = opts || {};
63
- if (!opts.ip || !net.isIP(opts.ip)) {
169
+
170
+ // the client IP is parsed once, include and redirect recursion reuse it through opts.addr
171
+ let addr = opts.addr || (opts.ip ? parseIp(opts.ip) : null);
172
+ if (!addr) {
64
173
  return false;
65
174
  }
66
175
 
67
- try {
68
- domain = punycode.toASCII(domain);
69
- } catch (err) {
70
- // ignore punycode conversion errors
176
+ let strict = !!opts.strict;
177
+ // shared across include and redirect recursion
178
+ let ctx = opts.ctx || { warnings: new Set() };
179
+ if (!opts.ctx || !opts.addr) {
180
+ opts = Object.assign({}, opts, { ctx, addr });
71
181
  }
72
182
 
73
- let addr = ipaddr.parse(opts.ip);
183
+ domain = toALabel(domain);
184
+
185
+ let isIPv6 = addr.kind() === 'ipv6';
74
186
 
75
187
  // %{d} must expand to the domain currently being evaluated, which changes on include/redirect recursion
76
- let macroValues = { sender: opts.sender, ip: opts.ip, helo: opts.helo, mta: opts.mta, domain };
188
+ let macroValues = { sender: opts.sender, ip: opts.ip, addr, helo: opts.helo, mta: opts.mta, domain };
77
189
 
78
190
  let resolver = opts.resolver || dns.resolve;
79
191
 
192
+ let createSubResolver = options => (typeof opts.createSubResolver === 'function' ? opts.createSubResolver(options) : resolver);
193
+
194
+ // RFC 8616 section 4: a non-ASCII local-part can not be used in a DNS label
195
+ let senderStr = (opts.sender || '').toString();
196
+ let nonAsciiLocalPart = /[^\x00-\x7F]/.test(senderStr.substr(0, Math.max(senderStr.lastIndexOf('@'), 0)));
197
+
198
+ // The macro values with %{p}, the validated domain name of the client IP (RFC 7208 section 7.3)
199
+ let withPtr = async (ptrResolver, subResolver) =>
200
+ Object.assign({}, macroValues, { p: selectPtrName(await getValidatedNames(addr, ptrResolver, subResolver), domain) });
201
+
202
+ /**
203
+ * Expands a domain-spec into a target name
204
+ *
205
+ * @param {String} spec domain-spec
206
+ * @param {Object} [options]
207
+ * @param {Boolean} [options.lowercase] If true, lower case the literal parts of the domain-spec
208
+ * @param {Function} [options.ptrResolver] Resolver to use for the %{p} PTR lookup
209
+ * @returns {Object} Either { name } or { skip: reason } when the term must not match
210
+ */
211
+ let expandTarget = async (spec, options) => {
212
+ options = options || {};
213
+
214
+ if (usesMacro(spec, 'sl')) {
215
+ if (!options.explanation) {
216
+ // the policy has explicit provisions for the local-part (RFC 8601 section 2.7.2)
217
+ ctx.usedLocalPart = true;
218
+ }
219
+ if (nonAsciiLocalPart) {
220
+ // RFC 8616 section 4: terms that include %{s} or %{l} do not match anything
221
+ if (strict) {
222
+ return { skip: 'local-part' };
223
+ }
224
+ ctx.warnings.add('non-ascii-local-part');
225
+ }
226
+ }
227
+
228
+ let values = usesMacro(spec, 'p')
229
+ ? await withPtr(options.ptrResolver || resolver, options.ptrResolver || createSubResolver({ ignoreVoid: true }))
230
+ : macroValues;
231
+
232
+ if (options.lowercase) {
233
+ // lower case the literal parts only, an uppercase macro letter means URL escaping
234
+ spec = spec.replace(/%\{[^}]*\}|%[%_-]|[^%]+|%/g, part => (part.charAt(0) === '%' ? part : part.toLowerCase()));
235
+ }
236
+
237
+ let name = toALabel(macro(spec, values).replace(/\.$/, ''));
238
+
239
+ // names longer than 253 characters are truncated from the left (RFC 7208 section 7.3)
240
+ name = normalizeTargetName(name);
241
+
242
+ if (strict && !isValidTargetName(name)) {
243
+ return { skip: 'invalid', name };
244
+ }
245
+
246
+ return { name };
247
+ };
248
+
80
249
  let responses;
81
250
  try {
82
251
  responses = await resolver(domain, 'TXT');
@@ -90,28 +259,43 @@ const spfVerify = async (domain, opts) => {
90
259
  let spfRecord;
91
260
  let spfRr;
92
261
 
93
- for (let row of responses) {
94
- row = row.join('');
95
- let parts = row.trim().split(/\s+/);
262
+ for (let row of responses || []) {
263
+ row = [].concat(row || []).join('');
96
264
 
97
- if (parts[0].toLowerCase() === 'v=spf1') {
98
- if (spfRecord) {
99
- // multiple records, return permerror
100
- let err = new Error('SPF failure');
101
- err.spfResult = { error: 'permerror', text: `multiple SPF records found for ${domain}` };
102
- throw err;
265
+ let parts;
266
+ // RFC 7208 section 4.5: the record begins with "v=spf1", terminated by SP or the end of the record
267
+ let exactVersion = /^v=spf1(?: |$)/i.test(row);
268
+ if (strict) {
269
+ if (!exactVersion) {
270
+ continue;
103
271
  }
104
- spfRr = row;
105
- spfRecord = parts.slice(1);
106
-
107
- if (spfRr && /[^\x20-\x7E]/.test(spfRr)) {
108
- let err = new Error('Invalid characters in DNS response');
109
- err.spfResult = {
110
- error: 'permerror',
111
- text: 'DNS response includes invalid characters'
112
- };
113
- throw err;
272
+ // terms = *( 1*SP ( directive / modifier ) ), record = version terms *SP
273
+ parts = row.split(' ').filter(part => part);
274
+ } else {
275
+ parts = row.trim().split(/\s+/);
276
+ if (parts[0].toLowerCase() !== 'v=spf1') {
277
+ continue;
114
278
  }
279
+ if (!exactVersion) {
280
+ // selected only because leading whitespace was trimmed
281
+ ctx.warnings.add('syntax-error');
282
+ }
283
+ }
284
+
285
+ if (spfRecord) {
286
+ // multiple records, return permerror
287
+ throw permError(`multiple SPF records found for ${domain}`);
288
+ }
289
+ spfRr = row;
290
+ spfRecord = parts.slice(1);
291
+
292
+ if (spfRr && /[^\x20-\x7E]/.test(spfRr)) {
293
+ let err = new Error('Invalid characters in DNS response');
294
+ err.spfResult = {
295
+ error: 'permerror',
296
+ text: 'DNS response includes invalid characters'
297
+ };
298
+ throw err;
115
299
  }
116
300
  }
117
301
 
@@ -121,6 +305,19 @@ const spfVerify = async (domain, opts) => {
121
305
  throw err;
122
306
  }
123
307
 
308
+ // RFC 7208 section 4.6: the syntax of the whole record is validated first
309
+ let syntaxError = validateTerms(spfRecord);
310
+ if (syntaxError) {
311
+ if (strict) {
312
+ throw permError(syntaxError);
313
+ }
314
+ // the default mode evaluates the record lazily, so errors after the matching term are ignored
315
+ ctx.warnings.add('syntax-error');
316
+ }
317
+
318
+ // set if the result comes from a redirect, the explanation of this record is then not used
319
+ let redirected = false;
320
+
124
321
  let getResult = async () => {
125
322
  // this check is only for passing test suite
126
323
  for (let i = spfRecord.length - 1; i >= 0; i--) {
@@ -129,31 +326,29 @@ const spfVerify = async (domain, opts) => {
129
326
  //modifier, not mechanism
130
327
 
131
328
  if (!/^[a-z](a-z0-9-_\.)*/i.test(part)) {
132
- let err = new Error('SPF failure');
133
- err.spfResult = { error: 'permerror', text: `invalid modifier ${part}` };
134
- throw err;
329
+ throw permError(`invalid modifier ${part}`);
135
330
  }
136
331
 
137
332
  let splitPos = part.indexOf('=');
138
333
  let modifier = part.substr(0, splitPos).toLowerCase();
139
- let value = part.substr(splitPos + 1);
140
-
141
- value = macro(value, macroValues)
142
- // remove trailing dot
143
- .replace(/\.$/, '');
144
-
145
- if (!value) {
146
- let err = new Error('SPF failure');
147
- err.spfResult = { error: 'permerror', text: `Empty modifier value for ${modifier}` };
148
- throw err;
149
- } else if (modifier === 'redirect' && !/^([\x21-\x2D\x2f-\x7e]+\.)+[a-z]+[a-z\-0-9]*$/.test(value)) {
150
- let err = new Error('SPF failure');
151
- err.spfResult = { error: 'permerror', text: `Invalid redirect target ${value}` };
152
- throw err;
334
+ let rawValue = part.substr(splitPos + 1);
335
+
336
+ if (!strict) {
337
+ // the default mode checks the expanded values up front
338
+ let value = macro(rawValue, macroValues)
339
+ // remove trailing dot
340
+ .replace(/\.$/, '');
341
+
342
+ if (!value && (modifier === 'redirect' || modifier === 'exp')) {
343
+ // an unknown modifier may have an empty value (RFC 7208 section 6)
344
+ throw permError(`Empty modifier value for ${modifier}`);
345
+ } else if (modifier === 'redirect' && !/^([\x21-\x2D\x2f-\x7e]+\.)+[a-z]+[a-z\-0-9]*$/i.test(value)) {
346
+ throw permError(`Invalid redirect target ${value}`);
347
+ }
153
348
  }
154
349
 
155
350
  spfRecord.splice(i, 1);
156
- spfRecord.push({ modifier, value });
351
+ spfRecord.push({ modifier, value: rawValue });
157
352
  continue;
158
353
  }
159
354
 
@@ -164,17 +359,13 @@ const spfVerify = async (domain, opts) => {
164
359
  .replace(/^[?\-~+]/, '');
165
360
 
166
361
  if (!['all', 'include', 'a', 'mx', 'ip4', 'ip6', 'exists', 'ptr'].includes(mechanism)) {
167
- let err = new Error('SPF failure');
168
- err.spfResult = { error: 'permerror', text: `Unknown mechanism ${mechanism}` };
169
- throw err;
362
+ throw permError(`Unknown mechanism ${mechanism}`);
170
363
  }
171
364
  }
172
365
 
173
366
  if (spfRecord.filter(p => p && p.modifier === 'redirect').length > 1) {
174
367
  // too many redirects
175
- let err = new Error('SPF failure');
176
- err.spfResult = { error: 'permerror', text: `more than 1 redirect found` };
177
- throw err;
368
+ throw permError(`more than 1 redirect found`);
178
369
  }
179
370
 
180
371
  for (let i = 0; i < spfRecord.length; i++) {
@@ -191,19 +382,24 @@ const spfVerify = async (domain, opts) => {
191
382
  continue;
192
383
  }
193
384
 
385
+ let target = await expandTarget(value);
386
+ if (target.skip) {
387
+ // RFC 7208 section 6.1: a malformed target name is a permerror, not "none"
388
+ throw permError(`Invalid redirect target ${target.name || value}`);
389
+ }
390
+
194
391
  try {
195
- let subResult = await spfVerify(value, opts);
392
+ let subResult = await spfVerify(target.name, opts);
196
393
  if (subResult) {
394
+ redirected = true;
197
395
  return subResult;
198
396
  }
199
397
  } catch (err) {
200
- // kind of ignore
201
- if (err.spfResult) {
202
- if (err.spfResult.error === 'none') {
203
- err.spfResult.error = 'permerror';
204
- }
205
- throw err;
398
+ if (err.spfResult && err.spfResult.error === 'none') {
399
+ err.spfResult.error = 'permerror';
206
400
  }
401
+ // DNS errors (without spfResult) end up as temperror
402
+ throw err;
207
403
  }
208
404
  }
209
405
  break;
@@ -222,9 +418,7 @@ const spfVerify = async (domain, opts) => {
222
418
 
223
419
  let splitterPos = part.indexOf(':');
224
420
  if (splitterPos === part.length - 1) {
225
- let err = new Error('SPF failure');
226
- err.spfResult = { error: 'permerror', text: `unexpected empty value` };
227
- throw err;
421
+ throw permError(`unexpected empty value`);
228
422
  }
229
423
  if (splitterPos >= 0) {
230
424
  key = part.substr(0, splitterPos);
@@ -248,34 +442,41 @@ const spfVerify = async (domain, opts) => {
248
442
  switch (type) {
249
443
  case 'all':
250
444
  if (val) {
251
- let err = new Error('SPF failure');
252
- err.spfResult = { error: 'permerror', text: `unexpected extension for all` };
253
- throw err;
445
+ throw permError(`unexpected extension for all`);
254
446
  }
255
447
  return { type, qualifier };
256
448
 
257
449
  case 'include':
258
450
  {
451
+ let sub;
259
452
  try {
260
- let redirect = macro(val, macroValues)
261
- // remove trailing dot
262
- .replace(/\.$/, '');
263
- let sub = await spfVerify(redirect, opts);
264
- if (sub && sub.qualifier === '+') {
265
- // ignore other valid responses
266
- return { type, val, include: sub, qualifier };
453
+ let target = await expandTarget(val);
454
+ if (target.skip === 'local-part') {
455
+ // does not match
456
+ break;
267
457
  }
268
- if (sub && sub.error) {
269
- return sub;
458
+ if (target.skip) {
459
+ // a malformed target name gives "none" (RFC 7208 section 4.3), that is permerror for include
460
+ throw permError(`Invalid include target ${target.name}`);
270
461
  }
462
+ // the explanation of the included record is never used (RFC 7208 section 6.2)
463
+ sub = await spfVerify(target.name, Object.assign({}, opts, { inInclude: true }));
271
464
  } catch (err) {
272
- // kind of ignore
273
465
  if (err.spfResult) {
274
466
  if (err.spfResult.error === 'none') {
275
467
  err.spfResult.error = 'permerror';
276
468
  }
277
469
  return err.spfResult;
278
470
  }
471
+ // any other DNS error: "return temperror" (RFC 7208 section 5.2)
472
+ throw err;
473
+ }
474
+ if (sub && sub.qualifier === '+') {
475
+ // ignore other valid responses
476
+ return { type, val, include: sub, qualifier };
477
+ }
478
+ if (sub && sub.error) {
479
+ return sub;
279
480
  }
280
481
  }
281
482
  break;
@@ -283,45 +484,9 @@ const spfVerify = async (domain, opts) => {
283
484
  case 'ip4':
284
485
  case 'ip6':
285
486
  {
286
- let { domain: range, cidr4, cidr6 } = parseCidrValue(val, false, type);
287
- if (!range) {
288
- let err = new Error('SPF failure');
289
- err.spfResult = { error: 'permerror', text: `bare IP address` };
290
- throw err;
291
- }
292
-
293
- let originalRange = range;
294
- let mappingMatch = (range || '').toString().match(/^[:A-F]+:((\d+\.){3}\d+)$/i);
295
- if (mappingMatch) {
296
- range = mappingMatch[1];
297
- }
298
-
299
- if (net.isIP(range)) {
300
- if (type === 'ip6' && net.isIPv6(opts.ip) && net.isIPv6(originalRange) && net.isIPv4(range) && cidr4 === '/0') {
301
- // map all IPv6 addresses
302
- return { type, val, qualifier };
303
- }
304
-
305
- // validate ipv4 range only, skip ipv6
306
- if (cidr6 && net.isIPv4(range)) {
307
- let err = new Error('SPF failure');
308
- err.spfResult = { error: 'permerror', text: `invalid CIDR for IP` };
309
- throw err;
310
- }
311
-
312
- if (net.isIP(range) !== net.isIP(opts.ip) || net.isIP(range) !== Number(type.charAt(2))) {
313
- // nothing to do here
314
- break;
315
- }
316
-
317
- let cidr = net.isIPv6(range) ? cidr6 : cidr4;
318
- if (matchIp(addr, range + cidr)) {
319
- return { type, val, qualifier };
320
- }
321
- } else {
322
- let err = new Error('SPF failure');
323
- err.spfResult = { error: 'permerror', text: `invalid IP address` };
324
- throw err;
487
+ let res = strict ? matchIpTermStrict(type, val, addr) : matchIpTermLax(type, val, addr, opts.ip);
488
+ if (res) {
489
+ return { type, val, qualifier };
325
490
  }
326
491
  }
327
492
  break;
@@ -329,19 +494,16 @@ const spfVerify = async (domain, opts) => {
329
494
  case 'a':
330
495
  {
331
496
  let { domain: a, cidr4, cidr6 } = parseCidrValue(val, domain, type);
332
- let cidr = net.isIPv6(opts.ip) ? cidr6 : cidr4;
333
-
334
- a = macro(a, macroValues);
497
+ let cidr = isIPv6 ? cidr6 : cidr4;
335
498
 
336
- try {
337
- a = punycode.toASCII(a);
338
- } catch (err) {
339
- // ignore punycode conversion errors
499
+ let target = await expandTarget(a, { lowercase: true });
500
+ if (target.skip) {
501
+ break;
340
502
  }
341
503
 
342
504
  // Query A or AAAA based on client IP type, with dual-stack void optimization
343
505
  // Pass clientIpType to enable smart void counting (see dualStackResolver in index.js)
344
- let responses = await resolver(a, net.isIPv6(opts.ip) ? 'AAAA' : 'A', { clientIpType: net.isIPv6(opts.ip) ? 6 : 4 });
506
+ let responses = await resolver(target.name, isIPv6 ? 'AAAA' : 'A', { clientIpType: isIPv6 ? 6 : 4 });
345
507
  if (responses) {
346
508
  for (let ip of responses) {
347
509
  if (matchIp(addr, ip + cidr)) {
@@ -355,29 +517,30 @@ const spfVerify = async (domain, opts) => {
355
517
  case 'mx':
356
518
  {
357
519
  let { domain: mxDomain, cidr4, cidr6 } = parseCidrValue(val, domain, type);
358
- let cidr = net.isIPv6(opts.ip) ? cidr6 : cidr4;
520
+ let cidr = isIPv6 ? cidr6 : cidr4;
359
521
 
360
- mxDomain = macro(mxDomain, macroValues);
361
-
362
- try {
363
- mxDomain = punycode.toASCII(mxDomain);
364
- } catch (err) {
365
- // ignore punycode conversion errors
522
+ let target = await expandTarget(mxDomain, { lowercase: true });
523
+ if (target.skip) {
524
+ break;
366
525
  }
367
526
 
368
- let mxList = await resolver(mxDomain, 'MX');
527
+ let mxList = await resolver(target.name, 'MX');
369
528
  if (mxList) {
370
529
  // MX mechanism uses a separate resolver with independent DNS lookup counter
371
- // This prevents MX A/AAAA lookups from consuming the main query limit
372
- let subResolver = typeof opts.createSubResolver === 'function' ? opts.createSubResolver() : resolver;
530
+ // This prevents MX A/AAAA lookups from consuming the main query limit.
531
+ // The void limit counts terms, and the MX query of this term was not void,
532
+ // so address lookups of the MX hosts are not counted as void lookups
533
+ let subResolver = createSubResolver({ ignoreVoid: true });
373
534
  try {
374
535
  mxList = mxList.sort((a, b) => a.priority - b.priority);
375
536
  for (let mx of mxList) {
376
- if (mx.exchange) {
537
+ // a trailing dot is accepted, and a null MX (".") has no hosts
538
+ let exchange = mx && mx.exchange ? mx.exchange.toString().replace(/\.$/, '') : '';
539
+ if (exchange) {
377
540
  // Query A or AAAA for each MX host, with dual-stack void optimization
378
541
  // Pass clientIpType to enable smart void counting (see dualStackResolver in index.js)
379
- let responses = await subResolver(mx.exchange, net.isIPv6(opts.ip) ? 'AAAA' : 'A', {
380
- clientIpType: net.isIPv6(opts.ip) ? 6 : 4
542
+ let responses = await subResolver(exchange, isIPv6 ? 'AAAA' : 'A', {
543
+ clientIpType: isIPv6 ? 6 : 4
381
544
  });
382
545
  if (responses) {
383
546
  for (let a of responses) {
@@ -389,10 +552,7 @@ const spfVerify = async (domain, opts) => {
389
552
  }
390
553
  }
391
554
  } finally {
392
- if (typeof resolver.updateSubQueries === 'function') {
393
- resolver.updateSubQueries('mx', subResolver.getResolveCount());
394
- resolver.updateSubQueries('mx:void', subResolver.getVoidCount());
395
- }
555
+ reportSubQueries(resolver, subResolver, 'mx');
396
556
  }
397
557
  }
398
558
  }
@@ -400,16 +560,14 @@ const spfVerify = async (domain, opts) => {
400
560
 
401
561
  case 'exists':
402
562
  {
403
- let existDomain = macro(val, macroValues);
404
- try {
405
- existDomain = punycode.toASCII(existDomain);
406
- } catch (err) {
407
- // ignore punycode conversion errors
563
+ let target = await expandTarget(val);
564
+ if (target.skip) {
565
+ break;
408
566
  }
409
567
 
410
- let responses = await resolver(existDomain, 'A');
568
+ let responses = await resolver(target.name, 'A');
411
569
  if (responses && responses.length) {
412
- return { type, val: existDomain, qualifier };
570
+ return { type, val: target.name, qualifier };
413
571
  }
414
572
  }
415
573
  break;
@@ -418,56 +576,27 @@ const spfVerify = async (domain, opts) => {
418
576
  {
419
577
  let { cidr4, cidr6 } = parseCidrValue(val, false, type);
420
578
  if (cidr4 || cidr6) {
421
- let err = new Error('SPF failure');
422
- err.spfResult = { error: 'permerror', text: `invalid domain-spec definition: ${val}` };
423
- throw err;
579
+ throw permError(`invalid domain-spec definition: ${val}`);
424
580
  }
425
581
 
426
582
  // bare "ptr" defaults to %{d}, the currently evaluated domain
427
- let ptrDomain = val ? macro(val, macroValues) : domain;
428
- ptrDomain = formatDomain(ptrDomain);
429
-
430
- // Step 1. Resolve PTR hostnames
431
- let ptrValues;
432
- if (opts._resolvedPtr) {
433
- ptrValues = opts._resolvedPtr;
434
- } else {
435
- let responses = await resolver(getPtrHostname(addr), 'PTR');
436
- opts._resolvedPtr = ptrValues = responses && responses.length ? responses : [];
437
- }
438
-
439
- // PTR resolver has separate counter
440
- let subResolver = typeof opts.createSubResolver === 'function' ? opts.createSubResolver() : resolver;
441
-
442
- let resolvers = [];
443
- for (let ptrValue of ptrValues) {
444
- if (resolvers.length < LIMIT_PTR_RESOLVE_RECORDS) {
445
- // resolve up to 10 PTR A/AAAA records
446
- // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4
447
- resolvers.push(subResolver(ptrValue, net.isIPv6(opts.ip) ? 'AAAA' : 'A'));
583
+ let ptrDomain = domain;
584
+ if (val) {
585
+ let target = await expandTarget(val);
586
+ if (target.skip) {
587
+ break;
448
588
  }
589
+ ptrDomain = target.name;
449
590
  }
591
+ ptrDomain = formatDomain(ptrDomain);
450
592
 
451
- // Step 2. Validate PTR hostnames by reverse resolving these
452
- let validatedPtrRecords = [];
453
- let results = await Promise.allSettled(resolvers);
593
+ // PTR name validation uses a resolver with a separate counter
594
+ let subResolver = createSubResolver({ ignoreVoid: true });
454
595
 
455
- if (typeof resolver.updateSubQueries === 'function') {
456
- resolver.updateSubQueries('ptr', subResolver.getResolveCount());
457
- resolver.updateSubQueries('ptr:void', subResolver.getVoidCount());
458
- }
459
-
460
- for (let i = 0; i < results.length; i++) {
461
- let result = results[i];
462
- let ptrHostname = ptrValues[i];
463
- if (
464
- result.status === 'fulfilled' &&
465
- Array.isArray(result.value) &&
466
- result.value.map(val => ipaddr.parse(val).toNormalizedString()).includes(addr.toNormalizedString())
467
- ) {
468
- validatedPtrRecords.push(ptrHostname);
469
- }
470
- }
596
+ // Step 1 and 2. Resolve PTR hostnames and validate these by resolving their addresses.
597
+ // Every ptr term makes its own PTR query, so every ptr term counts toward the lookup limit
598
+ let validatedPtrRecords = await getValidatedNames(addr, resolver, subResolver);
599
+ reportSubQueries(resolver, subResolver, 'ptr');
471
600
 
472
601
  // Step 3. Check subdomain alignment
473
602
  for (let ptrRecord of validatedPtrRecords) {
@@ -485,9 +614,55 @@ const spfVerify = async (domain, opts) => {
485
614
  return false;
486
615
  };
487
616
 
617
+ /**
618
+ * Computes the explanation string (RFC 7208 section 6.2). Any problem means that there is no explanation.
619
+ */
620
+ let getExplanation = async spec => {
621
+ let expResolver = opts.expResolver || resolver;
622
+ try {
623
+ let target = await expandTarget(spec, { explanation: true, ptrResolver: expResolver });
624
+ if (target.skip || !target.name) {
625
+ return false;
626
+ }
627
+
628
+ let records = await expResolver(target.name, 'TXT');
629
+ if (!Array.isArray(records) || records.length !== 1) {
630
+ return false;
631
+ }
632
+
633
+ let text = [].concat(records[0] || []).join('');
634
+ // the explanation string is limited to US-ASCII
635
+ if (!text || /[^\x20-\x7E]/.test(text)) {
636
+ return false;
637
+ }
638
+
639
+ // explain-string = *( macro-string / SP ), throws on syntax errors
640
+ parseMacroString(text, { explain: true });
641
+
642
+ if (usesMacro(text, 'sl') && nonAsciiLocalPart && strict) {
643
+ return false;
644
+ }
645
+
646
+ return macro(text, usesMacro(text, 'p') ? await withPtr(expResolver, expResolver) : macroValues);
647
+ } catch (err) {
648
+ return false;
649
+ }
650
+ };
651
+
488
652
  try {
489
653
  let res = await getResult();
490
654
 
655
+ if (res && res.qualifier === '-' && !redirected && !opts.inInclude) {
656
+ // a mechanism of this record matched with "-", so the explanation of this record is used
657
+ let exp = spfRecord.find(p => p && typeof p === 'object' && p.modifier === 'exp');
658
+ if (exp) {
659
+ let explanation = await getExplanation(exp.value);
660
+ if (explanation) {
661
+ res.explanation = explanation;
662
+ }
663
+ }
664
+ }
665
+
491
666
  if (res && spfRr) {
492
667
  res.rr = spfRr;
493
668
  } else if (spfRr) {
@@ -506,4 +681,65 @@ const spfVerify = async (domain, opts) => {
506
681
  }
507
682
  };
508
683
 
684
+ // strict: the term has already passed the syntax validation (RFC 7208 section 5.6)
685
+ const matchIpTermStrict = (type, val, addr) => {
686
+ let match = val.match(/^(.*?)(?:\/(\d+))?$/);
687
+ let network = parseIp(match[1]);
688
+ if (!network || network.kind() !== (type === 'ip4' ? 'ipv4' : 'ipv6')) {
689
+ throw permError(`invalid IP address`);
690
+ }
691
+ let prefix = typeof match[2] === 'string' ? Number(match[2]) : type === 'ip4' ? 32 : 128;
692
+ // an IPv4 client only matches ip4 terms, an IPv6 client only matches ip6 terms
693
+ return ipInNetwork(addr, network, prefix);
694
+ };
695
+
696
+ // the default mode, keeps the historical handling of malformed ip4 and ip6 terms
697
+ const matchIpTermLax = (type, val, addr, clientIp) => {
698
+ let { domain: range, cidr4, cidr6, dualCidr } = parseCidrValue(val, false, type);
699
+ if (!range) {
700
+ throw permError(`bare IP address`);
701
+ }
702
+
703
+ // an IPv4 address written in the IPv4-mapped IPv6 form
704
+ let mappingMatch = range.match(/^[:A-F]+:((\d+\.){3}\d+)$/i);
705
+
706
+ if (type === 'ip6') {
707
+ if (net.isIPv6(range)) {
708
+ if (dualCidr && mappingMatch) {
709
+ throw permError(`invalid CIDR for IP`);
710
+ }
711
+ // an ip6 network is never converted to IPv4, and only IPv6 clients match it
712
+ // (IPv4-mapped clients are converted to IPv4 before evaluation)
713
+ return addr.kind() === 'ipv6' && matchIp(addr, range + cidr6);
714
+ }
715
+ if (net.isIPv4(range)) {
716
+ if (cidr6) {
717
+ throw permError(`invalid CIDR for IP`);
718
+ }
719
+ return false;
720
+ }
721
+ throw permError(`invalid IP address`);
722
+ }
723
+
724
+ if (mappingMatch) {
725
+ range = mappingMatch[1];
726
+ }
727
+
728
+ if (!net.isIP(range)) {
729
+ throw permError(`invalid IP address`);
730
+ }
731
+
732
+ // validate ipv4 range only, skip ipv6
733
+ if (cidr6 && net.isIPv4(range)) {
734
+ throw permError(`invalid CIDR for IP`);
735
+ }
736
+
737
+ if (net.isIP(range) !== net.isIP(clientIp) || !net.isIPv4(range)) {
738
+ // nothing to do here
739
+ return false;
740
+ }
741
+
742
+ return matchIp(addr, range + cidr4);
743
+ };
744
+
509
745
  module.exports = { spfVerify };