mailauth 5.0.3 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/README.md +117 -41
  4. package/bin/mailauth.js +14 -6
  5. package/cli.md +8 -3
  6. package/docs/arc.md +126 -43
  7. package/docs/bimi.md +54 -37
  8. package/docs/dkim.md +79 -40
  9. package/docs/dmarc.md +82 -31
  10. package/docs/mta-sts.md +140 -42
  11. package/docs/spf.md +69 -4
  12. package/index.d.ts +499 -45
  13. package/lib/arc/index.d.ts +10 -8
  14. package/lib/arc/index.js +505 -124
  15. package/lib/bimi/index.js +162 -81
  16. package/lib/commands/report.js +2 -1
  17. package/lib/commands/seal.js +35 -5
  18. package/lib/commands/sign.js +8 -1
  19. package/lib/commands/spf.js +8 -0
  20. package/lib/dkim/body/relaxed.js +6 -5
  21. package/lib/dkim/body/simple.js +1 -3
  22. package/lib/dkim/dkim-signer.js +219 -33
  23. package/lib/dkim/dkim-verifier.js +574 -231
  24. package/lib/dkim/header/relaxed.js +6 -43
  25. package/lib/dkim/header/signature-opts.js +87 -0
  26. package/lib/dkim/header/simple.js +6 -43
  27. package/lib/dkim/message-parser.js +18 -4
  28. package/lib/dkim/sign.d.ts +13 -7
  29. package/lib/dkim/sign.js +18 -2
  30. package/lib/dkim/syntax.js +31 -0
  31. package/lib/dkim/verify.js +1 -0
  32. package/lib/dmarc/author-domain.js +181 -0
  33. package/lib/dmarc/get-dmarc-record.js +267 -53
  34. package/lib/dmarc/index.d.ts +29 -2
  35. package/lib/dmarc/index.js +2 -1
  36. package/lib/dmarc/verify.js +205 -53
  37. package/lib/mailauth.js +86 -40
  38. package/lib/mta-sts.d.ts +77 -11
  39. package/lib/mta-sts.js +497 -182
  40. package/lib/parse-dkim-headers.js +376 -191
  41. package/lib/parse-received.js +104 -32
  42. package/lib/spf/index.d.ts +37 -1
  43. package/lib/spf/index.js +260 -28
  44. package/lib/spf/macro.js +50 -13
  45. package/lib/spf/spf-verify.js +440 -204
  46. package/lib/spf/syntax.js +366 -0
  47. package/lib/tools.js +524 -118
  48. package/package.json +12 -12
@@ -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
+ };