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/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
- for (let i = rows.length - 1; i >= 0; i--) {
86
- if (i > 0 && /^\s/.test(rows[i][0])) {
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
- casedKey = key[0].trim();
98
- key = casedKey.toLowerCase();
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
- const getSigningHeaderLines = (parsedHeaders, fieldNames, verify) => {
108
- fieldNames = (typeof fieldNames === 'string' ? fieldNames : defaultDKIMFieldNames)
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.trim().toLowerCase())
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.round(Date.now() / 1000),
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.round(Date.now() / 1000),
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.round(Date.now() / 1000)
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
- let val = values[key] || '';
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
- ?.toString('binary')
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(/^([^:]*):\s*/, (m, k) => k.toLowerCase().trim() + ':')
518
+ .replace(/^([^:]*):[ \t]*/, (m, k) => lowerCaseASCII(k).replace(/^[ \t]+|[ \t]+$/g, '') + ':')
460
519
  // single WSP
461
- .replace(/\s+/g, ' ')
462
- .trim() + (suffix ? suffix : '');
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": "5.0.2",
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.8.0",
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.3",
41
+ "js-yaml": "5.4.2",
42
42
  "license-report": "6.8.5",
43
43
  "mbox-reader": "1.2.0",
44
- "mocha": "11.8.0",
45
- "prettier": "3.9.6",
46
- "resedit": "3.0.2"
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.10.1",
51
+ "fast-xml-parser": "5.11.1",
52
52
  "ipaddr.js": "2.5.0",
53
- "joi": "18.2.3",
54
- "libmime": "5.4.1",
55
- "nodemailer": "9.0.4",
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.10",
58
- "undici": "8.10.0"
57
+ "tldts": "7.4.15",
58
+ "undici": "8.11.2"
59
59
  },
60
60
  "engines": {
61
61
  "node": ">=22.19.0"