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/spf/index.js
CHANGED
|
@@ -1,24 +1,50 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { spfVerify } = require('./spf-verify');
|
|
4
|
+
const { parseIp, isValidTargetName } = require('./syntax');
|
|
4
5
|
const os = require('node:os');
|
|
5
6
|
const dns = require('node:dns');
|
|
6
7
|
const libmime = require('libmime');
|
|
7
8
|
const Joi = require('joi');
|
|
8
9
|
const domainSchema = Joi.string().domain({ allowUnicode: false, tlds: false });
|
|
9
|
-
const { formatAuthHeaderRow, escapeCommentValue } = require('../tools');
|
|
10
|
+
const { formatAuthHeaderRow, escapeCommentValue, formatDotAtomOrQuoted, MAX_HEADER_VALUE_LENGTH, toALabel } = require('../tools');
|
|
10
11
|
|
|
11
12
|
const MAX_RESOLVE_COUNT = 10;
|
|
12
13
|
const MAX_VOID_COUNT = 2;
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Formats the Received-SPF header field (RFC 7208 section 9.1)
|
|
17
|
+
*/
|
|
18
|
+
const formatHeaders = (result, keys) => {
|
|
19
|
+
let pairs = [];
|
|
20
|
+
for (let key of ['client-ip', 'envelope-from', 'helo']) {
|
|
21
|
+
if (keys[key]) {
|
|
22
|
+
let value = formatDotAtomOrQuoted(keys[key]);
|
|
23
|
+
// a value this long can not be folded and would take the line past the 998
|
|
24
|
+
// character limit of RFC 5322 section 2.1.1, so the optional pair is left out
|
|
25
|
+
if (Buffer.byteLength(value) <= MAX_HEADER_VALUE_LENGTH) {
|
|
26
|
+
pairs.push(`${key}=${value};`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let header = `Received-SPF: ${result.status.result}${result.status.comment ? ` (${escapeCommentValue(result.status.comment)})` : ''}${
|
|
32
|
+
pairs.length ? ` ${pairs.join(' ')}` : ''
|
|
33
|
+
}`;
|
|
18
34
|
|
|
19
35
|
return libmime.foldLines(header, 160);
|
|
20
36
|
};
|
|
21
37
|
|
|
38
|
+
const timeLimitError = () => {
|
|
39
|
+
let err = new Error('SPF evaluation time limit exceeded');
|
|
40
|
+
err.code = 'ESPFTIMELIMIT';
|
|
41
|
+
err.spfResult = {
|
|
42
|
+
error: 'temperror',
|
|
43
|
+
text: 'SPF evaluation time limit exceeded'
|
|
44
|
+
};
|
|
45
|
+
return err;
|
|
46
|
+
};
|
|
47
|
+
|
|
22
48
|
/**
|
|
23
49
|
* Dual-stack DNS resolver for SPF A/AAAA mechanism queries
|
|
24
50
|
*
|
|
@@ -37,13 +63,17 @@ const formatHeaders = result => {
|
|
|
37
63
|
* - AAAA query returns: ENODATA (empty)
|
|
38
64
|
* - Result: Returns empty AAAA array (no match), but does NOT count as void
|
|
39
65
|
*
|
|
66
|
+
* RFC 7208 section 4.6.4 counts the query for the client's address type only, so this is used
|
|
67
|
+
* in the default mode only. `state.clientVoid` is set if that query alone was void.
|
|
68
|
+
*
|
|
40
69
|
* @param {Function} resolver - Base DNS resolver function
|
|
41
70
|
* @param {String} domain - Domain to query
|
|
42
71
|
* @param {Object} opts - Options object with clientIpType (4 or 6)
|
|
72
|
+
* @param {Object} [state] - Updated with the clientVoid flag
|
|
43
73
|
* @returns {Promise<Array>} - Array of IP addresses matching client type
|
|
44
74
|
* @throws {Error} - Throws on real DNS errors or when both A and AAAA are void
|
|
45
75
|
*/
|
|
46
|
-
let dualStackResolver = async (resolver, domain, opts) => {
|
|
76
|
+
let dualStackResolver = async (resolver, domain, opts, state) => {
|
|
47
77
|
const isIPv6 = opts.clientIpType === 6;
|
|
48
78
|
|
|
49
79
|
// Query both A and AAAA records in parallel for efficiency
|
|
@@ -79,6 +109,11 @@ let dualStackResolver = async (resolver, domain, opts) => {
|
|
|
79
109
|
throw voidError;
|
|
80
110
|
}
|
|
81
111
|
|
|
112
|
+
if (state && (isIPv6 ? aaaaIsVoid : aIsVoid)) {
|
|
113
|
+
// RFC 7208 would count this as a void lookup
|
|
114
|
+
state.clientVoid = true;
|
|
115
|
+
}
|
|
116
|
+
|
|
82
117
|
// Return only the records matching the client's IP type
|
|
83
118
|
// Empty arrays are valid (host exists but doesn't match client IP type)
|
|
84
119
|
return isIPv6 ? aaaaRecords : aRecords;
|
|
@@ -96,11 +131,19 @@ let dualStackResolver = async (resolver, domain, opts) => {
|
|
|
96
131
|
* @param {Number} maxResolveCount - Maximum DNS lookups allowed (default: 10)
|
|
97
132
|
* @param {Number} maxVoidCount - Maximum void lookups allowed (default: 2)
|
|
98
133
|
* @param {Boolean} ignoreFirst - If true, don't count the first DNS lookup (used for initial TXT record fetch)
|
|
134
|
+
* @param {Object} [options]
|
|
135
|
+
* @param {Boolean} [options.strict] - If true, follow RFC 7208 exactly (no dual-stack void optimization)
|
|
136
|
+
* @param {Object} [options.ctx] - Shared evaluation state, lax acceptances are added to ctx.warnings
|
|
137
|
+
* @param {Number} [options.deadline] - Timestamp after which no more queries are made
|
|
99
138
|
* @returns {Function} - Rate-limited resolver function with signature: (domain, type, opts) => Promise<Array>
|
|
100
139
|
*/
|
|
101
|
-
let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) => {
|
|
140
|
+
let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst, options) => {
|
|
141
|
+
options = options || {};
|
|
142
|
+
|
|
102
143
|
let resolveCount = 0;
|
|
103
144
|
let voidCount = 0;
|
|
145
|
+
// void lookups as RFC 7208 counts them, without the dual-stack optimization
|
|
146
|
+
let rfcVoidCount = 0;
|
|
104
147
|
|
|
105
148
|
let subResolveCounts = {};
|
|
106
149
|
let firstCounted = !ignoreFirst;
|
|
@@ -108,7 +151,18 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
|
|
|
108
151
|
maxResolveCount = maxResolveCount || MAX_RESOLVE_COUNT;
|
|
109
152
|
maxVoidCount = maxVoidCount || MAX_VOID_COUNT;
|
|
110
153
|
|
|
154
|
+
let checkRfcVoidCount = () => {
|
|
155
|
+
if (rfcVoidCount > maxVoidCount && options.ctx && options.ctx.warnings) {
|
|
156
|
+
// strict mode would have returned permerror here
|
|
157
|
+
options.ctx.warnings.add('void-lookup-limit');
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
111
161
|
let resolverFunc = async (domain, type, opts) => {
|
|
162
|
+
if (options.deadline && Date.now() >= options.deadline) {
|
|
163
|
+
throw timeLimitError();
|
|
164
|
+
}
|
|
165
|
+
|
|
112
166
|
// Increment DNS lookup counter
|
|
113
167
|
// Note: Dual-stack queries (A+AAAA) still count as 1 lookup
|
|
114
168
|
if (firstCounted) {
|
|
@@ -130,7 +184,8 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
|
|
|
130
184
|
// Validate domain name format before querying
|
|
131
185
|
// This is a lenient check to pass test suites and prevent obvious invalid queries
|
|
132
186
|
try {
|
|
133
|
-
|
|
187
|
+
// strict mode also accepts every name that follows the domain-spec rules of RFC 7208
|
|
188
|
+
if (!/^([\x20-\x2D\x2f-\x7e]+\.)+[a-z]+[a-z\-0-9]*$/i.test(domain) && !(options.strict && isValidTargetName(domain))) {
|
|
134
189
|
throw new Error('Failed to validate domain');
|
|
135
190
|
}
|
|
136
191
|
} catch (err) {
|
|
@@ -142,24 +197,45 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
|
|
|
142
197
|
}
|
|
143
198
|
|
|
144
199
|
// Execute DNS query with dual-stack optimization for A/AAAA queries
|
|
200
|
+
let state = {};
|
|
145
201
|
try {
|
|
146
202
|
// Use dual-stack resolver when:
|
|
147
203
|
// 1. Query type is A or AAAA (address lookups)
|
|
148
204
|
// 2. Client IP type is provided (4 for IPv4, 6 for IPv6)
|
|
205
|
+
// 3. Not in strict mode, RFC 7208 only looks at the query for the client's address type
|
|
149
206
|
// This prevents single-stack hosts from being counted as void lookups
|
|
150
|
-
|
|
151
|
-
|
|
207
|
+
let result;
|
|
208
|
+
if (!options.strict && opts?.clientIpType && (type === 'A' || type === 'AAAA')) {
|
|
209
|
+
result = await dualStackResolver(resolver, domain, opts, state);
|
|
152
210
|
} else {
|
|
153
211
|
// Standard single-query resolution for other record types (TXT, MX, PTR, etc.) and A if no client info provided.
|
|
154
|
-
|
|
212
|
+
result = await resolver(domain, type);
|
|
155
213
|
}
|
|
214
|
+
|
|
215
|
+
if (state.clientVoid) {
|
|
216
|
+
rfcVoidCount++;
|
|
217
|
+
checkRfcVoidCount();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return result;
|
|
156
221
|
} catch (err) {
|
|
222
|
+
if (!err || typeof err !== 'object') {
|
|
223
|
+
// a custom resolver rejected with something other than an error object
|
|
224
|
+
let wrapped = new Error(`DNS error when resolving ${domain}: ${err}`);
|
|
225
|
+
wrapped.spfResult = {
|
|
226
|
+
error: 'temperror',
|
|
227
|
+
text: wrapped.message
|
|
228
|
+
};
|
|
229
|
+
throw wrapped;
|
|
230
|
+
}
|
|
231
|
+
|
|
157
232
|
switch (err.code) {
|
|
158
233
|
case 'ENOTFOUND': // Domain does not exist
|
|
159
234
|
case 'ENODATA': {
|
|
160
235
|
// Domain exists but has no records of this type
|
|
161
236
|
// Increment void lookup counter
|
|
162
237
|
voidCount++;
|
|
238
|
+
rfcVoidCount++;
|
|
163
239
|
if (voidCount > maxVoidCount) {
|
|
164
240
|
err.spfResult = {
|
|
165
241
|
error: 'permerror',
|
|
@@ -167,6 +243,7 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
|
|
|
167
243
|
};
|
|
168
244
|
throw err;
|
|
169
245
|
}
|
|
246
|
+
checkRfcVoidCount();
|
|
170
247
|
// Return empty array to continue SPF evaluation
|
|
171
248
|
return [];
|
|
172
249
|
}
|
|
@@ -187,8 +264,27 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
|
|
|
187
264
|
};
|
|
188
265
|
throw err;
|
|
189
266
|
|
|
267
|
+
case 'ESPFTIMELIMIT':
|
|
268
|
+
throw err;
|
|
269
|
+
|
|
270
|
+
case 'EBADNAME':
|
|
271
|
+
// the resolver rejected the name (for example a label longer than 63 characters),
|
|
272
|
+
// handled like the names that fail the format check above
|
|
273
|
+
err.spfResult = {
|
|
274
|
+
error: 'permerror',
|
|
275
|
+
text: `Invalid domain ${domain}`
|
|
276
|
+
};
|
|
277
|
+
throw err;
|
|
278
|
+
|
|
190
279
|
default:
|
|
191
|
-
//
|
|
280
|
+
// Any other DNS error (SERVFAIL, connection refused, bad response, ...) is a
|
|
281
|
+
// temporary error (RFC 7208 sections 4.4 and 5), also inside include and redirect
|
|
282
|
+
if (!err.spfResult) {
|
|
283
|
+
err.spfResult = {
|
|
284
|
+
error: 'temperror',
|
|
285
|
+
text: err.message
|
|
286
|
+
};
|
|
287
|
+
}
|
|
192
288
|
throw err;
|
|
193
289
|
}
|
|
194
290
|
}
|
|
@@ -210,6 +306,50 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
|
|
|
210
306
|
return resolverFunc;
|
|
211
307
|
};
|
|
212
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Wraps a DNS resolver so that each question is sent once per SPF evaluation. The lookup limits
|
|
311
|
+
* are counted by limitedResolver before it calls this, so every query still counts. Nothing
|
|
312
|
+
* mutable is handed out twice: limitedResolver writes spfResult onto the error it gets, and
|
|
313
|
+
* the MX list is sorted in place, so every caller gets its own copy of the error or the list
|
|
314
|
+
*
|
|
315
|
+
* @param {Function} resolver Base DNS resolver
|
|
316
|
+
* @returns {Function} Resolver with the same signature
|
|
317
|
+
*/
|
|
318
|
+
const memoizeResolver = resolver => {
|
|
319
|
+
const answers = new Map();
|
|
320
|
+
|
|
321
|
+
const copyError = err => {
|
|
322
|
+
if (!err || typeof err !== 'object') {
|
|
323
|
+
// not an error object, there is nothing on it to share
|
|
324
|
+
return err;
|
|
325
|
+
}
|
|
326
|
+
let copy = Object.assign(new Error(err.message), err);
|
|
327
|
+
copy.code = err.code;
|
|
328
|
+
if (err.spfResult && typeof err.spfResult === 'object') {
|
|
329
|
+
copy.spfResult = Object.assign({}, err.spfResult);
|
|
330
|
+
}
|
|
331
|
+
return copy;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
return (name, type) => {
|
|
335
|
+
let key = `${type}:${name}`;
|
|
336
|
+
let answer = answers.get(key);
|
|
337
|
+
if (!answer) {
|
|
338
|
+
// a resolver that throws synchronously still does so, and is not cached
|
|
339
|
+
answer = Promise.resolve(resolver(name, type));
|
|
340
|
+
// failures are reported to the callers below, never as an unhandled rejection
|
|
341
|
+
answer.catch(() => false);
|
|
342
|
+
answers.set(key, answer);
|
|
343
|
+
}
|
|
344
|
+
return answer.then(
|
|
345
|
+
value => (Array.isArray(value) ? value.slice() : value),
|
|
346
|
+
err => {
|
|
347
|
+
throw copyError(err);
|
|
348
|
+
}
|
|
349
|
+
);
|
|
350
|
+
};
|
|
351
|
+
};
|
|
352
|
+
|
|
213
353
|
/**
|
|
214
354
|
*
|
|
215
355
|
* @param {Object} opts
|
|
@@ -219,30 +359,57 @@ let limitedResolver = (resolver, maxResolveCount, maxVoidCount, ignoreFirst) =>
|
|
|
219
359
|
* @param {String} [opts.mta] Hostname of the MTA or MX server that processes the message
|
|
220
360
|
* @param {String} [opts.maxResolveCount=10] Maximum DNS lookups allowed
|
|
221
361
|
* @param {String} [opts.maxVoidCount=2] Maximum empty DNS lookups allowed
|
|
362
|
+
* @param {Number} [opts.maxElapsedTime] Maximum time in milliseconds for the whole evaluation, temperror if exceeded (no limit by default)
|
|
363
|
+
* @param {Boolean} [opts.strict=false] If true, follow RFC 7208 exactly instead of the lenient default
|
|
222
364
|
*/
|
|
223
365
|
const verify = async opts => {
|
|
224
|
-
let { sender, ip, helo, mta, maxResolveCount, maxVoidCount, resolver } = opts || {};
|
|
366
|
+
let { sender, ip, helo, mta, maxResolveCount, maxVoidCount, resolver, strict, maxElapsedTime } = opts || {};
|
|
367
|
+
|
|
368
|
+
strict = !!strict;
|
|
225
369
|
|
|
226
370
|
mta = mta || os.hostname();
|
|
227
371
|
|
|
372
|
+
// a null reverse-path means that the HELO identity is checked (RFC 7208 section 2.4)
|
|
373
|
+
let nullSender = !sender;
|
|
374
|
+
|
|
228
375
|
sender = sender || `postmaster@${helo}`;
|
|
229
376
|
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
377
|
+
// IPv4-mapped IPv6 addresses (::ffff:0:0/96, dotted or hex form) are IPv4 clients (RFC 7208 section 5),
|
|
378
|
+
// no other IPv6 address is ever treated as IPv4
|
|
379
|
+
let parsedIp = ip ? parseIp(ip) : null;
|
|
380
|
+
if (parsedIp) {
|
|
381
|
+
ip = ip.toString().trim();
|
|
382
|
+
}
|
|
383
|
+
if (parsedIp && parsedIp.kind() === 'ipv6' && parsedIp.isIPv4MappedAddress()) {
|
|
384
|
+
parsedIp = parsedIp.toIPv4Address();
|
|
385
|
+
ip = parsedIp.toString();
|
|
234
386
|
}
|
|
235
387
|
|
|
236
|
-
|
|
388
|
+
// the local-part may be a quoted string that contains "@", the domain follows the last "@"
|
|
389
|
+
let atPos = sender.lastIndexOf('@');
|
|
237
390
|
if (atPos < 0) {
|
|
238
391
|
sender = `postmaster@${sender}`;
|
|
239
392
|
} else if (atPos === 0) {
|
|
240
393
|
sender = `postmaster${sender}`;
|
|
241
394
|
}
|
|
395
|
+
atPos = sender.lastIndexOf('@');
|
|
396
|
+
|
|
397
|
+
let domain =
|
|
398
|
+
sender
|
|
399
|
+
.substr(atPos + 1)
|
|
400
|
+
.toLowerCase()
|
|
401
|
+
.trim() || '-';
|
|
402
|
+
|
|
403
|
+
// RFC 8616 section 4: U-labels are converted to A-labels before SPF validation, this also
|
|
404
|
+
// applies to the sender domain used in macro expansion
|
|
405
|
+
let evalSender = sender;
|
|
406
|
+
if (/[^\x00-\x7F]/.test(domain)) {
|
|
407
|
+
domain = toALabel(domain);
|
|
408
|
+
evalSender = `${sender.substr(0, atPos)}@${domain}`;
|
|
409
|
+
}
|
|
242
410
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
resolver = resolver || dns.promises.resolve;
|
|
411
|
+
// one DNS query per question for the whole evaluation, includes and redirects included
|
|
412
|
+
resolver = memoizeResolver(resolver || dns.promises.resolve);
|
|
246
413
|
|
|
247
414
|
let status = {
|
|
248
415
|
result: 'neutral',
|
|
@@ -254,10 +421,35 @@ const verify = async opts => {
|
|
|
254
421
|
}
|
|
255
422
|
};
|
|
256
423
|
|
|
257
|
-
|
|
424
|
+
// shared evaluation state
|
|
425
|
+
let ctx = { warnings: new Set(), usedLocalPart: false };
|
|
426
|
+
|
|
427
|
+
let deadline = typeof maxElapsedTime === 'number' && maxElapsedTime > 0 ? Date.now() + maxElapsedTime : false;
|
|
428
|
+
let resolverOptions = { strict, ctx, deadline };
|
|
429
|
+
|
|
430
|
+
let verifyResolver = limitedResolver(resolver, maxResolveCount, maxVoidCount, true, resolverOptions);
|
|
431
|
+
|
|
432
|
+
// the explanation lookup is not counted toward the lookup limits (RFC 7208 section 4.6.4)
|
|
433
|
+
let expResolver = async (name, type) => {
|
|
434
|
+
if (deadline && Date.now() >= deadline) {
|
|
435
|
+
throw timeLimitError();
|
|
436
|
+
}
|
|
437
|
+
return await resolver(name, type);
|
|
438
|
+
};
|
|
258
439
|
|
|
259
440
|
let result;
|
|
441
|
+
let timer;
|
|
260
442
|
try {
|
|
443
|
+
if (!parsedIp) {
|
|
444
|
+
// <ip> is a required argument of check_host() (RFC 7208 section 4.1)
|
|
445
|
+
let err = new Error('Invalid client IP');
|
|
446
|
+
err.spfResult = {
|
|
447
|
+
error: 'temperror',
|
|
448
|
+
text: 'missing or invalid client IP address'
|
|
449
|
+
};
|
|
450
|
+
throw err;
|
|
451
|
+
}
|
|
452
|
+
|
|
261
453
|
let validation = domainSchema.validate(domain);
|
|
262
454
|
if (validation.error) {
|
|
263
455
|
let err = validation.error;
|
|
@@ -268,18 +460,35 @@ const verify = async opts => {
|
|
|
268
460
|
throw err;
|
|
269
461
|
}
|
|
270
462
|
|
|
271
|
-
|
|
272
|
-
sender,
|
|
463
|
+
let evaluation = spfVerify(domain, {
|
|
464
|
+
sender: evalSender,
|
|
273
465
|
ip,
|
|
274
466
|
mta,
|
|
275
467
|
helo,
|
|
468
|
+
strict,
|
|
469
|
+
ctx,
|
|
470
|
+
expResolver,
|
|
276
471
|
|
|
277
472
|
// generate DNS handler
|
|
278
473
|
resolver: verifyResolver,
|
|
279
474
|
|
|
280
|
-
// allow to create sub resolvers
|
|
281
|
-
createSubResolver:
|
|
475
|
+
// allow to create sub resolvers. Address lookups of MX and PTR names do not count as void lookups
|
|
476
|
+
createSubResolver: subOptions =>
|
|
477
|
+
limitedResolver(resolver, maxResolveCount, subOptions && subOptions.ignoreVoid ? Infinity : maxVoidCount, false, resolverOptions)
|
|
282
478
|
});
|
|
479
|
+
|
|
480
|
+
if (deadline) {
|
|
481
|
+
// RFC 7208 section 4.6.4: a limit on the elapsed time, temperror if exceeded
|
|
482
|
+
evaluation.catch(() => false);
|
|
483
|
+
result = await Promise.race([
|
|
484
|
+
evaluation,
|
|
485
|
+
new Promise((resolve, reject) => {
|
|
486
|
+
timer = setTimeout(() => reject(timeLimitError()), Math.max(deadline - Date.now(), 0));
|
|
487
|
+
})
|
|
488
|
+
]);
|
|
489
|
+
} else {
|
|
490
|
+
result = await evaluation;
|
|
491
|
+
}
|
|
283
492
|
} catch (err) {
|
|
284
493
|
if (err.spfResult) {
|
|
285
494
|
result = err.spfResult;
|
|
@@ -289,6 +498,8 @@ const verify = async opts => {
|
|
|
289
498
|
text: err.message
|
|
290
499
|
};
|
|
291
500
|
}
|
|
501
|
+
} finally {
|
|
502
|
+
clearTimeout(timer);
|
|
292
503
|
}
|
|
293
504
|
|
|
294
505
|
if (result && typeof result === 'object') {
|
|
@@ -353,13 +564,24 @@ const verify = async opts => {
|
|
|
353
564
|
break;
|
|
354
565
|
}
|
|
355
566
|
|
|
567
|
+
if (strict) {
|
|
568
|
+
// RFC 8601 section 2.7.2: only the identity that was checked is reported, and the
|
|
569
|
+
// local-part only if the policy has provisions for it
|
|
570
|
+
status.smtp = nullSender ? { helo } : { mailfrom: ctx.usedLocalPart ? sender : sender.substr(atPos + 1) };
|
|
571
|
+
}
|
|
572
|
+
|
|
356
573
|
if (result.rr) {
|
|
357
574
|
response.rr = result.rr;
|
|
358
575
|
}
|
|
359
576
|
|
|
360
577
|
response.status = status;
|
|
361
|
-
response.header = formatHeaders(response
|
|
362
|
-
|
|
578
|
+
response.header = formatHeaders(response, {
|
|
579
|
+
'client-ip': parsedIp ? ip : false,
|
|
580
|
+
// RFC 7208 section 9.1: "envelope-from" if the "MAIL FROM" identity was checked
|
|
581
|
+
'envelope-from': nullSender ? false : sender,
|
|
582
|
+
helo
|
|
583
|
+
});
|
|
584
|
+
response.info = formatAuthHeaderRow('spf', status, { strict });
|
|
363
585
|
|
|
364
586
|
if (typeof response.status.comment === 'boolean') {
|
|
365
587
|
delete response.status.comment;
|
|
@@ -369,6 +591,16 @@ const verify = async opts => {
|
|
|
369
591
|
response.lookups = result.lookups;
|
|
370
592
|
}
|
|
371
593
|
|
|
594
|
+
if (result.explanation && status.result === 'fail') {
|
|
595
|
+
// explanation string published by the domain owner (RFC 7208 section 6.2), third party text
|
|
596
|
+
response.explanation = result.explanation;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (ctx.warnings.size && status.result !== 'permerror') {
|
|
600
|
+
// the default mode accepted something that strict mode would have rejected
|
|
601
|
+
response.warnings = Array.from(ctx.warnings);
|
|
602
|
+
}
|
|
603
|
+
|
|
372
604
|
return response;
|
|
373
605
|
};
|
|
374
606
|
|
package/lib/spf/macro.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const net = require('net');
|
|
4
|
-
const ipaddr = require('ipaddr.js');
|
|
5
3
|
const os = require('node:os');
|
|
4
|
+
const { parseIp } = require('./syntax');
|
|
5
|
+
|
|
6
|
+
// RFC 3986 unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
|
|
7
|
+
const urlEscape = value =>
|
|
8
|
+
Array.from(Buffer.from(value, 'utf-8'))
|
|
9
|
+
.map(byte => {
|
|
10
|
+
let chr = String.fromCharCode(byte);
|
|
11
|
+
if (/^[A-Za-z0-9\-._~]$/.test(chr)) {
|
|
12
|
+
return chr;
|
|
13
|
+
}
|
|
14
|
+
return `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
15
|
+
})
|
|
16
|
+
.join('');
|
|
6
17
|
|
|
7
18
|
/**
|
|
8
19
|
* Renders macro into an output string.
|
|
@@ -13,20 +24,31 @@ const os = require('node:os');
|
|
|
13
24
|
* @param {String} values.helo Client's HELO/EHLO domain
|
|
14
25
|
* @param {String} [values.mta] Hostname of the MTA or MX server that processes the message
|
|
15
26
|
* @param {String} [values.domain] Domain currently being evaluated by check_host(), changes on include/redirect recursion (RFC 7208 sections 5.2, 6.1)
|
|
27
|
+
* @param {String} [values.p] Validated domain name of the client IP (RFC 7208 section 7.3), "unknown" if not set
|
|
28
|
+
* @param {Object} [values.addr] `values.ip` parsed with parseIp(), if the caller already has it
|
|
16
29
|
*/
|
|
17
30
|
const macro = (input, values) => {
|
|
18
31
|
input = (input || '').toString();
|
|
19
32
|
|
|
20
|
-
|
|
33
|
+
if (input.indexOf('%') < 0) {
|
|
34
|
+
// nothing to expand
|
|
35
|
+
return input;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let { sender, ip, helo, mta, domain, p, addr } = values || {};
|
|
21
39
|
|
|
22
40
|
sender = (sender || '').toString();
|
|
23
41
|
ip = (ip || '').toString();
|
|
24
42
|
helo = (helo || '').toString();
|
|
25
43
|
|
|
26
|
-
|
|
44
|
+
// the local-part may be a quoted string that contains "@", the domain follows the last "@"
|
|
45
|
+
let atPos = sender.lastIndexOf('@');
|
|
27
46
|
let senderLocal = atPos >= 0 ? sender.substr(0, atPos) : '';
|
|
28
47
|
let senderDomain = atPos >= 0 ? sender.substr(atPos + 1) : sender;
|
|
29
|
-
|
|
48
|
+
|
|
49
|
+
// the caller can pass the client IP already parsed, it is then not parsed again
|
|
50
|
+
let parsedIp = addr || parseIp(ip);
|
|
51
|
+
let vStr = parsedIp ? (parsedIp.kind() === 'ipv4' ? 'in-addr' : 'ip6') : '';
|
|
30
52
|
|
|
31
53
|
return input.replace(/%%|%_|%-|%\{([^}]+)\}|%/gi, (m, c) => {
|
|
32
54
|
if (m === '%') {
|
|
@@ -65,18 +87,25 @@ const macro = (input, values) => {
|
|
|
65
87
|
break;
|
|
66
88
|
|
|
67
89
|
case 'o':
|
|
68
|
-
case 'p': // validated domain name is unsupported, instead we use the unvalidated sender domain
|
|
69
90
|
curval = senderDomain;
|
|
70
91
|
break;
|
|
71
92
|
|
|
93
|
+
case 'p':
|
|
94
|
+
// validated domain name of the client IP, resolved by the caller (RFC 7208 section 7.3)
|
|
95
|
+
curval = p || 'unknown';
|
|
96
|
+
break;
|
|
97
|
+
|
|
72
98
|
case 'd':
|
|
73
99
|
// current <domain> of check_host(), not the sender domain (these only match at the top level)
|
|
74
100
|
curval = domain || senderDomain;
|
|
75
101
|
break;
|
|
76
102
|
|
|
77
103
|
case 'i':
|
|
78
|
-
|
|
79
|
-
|
|
104
|
+
if (!parsedIp) {
|
|
105
|
+
throw new Error(`Invalid IP address ${ip}`);
|
|
106
|
+
}
|
|
107
|
+
curval = parsedIp.toNormalizedString();
|
|
108
|
+
if (parsedIp.kind() === 'ipv6') {
|
|
80
109
|
curval = curval
|
|
81
110
|
.split(':')
|
|
82
111
|
.flatMap(p => {
|
|
@@ -94,11 +123,14 @@ const macro = (input, values) => {
|
|
|
94
123
|
break;
|
|
95
124
|
|
|
96
125
|
case 'h':
|
|
97
|
-
curval = helo || `[${
|
|
126
|
+
curval = helo || `[${parsedIp ? parsedIp.toString() : ip}]`;
|
|
98
127
|
break;
|
|
99
128
|
|
|
100
129
|
case 'c':
|
|
101
|
-
|
|
130
|
+
if (!parsedIp) {
|
|
131
|
+
throw new Error(`Invalid IP address ${ip}`);
|
|
132
|
+
}
|
|
133
|
+
curval = parsedIp.toString();
|
|
102
134
|
break;
|
|
103
135
|
|
|
104
136
|
case 'r':
|
|
@@ -134,8 +166,8 @@ const macro = (input, values) => {
|
|
|
134
166
|
nr = parseInt(nr, 10);
|
|
135
167
|
}
|
|
136
168
|
|
|
137
|
-
// find the reverse transformer
|
|
138
|
-
if (chars.length && chars[0] === 'r') {
|
|
169
|
+
// find the reverse transformer, ABNF literals are case-insensitive, so "R" is the same as "r"
|
|
170
|
+
if (chars.length && chars[0].toLowerCase() === 'r') {
|
|
139
171
|
chars.shift();
|
|
140
172
|
reversed = true;
|
|
141
173
|
}
|
|
@@ -163,7 +195,12 @@ const macro = (input, values) => {
|
|
|
163
195
|
}
|
|
164
196
|
|
|
165
197
|
// no matter the expansion delimiter, values are joined with dots
|
|
166
|
-
|
|
198
|
+
curval = curval.join('.');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// uppercase macros expand exactly as their lowercase equivalents, and are then URL escaped (RFC 7208 section 7.3)
|
|
202
|
+
if (macroChar !== macroChar.toLowerCase()) {
|
|
203
|
+
curval = urlEscape(curval);
|
|
167
204
|
}
|
|
168
205
|
|
|
169
206
|
return curval;
|