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
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('node:net');
|
|
4
|
+
const ipaddr = require('ipaddr.js');
|
|
5
|
+
|
|
6
|
+
// RFC 7208 section 7.1
|
|
7
|
+
const MACRO_EXPAND = /^([a-z])(\d*)(r?)([.\-+,/_=]*)$/i;
|
|
8
|
+
const MACRO_LETTERS = 'slodiphcrtv';
|
|
9
|
+
// allowed only in "exp" text (RFC 7208 section 7.2)
|
|
10
|
+
const EXP_ONLY_LETTERS = 'crt';
|
|
11
|
+
|
|
12
|
+
// toplabel = ( *alphanum ALPHA *alphanum ) / ( 1*alphanum "-" *( alphanum / "-" ) alphanum )
|
|
13
|
+
const TOPLABEL = /^(?:[a-z0-9]*[a-z][a-z0-9]*|[a-z0-9]+-[a-z0-9-]*[a-z0-9])$/i;
|
|
14
|
+
|
|
15
|
+
// name = ALPHA *( ALPHA / DIGIT / "-" / "_" / "." ) (RFC 7208 section 4.6.1)
|
|
16
|
+
const MODIFIER_NAME = /^[a-z][a-z0-9\-_.]*$/i;
|
|
17
|
+
|
|
18
|
+
// qnum based dotted quad (RFC 7208 section 5.6), no leading zeros
|
|
19
|
+
const QNUM = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])';
|
|
20
|
+
const IP4_NETWORK = new RegExp(`^${QNUM}(?:\\.${QNUM}){3}$`);
|
|
21
|
+
|
|
22
|
+
// ip4-cidr-length = "/" ("0" / %x31-39 0*1DIGIT), ip6-cidr-length = "/" ("0" / %x31-39 0*2DIGIT)
|
|
23
|
+
const IP4_CIDR = /^(?:0|[1-9][0-9]?)$/;
|
|
24
|
+
const IP6_CIDR = /^(?:0|[1-9][0-9]{0,2})$/;
|
|
25
|
+
|
|
26
|
+
const MAX_NAME_LENGTH = 253;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parses an IP address string. ipaddr.js 2.x reads "::a.b.c.d" as the IPv4-mapped
|
|
30
|
+
* "::ffff:a.b.c.d", so IPv6 input is first canonicalized by the WHATWG URL parser,
|
|
31
|
+
* which spells every address in hex groups.
|
|
32
|
+
*
|
|
33
|
+
* @param {String} value IP address
|
|
34
|
+
* @returns {Object|null} ipaddr.js address object, or null if the value is not an IP address
|
|
35
|
+
*/
|
|
36
|
+
const parseIp = value => {
|
|
37
|
+
value = (value || '').toString().trim();
|
|
38
|
+
switch (net.isIP(value)) {
|
|
39
|
+
case 4:
|
|
40
|
+
return ipaddr.parse(value);
|
|
41
|
+
case 6: {
|
|
42
|
+
// a zone index is local to the host, it is not part of the address
|
|
43
|
+
let address = value.replace(/%.*$/, '');
|
|
44
|
+
try {
|
|
45
|
+
let canonical = new URL(`http://[${address}]/`).hostname.replace(/^\[|\]$/g, '');
|
|
46
|
+
return ipaddr.parse(canonical);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
default:
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks if an address object is within a network
|
|
58
|
+
*
|
|
59
|
+
* @param {Object} addr ipaddr.js address object
|
|
60
|
+
* @param {Object} network ipaddr.js address object for the network
|
|
61
|
+
* @param {Number} prefix Prefix length
|
|
62
|
+
* @returns {Boolean}
|
|
63
|
+
*/
|
|
64
|
+
const ipInNetwork = (addr, network, prefix) => {
|
|
65
|
+
if (!addr || !network || addr.kind() !== network.kind()) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return addr.match(network, prefix);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Removes a single trailing dot and truncates names longer than 253 characters from the
|
|
73
|
+
* left by removing whole labels (RFC 7208 section 7.3)
|
|
74
|
+
*
|
|
75
|
+
* @param {String} name Domain name
|
|
76
|
+
* @returns {String}
|
|
77
|
+
*/
|
|
78
|
+
const normalizeTargetName = name => {
|
|
79
|
+
name = (name || '').toString().replace(/\.$/, '');
|
|
80
|
+
while (name.length > MAX_NAME_LENGTH && name.indexOf('.') >= 0) {
|
|
81
|
+
name = name.substring(name.indexOf('.') + 1);
|
|
82
|
+
}
|
|
83
|
+
return name;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Checks if a (macro expanded) target name can be used as a DNS query name: at least two
|
|
88
|
+
* labels, each 1 to 63 visible ASCII characters or spaces (spaces can be produced by "%_"),
|
|
89
|
+
* and a top label that follows the "toplabel" rule of RFC 7208 section 7.1
|
|
90
|
+
*
|
|
91
|
+
* @param {String} name Domain name, without the trailing dot
|
|
92
|
+
* @returns {Boolean}
|
|
93
|
+
*/
|
|
94
|
+
const isValidTargetName = name => {
|
|
95
|
+
if (typeof name !== 'string' || !name || name.length > MAX_NAME_LENGTH) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
let labels = name.split('.');
|
|
99
|
+
if (labels.length < 2) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
for (let label of labels) {
|
|
103
|
+
if (!label || label.length > 63 || !/^[\x20-\x2D\x2F-\x7E]+$/.test(label)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return TOPLABEL.test(labels[labels.length - 1]);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const syntaxError = message => {
|
|
111
|
+
let err = new Error(message);
|
|
112
|
+
err.spfSyntax = true;
|
|
113
|
+
return err;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validates a macro-string (RFC 7208 section 7.1)
|
|
118
|
+
*
|
|
119
|
+
* @param {String} input Macro string
|
|
120
|
+
* @param {Object} [options]
|
|
121
|
+
* @param {Boolean} [options.explain] If true, validates an explain-string: SP is allowed and so are the c, r and t macros
|
|
122
|
+
* @param {Boolean} [options.expLetters] If true, allows the c, r and t macros
|
|
123
|
+
* @returns {Array} List of tokens, each either { literal } or { expand }
|
|
124
|
+
* @throws {Error} On syntax errors
|
|
125
|
+
*/
|
|
126
|
+
const parseMacroString = (input, options) => {
|
|
127
|
+
options = options || {};
|
|
128
|
+
input = (input || '').toString();
|
|
129
|
+
|
|
130
|
+
let tokens = [];
|
|
131
|
+
let pos = 0;
|
|
132
|
+
while (pos < input.length) {
|
|
133
|
+
let chr = input.charAt(pos);
|
|
134
|
+
|
|
135
|
+
if (chr === '%') {
|
|
136
|
+
let next = input.charAt(pos + 1);
|
|
137
|
+
if (next === '%' || next === '_' || next === '-') {
|
|
138
|
+
tokens.push({ expand: chr + next });
|
|
139
|
+
pos += 2;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (next !== '{') {
|
|
144
|
+
throw syntaxError('Unexpected % in macro');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let end = input.indexOf('}', pos + 2);
|
|
148
|
+
if (end < 0) {
|
|
149
|
+
throw syntaxError('Unterminated macro');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let body = input.substring(pos + 2, end);
|
|
153
|
+
let match = body.match(MACRO_EXPAND);
|
|
154
|
+
if (!match) {
|
|
155
|
+
throw syntaxError(`Invalid macro "%{${body}}"`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let letter = match[1].toLowerCase();
|
|
159
|
+
if (!MACRO_LETTERS.includes(letter)) {
|
|
160
|
+
throw syntaxError(`Unknown macro letter "${match[1]}"`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (EXP_ONLY_LETTERS.includes(letter) && !options.explain && !options.expLetters) {
|
|
164
|
+
throw syntaxError(`Macro letter "${match[1]}" is only allowed in explanation text`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (match[2] && Number(match[2]) === 0) {
|
|
168
|
+
throw syntaxError(`Invalid macro "%{${body}}", the number of parts must be nonzero`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
tokens.push({ expand: input.substring(pos, end + 1) });
|
|
172
|
+
pos = end + 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let code = input.charCodeAt(pos);
|
|
177
|
+
// macro-literal = %x21-24 / %x26-7E, explain-string also allows SP
|
|
178
|
+
if ((code >= 0x21 && code <= 0x7e) || (code === 0x20 && options.explain)) {
|
|
179
|
+
if (tokens.length && typeof tokens[tokens.length - 1].literal === 'string') {
|
|
180
|
+
tokens[tokens.length - 1].literal += input.charAt(pos);
|
|
181
|
+
} else {
|
|
182
|
+
tokens.push({ literal: input.charAt(pos) });
|
|
183
|
+
}
|
|
184
|
+
pos++;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
throw syntaxError('Invalid character in macro string');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return tokens;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Validates a domain-spec: domain-spec = macro-string domain-end,
|
|
196
|
+
* domain-end = ( "." toplabel [ "." ] ) / macro-expand
|
|
197
|
+
*
|
|
198
|
+
* @param {String} input
|
|
199
|
+
* @throws {Error} On syntax errors
|
|
200
|
+
*/
|
|
201
|
+
const validateDomainSpec = input => {
|
|
202
|
+
let tokens = parseMacroString(input);
|
|
203
|
+
if (!tokens.length) {
|
|
204
|
+
throw syntaxError('Empty domain-spec');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let last = tokens[tokens.length - 1];
|
|
208
|
+
if (last.expand) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// the domain-spec ends with literal text, so it must end with "." toplabel [ "." ]
|
|
213
|
+
let literal = last.literal.replace(/\.$/, '');
|
|
214
|
+
let dotPos = literal.lastIndexOf('.');
|
|
215
|
+
if (dotPos < 0 || !TOPLABEL.test(literal.substring(dotPos + 1))) {
|
|
216
|
+
throw syntaxError(`Invalid domain-spec "${input}"`);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// a/mx: [ ":" domain-spec ] [ dual-cidr-length ]
|
|
221
|
+
const DUAL_CIDR_TERM = /^(?::(.+?))?(?:\/([0-9]+))?(?:\/\/([0-9]+))?$/;
|
|
222
|
+
|
|
223
|
+
const validateCidr = (value, re, max) => {
|
|
224
|
+
if (typeof value !== 'string') {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!re.test(value) || Number(value) > max) {
|
|
228
|
+
throw syntaxError(`Invalid CIDR length "${value}"`);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Validates a single term of an SPF record
|
|
234
|
+
*
|
|
235
|
+
* @param {String} term
|
|
236
|
+
* @param {Object} counters Modifier counters, updated in place
|
|
237
|
+
* @throws {Error} On syntax errors
|
|
238
|
+
*/
|
|
239
|
+
const validateTerm = (term, counters) => {
|
|
240
|
+
let eqPos = term.indexOf('=');
|
|
241
|
+
let sepMatch = term.match(/[:/]/);
|
|
242
|
+
let sepPos = sepMatch ? sepMatch.index : -1;
|
|
243
|
+
|
|
244
|
+
if (eqPos >= 0 && (sepPos < 0 || eqPos < sepPos)) {
|
|
245
|
+
// modifier
|
|
246
|
+
let name = term.substring(0, eqPos);
|
|
247
|
+
let value = term.substring(eqPos + 1);
|
|
248
|
+
if (!MODIFIER_NAME.test(name)) {
|
|
249
|
+
throw syntaxError(`Invalid modifier name "${name}"`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
switch (name.toLowerCase()) {
|
|
253
|
+
case 'redirect':
|
|
254
|
+
case 'exp':
|
|
255
|
+
counters[name.toLowerCase()] = (counters[name.toLowerCase()] || 0) + 1;
|
|
256
|
+
validateDomainSpec(value);
|
|
257
|
+
break;
|
|
258
|
+
default:
|
|
259
|
+
// unknown-modifier = name "=" macro-string, the macro-string may be empty
|
|
260
|
+
parseMacroString(value, { expLetters: true });
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let match = term.match(/^([+\-?~]?)([^:/]*)(.*)$/);
|
|
266
|
+
let mechanism = match[2].toLowerCase();
|
|
267
|
+
let rest = match[3];
|
|
268
|
+
|
|
269
|
+
switch (mechanism) {
|
|
270
|
+
case 'all':
|
|
271
|
+
if (rest) {
|
|
272
|
+
throw syntaxError('Unexpected value for "all"');
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
|
|
276
|
+
case 'include':
|
|
277
|
+
case 'exists':
|
|
278
|
+
if (rest.charAt(0) !== ':') {
|
|
279
|
+
throw syntaxError(`Missing domain-spec for "${mechanism}"`);
|
|
280
|
+
}
|
|
281
|
+
validateDomainSpec(rest.substring(1));
|
|
282
|
+
return;
|
|
283
|
+
|
|
284
|
+
case 'a':
|
|
285
|
+
case 'mx': {
|
|
286
|
+
let parts = rest.match(DUAL_CIDR_TERM);
|
|
287
|
+
if (!parts) {
|
|
288
|
+
throw syntaxError(`Invalid "${mechanism}" term`);
|
|
289
|
+
}
|
|
290
|
+
if (typeof parts[1] === 'string') {
|
|
291
|
+
validateDomainSpec(parts[1]);
|
|
292
|
+
}
|
|
293
|
+
validateCidr(parts[2], IP4_CIDR, 32);
|
|
294
|
+
validateCidr(parts[3], IP6_CIDR, 128);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
case 'ptr':
|
|
299
|
+
if (!rest) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (rest.charAt(0) !== ':') {
|
|
303
|
+
throw syntaxError('Invalid "ptr" term');
|
|
304
|
+
}
|
|
305
|
+
validateDomainSpec(rest.substring(1));
|
|
306
|
+
return;
|
|
307
|
+
|
|
308
|
+
case 'ip4': {
|
|
309
|
+
let parts = rest.match(/^:([^/]+)(?:\/([^/]*))?$/);
|
|
310
|
+
if (!parts || !IP4_NETWORK.test(parts[1])) {
|
|
311
|
+
throw syntaxError('Invalid "ip4" term');
|
|
312
|
+
}
|
|
313
|
+
validateCidr(parts[2], IP4_CIDR, 32);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
case 'ip6': {
|
|
318
|
+
let parts = rest.match(/^:([0-9a-f:.]+)(?:\/([^/]*))?$/i);
|
|
319
|
+
if (!parts || !net.isIPv6(parts[1])) {
|
|
320
|
+
throw syntaxError('Invalid "ip6" term');
|
|
321
|
+
}
|
|
322
|
+
validateCidr(parts[2], IP6_CIDR, 128);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
default:
|
|
327
|
+
throw syntaxError(`Unknown mechanism "${mechanism}"`);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Validates the terms of an SPF record (RFC 7208 section 4.6)
|
|
333
|
+
*
|
|
334
|
+
* @param {Array} terms List of terms (the record without the version section, split on SP)
|
|
335
|
+
* @returns {String|false} Description of the first syntax error, or false if the record is valid
|
|
336
|
+
*/
|
|
337
|
+
const validateTerms = terms => {
|
|
338
|
+
let counters = {};
|
|
339
|
+
try {
|
|
340
|
+
for (let term of terms) {
|
|
341
|
+
validateTerm(term, counters);
|
|
342
|
+
}
|
|
343
|
+
} catch (err) {
|
|
344
|
+
if (err.spfSyntax) {
|
|
345
|
+
return err.message;
|
|
346
|
+
}
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (let modifier of ['redirect', 'exp']) {
|
|
351
|
+
if (counters[modifier] > 1) {
|
|
352
|
+
return `more than 1 ${modifier} found`;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return false;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
module.exports = {
|
|
360
|
+
parseIp,
|
|
361
|
+
ipInNetwork,
|
|
362
|
+
normalizeTargetName,
|
|
363
|
+
isValidTargetName,
|
|
364
|
+
parseMacroString,
|
|
365
|
+
validateTerms
|
|
366
|
+
};
|