mailauth 5.0.2 → 6.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 +24 -0
- package/README.md +6 -2
- package/docs/dmarc.md +17 -13
- package/index.d.ts +12 -4
- package/lib/arc/index.d.ts +2 -2
- package/lib/arc/index.js +16 -21
- package/lib/dkim/body/line-endings.js +34 -0
- package/lib/dkim/body/relaxed.js +35 -38
- package/lib/dkim/body/simple.js +72 -36
- package/lib/dkim/dkim-signer.js +16 -0
- package/lib/dkim/dkim-verifier.js +4 -7
- package/lib/dkim/header/relaxed.js +7 -13
- package/lib/dkim/header/simple.js +7 -13
- package/lib/dkim/message-parser.js +21 -12
- package/lib/dmarc/get-dmarc-record.js +159 -38
- package/lib/dmarc/index.js +2 -1
- package/lib/dmarc/verify.js +108 -32
- package/lib/mailauth.js +20 -7
- package/lib/tools.js +90 -18
- package/package.json +12 -12
package/lib/tools.js
CHANGED
|
@@ -76,14 +76,47 @@ const writeToStream = async (stream, input, chunkSize) => {
|
|
|
76
76
|
});
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
// Lowercases a 'binary' string the way RFC 6376 section 3.4.2 means it. String.prototype
|
|
80
|
+
// toLowerCase() is Unicode aware and maps the latin1 bytes 0xC0-0xDE to 0xE0-0xFE, which
|
|
81
|
+
// corrupts a UTF-8 sequence and folds two distinct header names onto one. Only a string
|
|
82
|
+
// that has such a byte needs the slower per character mapping
|
|
83
|
+
const NON_ASCII = /[^\x00-\x7f]/;
|
|
84
|
+
const lowerCaseASCII = str => (NON_ASCII.test(str) ? str.replace(/[A-Z]/g, c => c.toLowerCase()) : str.toLowerCase());
|
|
85
|
+
|
|
86
|
+
// RFC 5322 section 3.6.8: field-name = 1*ftext, and ftext is printable US-ASCII except
|
|
87
|
+
// the colon. A fold, and the obsolete syntax of section 4.5.8, may put whitespace between
|
|
88
|
+
// the name and the colon
|
|
89
|
+
const FIELD_START = /^[\x21-\x39\x3b-\x7e]+[ \t\r\n]*:/;
|
|
90
|
+
|
|
91
|
+
// Whether a row opens a header of its own. Anything else is appended to the row above,
|
|
92
|
+
// which is wider than the CRLF-then-WSP fold of RFC 5322 section 2.2.3 and deliberately
|
|
93
|
+
// so: a line that is not a well formed field is read differently by different parsers,
|
|
94
|
+
// some mail readers append whitespace-looking lines to the value above them. Asking what
|
|
95
|
+
// a field looks like instead of what whitespace looks like
|
|
96
|
+
// covers every spelling of it, including the multi-byte ones a leading byte test misses,
|
|
97
|
+
// such as UTF-8 NBSP (C2 A0) and the ideographic space (E3 80 80). Appending a line can
|
|
98
|
+
// only add bytes to a signed value, so it can only ever turn a pass into a fail; reading
|
|
99
|
+
// the line the other way round is what cannot be undone. formatRelaxedLine is not lenient
|
|
100
|
+
// in the same way, what a signature covers is RFC 6376 section 3.4.2 alone
|
|
101
|
+
const startsField = row =>
|
|
102
|
+
// only the start of the row decides this, and a fold can push the colon no further
|
|
103
|
+
// than onto the line after the name
|
|
104
|
+
FIELD_START.test(row.length > 1 ? `${row[0]}\r\n${row[1]}` : row[0]);
|
|
105
|
+
|
|
79
106
|
const parseHeaders = buf => {
|
|
80
107
|
let rows = buf
|
|
81
108
|
.toString('binary')
|
|
82
109
|
.replace(/[\r\n]+$/, '')
|
|
83
110
|
.split(/\r?\n/)
|
|
84
111
|
.map(row => [row]);
|
|
85
|
-
|
|
86
|
-
|
|
112
|
+
|
|
113
|
+
if (rows.length === 1 && !rows[0][0]) {
|
|
114
|
+
// nothing to parse, rather than one row that is not a header
|
|
115
|
+
return { parsed: [], original: buf };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (let i = rows.length - 1; i > 0; i--) {
|
|
119
|
+
if (!startsField(rows[i])) {
|
|
87
120
|
rows[i - 1] = rows[i - 1].concat(rows[i]);
|
|
88
121
|
rows.splice(i, 1);
|
|
89
122
|
}
|
|
@@ -94,8 +127,15 @@ const parseHeaders = buf => {
|
|
|
94
127
|
let key = row.match(/^[^:]+/);
|
|
95
128
|
let casedKey;
|
|
96
129
|
if (key) {
|
|
97
|
-
|
|
98
|
-
key
|
|
130
|
+
// a fold may sit between the field name and the colon, and the CRLF it leaves
|
|
131
|
+
// behind is not part of the name. Without this the key of a header folded that
|
|
132
|
+
// way matches nothing, and a From read by every MUA stops being a From here
|
|
133
|
+
casedKey = key[0];
|
|
134
|
+
if (casedKey.indexOf('\n') >= 0) {
|
|
135
|
+
casedKey = casedKey.replace(/\r?\n/g, '');
|
|
136
|
+
}
|
|
137
|
+
casedKey = casedKey.replace(/^[ \t]+|[ \t]+$/g, '');
|
|
138
|
+
key = lowerCaseASCII(casedKey);
|
|
99
139
|
}
|
|
100
140
|
|
|
101
141
|
return { key, casedKey, line: Buffer.from(row, 'binary') };
|
|
@@ -104,12 +144,21 @@ const parseHeaders = buf => {
|
|
|
104
144
|
return { parsed: rows, original: buf };
|
|
105
145
|
};
|
|
106
146
|
|
|
107
|
-
|
|
108
|
-
|
|
147
|
+
// Normalizes an h= tag, or a configured header list, exactly as parseHeaders normalizes a
|
|
148
|
+
// field name. Anything else and an entry names a key no parsed row can ever equal
|
|
149
|
+
const normalizeFieldNames = fieldNames =>
|
|
150
|
+
fieldNames
|
|
109
151
|
.split(':')
|
|
110
|
-
.map(key => key.
|
|
152
|
+
.map(key => lowerCaseASCII(key.replace(/^[ \t]+|[ \t]+$/g, '')))
|
|
111
153
|
.filter(key => key);
|
|
112
154
|
|
|
155
|
+
const defaultDKIMFieldNamesNormalized = normalizeFieldNames(defaultDKIMFieldNames);
|
|
156
|
+
|
|
157
|
+
const getSigningHeaderLines = (parsedHeaders, fieldNames, verify) => {
|
|
158
|
+
// an h= tag is attacker controlled on inbound mail, so only the constant default list
|
|
159
|
+
// is normalized ahead of time
|
|
160
|
+
fieldNames = typeof fieldNames === 'string' ? normalizeFieldNames(fieldNames) : defaultDKIMFieldNamesNormalized;
|
|
161
|
+
|
|
113
162
|
let signingList = [];
|
|
114
163
|
|
|
115
164
|
if (verify) {
|
|
@@ -154,7 +203,7 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
154
203
|
values = Object.assign(
|
|
155
204
|
{
|
|
156
205
|
v: 1,
|
|
157
|
-
t: Math.
|
|
206
|
+
t: Math.floor(Date.now() / 1000),
|
|
158
207
|
q: 'dns/txt'
|
|
159
208
|
},
|
|
160
209
|
values
|
|
@@ -166,7 +215,7 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
166
215
|
keyOrdering = keyOrderingARC;
|
|
167
216
|
values = Object.assign(
|
|
168
217
|
{
|
|
169
|
-
t: Math.
|
|
218
|
+
t: Math.floor(Date.now() / 1000),
|
|
170
219
|
q: 'dns/txt'
|
|
171
220
|
},
|
|
172
221
|
values
|
|
@@ -178,7 +227,7 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
178
227
|
keyOrdering = keyOrderingAS;
|
|
179
228
|
values = Object.assign(
|
|
180
229
|
{
|
|
181
|
-
t: Math.
|
|
230
|
+
t: Math.floor(Date.now() / 1000)
|
|
182
231
|
},
|
|
183
232
|
values
|
|
184
233
|
);
|
|
@@ -194,7 +243,10 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
194
243
|
.filter(key => values[key] !== false && typeof values[key] !== 'undefined' && values[key] !== null && keyOrdering.includes(key))
|
|
195
244
|
.sort((a, b) => keyOrdering.indexOf(a) - keyOrdering.indexOf(b))
|
|
196
245
|
.map(key => {
|
|
197
|
-
|
|
246
|
+
// the filter above already dropped false, null and undefined, so a falsy
|
|
247
|
+
// value left here is a meaningful one. `l=0` is a valid sig-l-tag (RFC 6376
|
|
248
|
+
// section 3.5, 1*76DIGIT) and must not be serialized as a valueless `l=`
|
|
249
|
+
let val = values[key];
|
|
198
250
|
if (key === 'b' && folded && val) {
|
|
199
251
|
// fold signature value
|
|
200
252
|
return `${key}=${val}`.replace(/.{75}/g, '$& ').trim();
|
|
@@ -210,6 +262,7 @@ const formatSignatureHeaderLine = (type, values, folded) => {
|
|
|
210
262
|
}
|
|
211
263
|
|
|
212
264
|
if (key === 'i' && type === 'DKIM') {
|
|
265
|
+
val = val.toString();
|
|
213
266
|
let atPos = val.indexOf('@');
|
|
214
267
|
if (atPos >= 0) {
|
|
215
268
|
let domainPart = val.substr(atPos + 1);
|
|
@@ -449,21 +502,35 @@ const formatAuthHeaderRow = (method, status) => {
|
|
|
449
502
|
return parts.join(' ');
|
|
450
503
|
};
|
|
451
504
|
|
|
505
|
+
// RFC 6376 section 3.4.2. Only SP and HTAB are whitespace here: the line is a
|
|
506
|
+
// 'binary' string, so a byte such as 0xA0 (the second byte of a UTF-8
|
|
507
|
+
// non-breaking space) must not be collapsed or trimmed
|
|
452
508
|
const formatRelaxedLine = (line, suffix) => {
|
|
453
509
|
let result =
|
|
454
|
-
line
|
|
455
|
-
|
|
510
|
+
// a missing line is nothing to canonicalize. Optional chaining would resolve the
|
|
511
|
+
// whole chain to undefined and the concatenation below would then hash the
|
|
512
|
+
// 9 byte string "undefined" as if it were a header
|
|
513
|
+
(line || '')
|
|
514
|
+
.toString('binary')
|
|
456
515
|
// unfold
|
|
457
516
|
.replace(/\r?\n/g, '')
|
|
458
517
|
// key to lowercase, trim around :
|
|
459
|
-
.replace(/^([^:]*)
|
|
518
|
+
.replace(/^([^:]*):[ \t]*/, (m, k) => lowerCaseASCII(k).replace(/^[ \t]+|[ \t]+$/g, '') + ':')
|
|
460
519
|
// single WSP
|
|
461
|
-
.replace(
|
|
462
|
-
|
|
520
|
+
.replace(/[ \t]+/g, ' ')
|
|
521
|
+
// no WSP around the value
|
|
522
|
+
.replace(/^ | $/g, '') + (suffix ? suffix : '');
|
|
463
523
|
|
|
464
524
|
return Buffer.from(result, 'binary');
|
|
465
525
|
};
|
|
466
526
|
|
|
527
|
+
// RFC 6376 section 3.7: when the signature header is hashed, its own b= value is treated
|
|
528
|
+
// as empty. The tag can be preceded by WSP and, in a header that has not been unfolded
|
|
529
|
+
// yet, by the CRLF of a fold. Nothing else is whitespace here, for the same reason it is
|
|
530
|
+
// not in formatRelaxedLine: a byte such as 0xA0 in front of b= is content, and matching it
|
|
531
|
+
// would strip a decoy tag and leave the real signature inside the hashed header
|
|
532
|
+
const stripSignatureValue = line => Buffer.from(line.toString('binary').replace(/([;: \t\r\n]+b=)[^;]+/, '$1'), 'binary');
|
|
533
|
+
|
|
467
534
|
const formatDomain = domain => {
|
|
468
535
|
domain = domain.toLowerCase().trim();
|
|
469
536
|
try {
|
|
@@ -568,15 +635,19 @@ function getCurTime(timeValue) {
|
|
|
568
635
|
return timeValue;
|
|
569
636
|
}
|
|
570
637
|
|
|
638
|
+
// `curTime.toString` is the method, never the string, so the guard below used to
|
|
639
|
+
// pass for every value and handed back an Invalid Date. That silently became a
|
|
640
|
+
// NaN timestamp in a signature, and a comparison that is false either way in the
|
|
641
|
+
// verifier, so an unparseable value falls back to the current time instead
|
|
571
642
|
if (typeof timeValue === 'number' || !isNaN(timeValue)) {
|
|
572
643
|
let timestamp = Number(timeValue);
|
|
573
644
|
let curTime = new Date(timestamp);
|
|
574
|
-
if (curTime.toString !== 'Invalid Date') {
|
|
645
|
+
if (curTime.toString() !== 'Invalid Date') {
|
|
575
646
|
return curTime;
|
|
576
647
|
}
|
|
577
648
|
} else if (typeof timeValue === 'string') {
|
|
578
649
|
let curTime = new Date(timeValue);
|
|
579
|
-
if (curTime.toString !== 'Invalid Date') {
|
|
650
|
+
if (curTime.toString() !== 'Invalid Date') {
|
|
580
651
|
return curTime;
|
|
581
652
|
}
|
|
582
653
|
}
|
|
@@ -774,6 +845,7 @@ module.exports = {
|
|
|
774
845
|
getAlignment,
|
|
775
846
|
|
|
776
847
|
formatRelaxedLine,
|
|
848
|
+
stripSignatureValue,
|
|
777
849
|
formatDomain,
|
|
778
850
|
|
|
779
851
|
getPtrHostname,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mailauth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "Email authentication library for Node.js",
|
|
5
5
|
"main": "lib/mailauth.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -35,27 +35,27 @@
|
|
|
35
35
|
"homepage": "https://github.com/postalsys/mailauth",
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"chai": "4.4.1",
|
|
38
|
-
"eslint": "10.
|
|
38
|
+
"eslint": "10.11.0",
|
|
39
39
|
"eslint-config-nodemailer": "1.2.0",
|
|
40
40
|
"eslint-config-prettier": "10.1.8",
|
|
41
|
-
"js-yaml": "5.2
|
|
41
|
+
"js-yaml": "5.4.2",
|
|
42
42
|
"license-report": "6.8.5",
|
|
43
43
|
"mbox-reader": "1.2.0",
|
|
44
|
-
"mocha": "
|
|
45
|
-
"prettier": "3.9.
|
|
46
|
-
"resedit": "3.0
|
|
44
|
+
"mocha": "12.0.2",
|
|
45
|
+
"prettier": "3.9.9",
|
|
46
|
+
"resedit": "3.1.0"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@postalsys/vmc": "1.1.5",
|
|
50
50
|
"commander": "15.0.0",
|
|
51
|
-
"fast-xml-parser": "5.
|
|
51
|
+
"fast-xml-parser": "5.11.1",
|
|
52
52
|
"ipaddr.js": "2.5.0",
|
|
53
|
-
"joi": "18.2.
|
|
54
|
-
"libmime": "5.4.
|
|
55
|
-
"nodemailer": "
|
|
53
|
+
"joi": "18.2.9",
|
|
54
|
+
"libmime": "5.4.4",
|
|
55
|
+
"nodemailer": "10.0.10",
|
|
56
56
|
"punycode.js": "2.3.1",
|
|
57
|
-
"tldts": "7.4.
|
|
58
|
-
"undici": "8.
|
|
57
|
+
"tldts": "7.4.15",
|
|
58
|
+
"undici": "8.11.2"
|
|
59
59
|
},
|
|
60
60
|
"engines": {
|
|
61
61
|
"node": ">=22.19.0"
|