mailauth 6.0.0 → 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 +12 -0
- package/README.md +111 -39
- 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 +80 -33
- package/docs/mta-sts.md +140 -42
- package/docs/spf.md +69 -4
- package/index.d.ts +495 -43
- 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 +140 -47
- package/lib/dmarc/index.d.ts +29 -2
- package/lib/dmarc/verify.js +120 -44
- package/lib/mailauth.js +75 -36
- 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 +522 -116
- package/package.json +1 -1
package/lib/spf/spf-verify.js
CHANGED
|
@@ -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
|
|
8
|
-
const {
|
|
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
|
-
|
|
31
|
+
let prefix;
|
|
32
|
+
let rangeMatch = range.match(/^(.*)\/(\d+)$/);
|
|
33
|
+
if (rangeMatch) {
|
|
14
34
|
// seems CIDR
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 (
|
|
269
|
-
|
|
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
|
|
287
|
-
if (
|
|
288
|
-
|
|
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 =
|
|
333
|
-
|
|
334
|
-
a = macro(a, macroValues);
|
|
497
|
+
let cidr = isIPv6 ? cidr6 : cidr4;
|
|
335
498
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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(
|
|
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 =
|
|
520
|
+
let cidr = isIPv6 ? cidr6 : cidr4;
|
|
359
521
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
380
|
-
clientIpType:
|
|
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
|
-
|
|
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
|
|
404
|
-
|
|
405
|
-
|
|
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(
|
|
568
|
+
let responses = await resolver(target.name, 'A');
|
|
411
569
|
if (responses && responses.length) {
|
|
412
|
-
return { type, val:
|
|
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
|
-
|
|
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 =
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
//
|
|
452
|
-
let
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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 };
|