nodemailer 10.1.3 → 10.1.4
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/CHANGELOG.md +15 -0
- package/dist/cjs/addressparser/index.js +297 -31
- package/dist/cjs/mime-node/index.js +8 -1
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/esm/addressparser/index.js +297 -31
- package/dist/esm/mime-node/index.js +8 -1
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [10.1.4](https://github.com/nodemailer/nodemailer/compare/v10.1.3...v10.1.4) (2026-10-18)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **addressparser:** apply the unexpected "<" rule outside quotes and comments ([ee35f57](https://github.com/nodemailer/nodemailer/commit/ee35f5745658bf46952651fa53207255e9dc3406))
|
|
9
|
+
* **addressparser:** bound group nesting at ten levels ([ce63303](https://github.com/nodemailer/nodemailer/commit/ce633031d58f8d2e9c9b0f5b671944a1afe71f95))
|
|
10
|
+
* **addressparser:** drop the obsolete route from an angle-addr ([3dbc730](https://github.com/nodemailer/nodemailer/commit/3dbc7304b0cfa06551543fa2dc01222447c2b554))
|
|
11
|
+
* **addressparser:** end a comment only at the ")" that closes the outermost one ([0a87617](https://github.com/nodemailer/nodemailer/commit/0a8761762ac78f2db2ba37f712f2a52a404ce011))
|
|
12
|
+
* **addressparser:** honour quoted strings and comments inside an angle-addr and a group ([1cd57d2](https://github.com/nodemailer/nodemailer/commit/1cd57d2b8c49dff048c21cdbafdb54439a7f07e2))
|
|
13
|
+
* **addressparser:** never read a mailbox out of a comment ([a1538ea](https://github.com/nodemailer/nodemailer/commit/a1538ea39c76c48a4134806c41e31e59a258b1e5))
|
|
14
|
+
* **addressparser:** read CFWS beside the "@" of an addr-spec as nothing ([ba3c095](https://github.com/nodemailer/nodemailer/commit/ba3c0952542581f95cb20d9322cc9e27baa470a2))
|
|
15
|
+
* **addressparser:** read the first addr-spec of the display text ([038940a](https://github.com/nodemailer/nodemailer/commit/038940a069a2cecb64a70d0e81b17eb8ce200d3a))
|
|
16
|
+
* split a domain literal off at the "@" in front of it ([d5983b0](https://github.com/nodemailer/nodemailer/commit/d5983b0ae234cd22ab45be817fefd434e9a53d58))
|
|
17
|
+
|
|
3
18
|
## [10.1.3](https://github.com/nodemailer/nodemailer/compare/v10.1.2...v10.1.3) (2026-10-18)
|
|
4
19
|
|
|
5
20
|
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* A domain literal that ends an address. dtext includes '@' (RFC 5322 3.4.1), so the '@'
|
|
5
|
+
* in front of the literal is the one that splits the domain off, not the last one: reading
|
|
6
|
+
* 'a@[x@evil.com]' at its last '@' made it an address at evil.com.
|
|
7
|
+
*/
|
|
8
|
+
const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
|
|
9
|
+
/**
|
|
10
|
+
* Offset of the '@' that splits the domain off an address, or -1 when there is none
|
|
11
|
+
*
|
|
12
|
+
* @param address Address to split
|
|
13
|
+
* @return Offset of the '@'
|
|
14
|
+
*/
|
|
15
|
+
function _domainAt(address) {
|
|
16
|
+
const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
|
|
17
|
+
return literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
|
|
18
|
+
}
|
|
3
19
|
/**
|
|
4
20
|
* Restores the quoting of a local part that was read out of a quoted string.
|
|
5
21
|
*
|
|
@@ -18,7 +34,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
18
34
|
* @return Address with the local part as a quoted-string
|
|
19
35
|
*/
|
|
20
36
|
function _quoteLocalPart(address) {
|
|
21
|
-
const lastAt = address
|
|
37
|
+
const lastAt = _domainAt(address);
|
|
22
38
|
if (lastAt < 0) {
|
|
23
39
|
// no domain to split off, nothing can be misrouted
|
|
24
40
|
return address;
|
|
@@ -44,6 +60,23 @@ const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
|
|
|
44
60
|
* One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
|
|
45
61
|
*/
|
|
46
62
|
const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
|
|
63
|
+
/**
|
|
64
|
+
* The same with a domain literal for the domain, which may hold an '@' of its own.
|
|
65
|
+
*/
|
|
66
|
+
const LITERAL_ADDR_SPEC = /^[^@\s]+@\[[^[\]\\\s]*\]$/;
|
|
67
|
+
/**
|
|
68
|
+
* The '@' and domain that follow a quoted local part in the same run.
|
|
69
|
+
*/
|
|
70
|
+
const QUOTED_LOCAL_DOMAIN = /^@(?:[^@\s"]+|\[[^[\]\\\s]*\])$/;
|
|
71
|
+
/**
|
|
72
|
+
* Whether a whitespace free run reads as an addr-spec under the strict grammar
|
|
73
|
+
*
|
|
74
|
+
* @param value Run to check
|
|
75
|
+
* @return true when it does
|
|
76
|
+
*/
|
|
77
|
+
function _isAddrSpec(value) {
|
|
78
|
+
return ADDR_SPEC.test(value) || LITERAL_ADDR_SPEC.test(value);
|
|
79
|
+
}
|
|
47
80
|
/**
|
|
48
81
|
* The looser reading applied once the strict one finds nothing, which tolerates the
|
|
49
82
|
* further '@' that a domain should not have but malformed headers carry anyway.
|
|
@@ -110,6 +143,62 @@ function _indexOfAt(text, from, to) {
|
|
|
110
143
|
}
|
|
111
144
|
return -1;
|
|
112
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* A quoted-string a value opens with.
|
|
148
|
+
*/
|
|
149
|
+
const LEADING_QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"/;
|
|
150
|
+
/**
|
|
151
|
+
* Drops the whitespace on either side of the '@' at an offset.
|
|
152
|
+
*
|
|
153
|
+
* obs-local-part and obs-domain allow CFWS around the '@' (RFC 5322 4.4), so 'user @ good.com'
|
|
154
|
+
* names user@good.com. Kept, the whitespace went into the envelope as '"user "@ good.com'.
|
|
155
|
+
* The scan is by index, a pattern such as /\s+@/ backtracks over a long whitespace run that
|
|
156
|
+
* no '@' follows.
|
|
157
|
+
*
|
|
158
|
+
* @param value Value holding the '@'
|
|
159
|
+
* @param at Offset of the '@', or -1
|
|
160
|
+
* @return The value with the whitespace beside that '@' removed
|
|
161
|
+
*/
|
|
162
|
+
function _joinAtSpacing(value, at) {
|
|
163
|
+
if (at < 0) {
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
let start = at;
|
|
167
|
+
while (start > 0 && _isSpaceCode(value.charCodeAt(start - 1))) {
|
|
168
|
+
start--;
|
|
169
|
+
}
|
|
170
|
+
let end = at + 1;
|
|
171
|
+
while (end < value.length && _isSpaceCode(value.charCodeAt(end))) {
|
|
172
|
+
end++;
|
|
173
|
+
}
|
|
174
|
+
return start === at && end === at + 1 ? value : value.slice(0, start) + '@' + value.slice(end);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Drops the whitespace beside the '@' that splits the domain off a value. That is the '@'
|
|
178
|
+
* right after a quoted local part the value opens with, or else the only '@' it holds outside
|
|
179
|
+
* any quoted string. With several it is not clear which one the whitespace belongs to, and
|
|
180
|
+
* '<x@evil.com @good.com>' is left to the addr-spec recovery to read as it always has.
|
|
181
|
+
*
|
|
182
|
+
* @param value Value to clean up
|
|
183
|
+
* @return The value with the whitespace beside that '@' removed
|
|
184
|
+
*/
|
|
185
|
+
function _collapseAtSpacing(value) {
|
|
186
|
+
const quoted = LEADING_QUOTED_STRING.exec(value);
|
|
187
|
+
if (quoted) {
|
|
188
|
+
let at = quoted[0].length;
|
|
189
|
+
while (at < value.length && _isSpaceCode(value.charCodeAt(at))) {
|
|
190
|
+
at++;
|
|
191
|
+
}
|
|
192
|
+
if (value.charAt(at) === '@' && value.indexOf('@', at + 1) < 0) {
|
|
193
|
+
return _joinAtSpacing(value, at);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const at = value.indexOf('@');
|
|
197
|
+
if (at < 0 || value.indexOf('@', at + 1) >= 0 || value.lastIndexOf('"') > at) {
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
return _joinAtSpacing(value, at);
|
|
201
|
+
}
|
|
113
202
|
/**
|
|
114
203
|
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
|
|
115
204
|
*
|
|
@@ -199,6 +288,7 @@ function _looseAddressStart(text) {
|
|
|
199
288
|
* @param data Collected address parts, mutated in place
|
|
200
289
|
*/
|
|
201
290
|
function _recoverAddrSpec(data) {
|
|
291
|
+
data.address = _collapseAtSpacing(data.address);
|
|
202
292
|
if (!HAS_WHITESPACE.test(data.address)) {
|
|
203
293
|
return;
|
|
204
294
|
}
|
|
@@ -225,7 +315,7 @@ function _recoverAddrSpec(data) {
|
|
|
225
315
|
return;
|
|
226
316
|
}
|
|
227
317
|
const parts = data.address.split(/\s+/);
|
|
228
|
-
let addrIndex = parts.findIndex(
|
|
318
|
+
let addrIndex = parts.findIndex(_isAddrSpec);
|
|
229
319
|
if (addrIndex < 0) {
|
|
230
320
|
addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
|
|
231
321
|
}
|
|
@@ -252,6 +342,15 @@ function _recoverAddrSpec(data) {
|
|
|
252
342
|
* A comment is folding whitespace, so it is read as a space that splits two atoms and as
|
|
253
343
|
* nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted
|
|
254
344
|
* strings and domain literals are copied through untouched, a '(' in there is not a comment.
|
|
345
|
+
* An unexpected '<' outside them drops everything in front of it, comments included.
|
|
346
|
+
*
|
|
347
|
+
* RFC 822 also let an angle-addr name the relays to go through in front of the mailbox,
|
|
348
|
+
* '<@relay1,@relay2:user@example.com>', which RFC 5322 still parses as obs-route. The route
|
|
349
|
+
* is not part of the mailbox, and folded into the local part it made
|
|
350
|
+
* '"@relay1,@relay2:user"@example.com' a recipient that does not exist. A local part can not
|
|
351
|
+
* start with '@', so contents that do, up to the first ':' outside a domain literal and in
|
|
352
|
+
* front of any quoted string, are a route and are dropped, as long as a mailbox with an '@'
|
|
353
|
+
* of its own follows.
|
|
255
354
|
*
|
|
256
355
|
* @param address Contents of the angle brackets
|
|
257
356
|
* @return The address with the comments removed, and the text of the comments
|
|
@@ -265,6 +364,16 @@ function _stripAddressComments(address) {
|
|
|
265
364
|
// carried along rather than read back off the growing result, which would flatten it on
|
|
266
365
|
// every comment (see lastChars in _handleAddress)
|
|
267
366
|
let lastChar = '';
|
|
367
|
+
// A comment that just closed stands for a space, written once the next character shows
|
|
368
|
+
// whether one is wanted: not beside an '@', and not on top of whitespace that follows.
|
|
369
|
+
// Deciding on the spot saw only the character right after the ')', so a second comment
|
|
370
|
+
// in a row hid the '@' behind it and 'a(x)(y)@b' read as 'a @b'
|
|
371
|
+
let pendingSpace = false;
|
|
372
|
+
// first character of the result and whether a quoted string has opened since, which
|
|
373
|
+
// together tell a route from a local part
|
|
374
|
+
let firstChar = '';
|
|
375
|
+
let quoteSeen = false;
|
|
376
|
+
let routeEnd = -1;
|
|
268
377
|
for (let i = 0, len = address.length; i < len; i++) {
|
|
269
378
|
const chr = address.charAt(i);
|
|
270
379
|
if (depth) {
|
|
@@ -278,16 +387,20 @@ function _stripAddressComments(address) {
|
|
|
278
387
|
else if (chr === ')' && !--depth) {
|
|
279
388
|
comments.push(comment.trim());
|
|
280
389
|
comment = '';
|
|
281
|
-
|
|
282
|
-
result += ' ';
|
|
283
|
-
lastChar = ' ';
|
|
284
|
-
}
|
|
390
|
+
pendingSpace = lastChar !== '@';
|
|
285
391
|
}
|
|
286
392
|
else {
|
|
287
393
|
comment += chr;
|
|
288
394
|
}
|
|
289
395
|
continue;
|
|
290
396
|
}
|
|
397
|
+
if (pendingSpace && chr !== '(') {
|
|
398
|
+
pendingSpace = false;
|
|
399
|
+
if (chr !== '@' && chr !== '<' && !_isSpaceCode(address.charCodeAt(i))) {
|
|
400
|
+
result += ' ';
|
|
401
|
+
lastChar = ' ';
|
|
402
|
+
}
|
|
403
|
+
}
|
|
291
404
|
if (closer) {
|
|
292
405
|
if (chr === '\\' && closer === '"' && i < len - 1) {
|
|
293
406
|
result += chr + address.charAt(++i);
|
|
@@ -300,6 +413,10 @@ function _stripAddressComments(address) {
|
|
|
300
413
|
}
|
|
301
414
|
else if (chr === '"') {
|
|
302
415
|
closer = '"';
|
|
416
|
+
quoteSeen = true;
|
|
417
|
+
}
|
|
418
|
+
else if (chr === ':' && routeEnd < 0 && firstChar === '@' && !quoteSeen) {
|
|
419
|
+
routeEnd = result.length + 1;
|
|
303
420
|
}
|
|
304
421
|
else if (chr === '[') {
|
|
305
422
|
closer = ']';
|
|
@@ -308,8 +425,27 @@ function _stripAddressComments(address) {
|
|
|
308
425
|
depth = 1;
|
|
309
426
|
continue;
|
|
310
427
|
}
|
|
428
|
+
else if (chr === '<') {
|
|
429
|
+
// Apple Mail drops everything between an unexpected '<' and the address after it.
|
|
430
|
+
// Read here rather than off the raw brackets so that a '<' inside a quoted string,
|
|
431
|
+
// a comment or a domain literal stays where it is: '<"a<b"@victim.com>' cut at
|
|
432
|
+
// the first '<' left the address 'b"@victim.com'
|
|
433
|
+
result = '';
|
|
434
|
+
lastChar = '';
|
|
435
|
+
comments.length = 0;
|
|
436
|
+
firstChar = '';
|
|
437
|
+
quoteSeen = false;
|
|
438
|
+
routeEnd = -1;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
311
441
|
result += chr;
|
|
312
442
|
lastChar = chr;
|
|
443
|
+
if (!firstChar && !_isSpaceCode(address.charCodeAt(i))) {
|
|
444
|
+
firstChar = chr;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (routeEnd >= 0 && result.indexOf('@', routeEnd) >= 0) {
|
|
448
|
+
result = result.slice(routeEnd);
|
|
313
449
|
}
|
|
314
450
|
if (depth) {
|
|
315
451
|
// an unterminated comment runs to the end of the address
|
|
@@ -333,9 +469,13 @@ function _handleAddress(tokens, depth) {
|
|
|
333
469
|
comment: [],
|
|
334
470
|
group: [],
|
|
335
471
|
text: [],
|
|
336
|
-
textWasQuoted: []
|
|
472
|
+
textWasQuoted: [],
|
|
473
|
+
quotedLocalAt: []
|
|
337
474
|
};
|
|
338
475
|
let insideQuotes = false;
|
|
476
|
+
// whether the last text run holds nothing but quoted text so far, which is what a quoted
|
|
477
|
+
// local part is until the '@' after it joins the run
|
|
478
|
+
let lastTextPureQuoted = false;
|
|
339
479
|
// Last character of the run each state is currently accumulating. Reading it back off
|
|
340
480
|
// the accumulator with slice(-1) makes the engine flatten the whole growing string on
|
|
341
481
|
// every token, which is quadratic over an address built from many comment-joined atoms
|
|
@@ -385,11 +525,6 @@ function _handleAddress(tokens, depth) {
|
|
|
385
525
|
!!prevToken.noBreak &&
|
|
386
526
|
prevPrevToken?.type === 'operator' &&
|
|
387
527
|
prevPrevToken.value === '"';
|
|
388
|
-
if (state === 'address') {
|
|
389
|
-
// Handle unquoted name that includes a "<".
|
|
390
|
-
// Apple Mail truncates everything between an unexpected < and an address.
|
|
391
|
-
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
|
392
|
-
}
|
|
393
528
|
// A comment is folding whitespace. It may sit inside an addr-spec, on either side
|
|
394
529
|
// of the '@', but it cannot join two atoms into one: gluing across it would read
|
|
395
530
|
// 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
|
|
@@ -400,12 +535,19 @@ function _handleAddress(tokens, depth) {
|
|
|
400
535
|
parts.length &&
|
|
401
536
|
(prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@');
|
|
402
537
|
if (joins) {
|
|
403
|
-
data[state]
|
|
538
|
+
const last = data[state].length - 1;
|
|
539
|
+
if (state === 'text' && !insideQuotes) {
|
|
540
|
+
if (lastTextPureQuoted && token.value.charAt(0) === '@') {
|
|
541
|
+
data.quotedLocalAt[last] = data.text[last].length;
|
|
542
|
+
}
|
|
543
|
+
lastTextPureQuoted = false;
|
|
544
|
+
}
|
|
545
|
+
data[state][last] += token.value;
|
|
404
546
|
if (token.value) {
|
|
405
547
|
lastChars[state] = token.value.charAt(token.value.length - 1);
|
|
406
548
|
}
|
|
407
549
|
if (state === 'text' && insideQuotes) {
|
|
408
|
-
data.textWasQuoted[
|
|
550
|
+
data.textWasQuoted[last] = true;
|
|
409
551
|
}
|
|
410
552
|
}
|
|
411
553
|
else {
|
|
@@ -413,14 +555,21 @@ function _handleAddress(tokens, depth) {
|
|
|
413
555
|
lastChars[state] = token.value.charAt(token.value.length - 1);
|
|
414
556
|
if (state === 'text') {
|
|
415
557
|
data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
|
|
558
|
+
// an empty quoted string leaves no text of its own, so the '@' opens the run
|
|
559
|
+
data.quotedLocalAt.push(opensAfterEmptyQuotedString && token.value.charAt(0) === '@' ? 0 : -1);
|
|
560
|
+
lastTextPureQuoted = insideQuotes;
|
|
416
561
|
}
|
|
417
562
|
}
|
|
418
563
|
}
|
|
419
564
|
}
|
|
420
|
-
// If there is no text but a comment, replace the two
|
|
565
|
+
// If there is no text but a comment, replace the two. A comment is never part of a
|
|
566
|
+
// mailbox, so text that came out of one only ever names it: reading an address out of it
|
|
567
|
+
// made '(a@evil.com)' a recipient where no conformant reading has a mailbox at all
|
|
568
|
+
let textFromComment = false;
|
|
421
569
|
if (!data.text.length && data.comment.length) {
|
|
422
570
|
data.text = data.comment;
|
|
423
571
|
data.comment = [];
|
|
572
|
+
textFromComment = true;
|
|
424
573
|
}
|
|
425
574
|
if (isGroup) {
|
|
426
575
|
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
|
@@ -449,10 +598,6 @@ function _handleAddress(tokens, depth) {
|
|
|
449
598
|
const addressComments = [];
|
|
450
599
|
const addressParts = [];
|
|
451
600
|
for (const part of data.address) {
|
|
452
|
-
if (part.indexOf('(') < 0) {
|
|
453
|
-
addressParts.push(part);
|
|
454
|
-
continue;
|
|
455
|
-
}
|
|
456
601
|
const stripped = _stripAddressComments(part);
|
|
457
602
|
for (const comment of stripped.comments) {
|
|
458
603
|
addressComments.push(comment);
|
|
@@ -463,25 +608,42 @@ function _handleAddress(tokens, depth) {
|
|
|
463
608
|
}
|
|
464
609
|
data.address = addressParts;
|
|
465
610
|
// If no address was found, try to detect one from regular text
|
|
466
|
-
if (!data.address.length && data.text.length) {
|
|
467
|
-
|
|
611
|
+
if (!data.address.length && data.text.length && !textFromComment) {
|
|
612
|
+
// Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both
|
|
613
|
+
// tiers read the collapsed runs, and only a run an address comes out of is replaced
|
|
614
|
+
// by its collapsed form, the display text keeps its spacing
|
|
615
|
+
const candidates = data.text.map((part) => _collapseAtSpacing(part));
|
|
616
|
+
// The first addr-spec in the text is the mailbox, as it is for a conformant parser
|
|
617
|
+
// reading as far as it can. Taking the last one let a comment part two of them and
|
|
618
|
+
// pick the second, 'user@good.com (x) evil@evil.com' went to evil.com, while the
|
|
619
|
+
// same two without the comment, one run, went to good.com
|
|
620
|
+
for (let i = 0; i < data.text.length; i++) {
|
|
468
621
|
// Security: Do not extract email addresses from quoted strings.
|
|
469
622
|
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
|
|
470
623
|
// Extracting emails from quoted text leads to misrouting vulnerabilities.
|
|
471
|
-
|
|
472
|
-
|
|
624
|
+
// A run that is a quoted local part with its '@' and domain is an addr-spec of
|
|
625
|
+
// its own, with the quoting put back on, and takes its turn like any other
|
|
626
|
+
const local = data.quotedLocalAt[i];
|
|
627
|
+
const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local))
|
|
628
|
+
? _quoteLocalPart(data.text[i])
|
|
629
|
+
: '';
|
|
630
|
+
if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) {
|
|
631
|
+
data.address = [quotedLocal || candidates[i]];
|
|
632
|
+
data.text.splice(i, 1);
|
|
473
633
|
data.textWasQuoted.splice(i, 1);
|
|
634
|
+
data.quotedLocalAt.splice(i, 1);
|
|
474
635
|
break;
|
|
475
636
|
}
|
|
476
637
|
}
|
|
477
638
|
// Try a looser regex match if strict match found nothing
|
|
478
639
|
if (!data.address.length) {
|
|
479
640
|
let extracted = false;
|
|
480
|
-
for (let i = data.text.length
|
|
641
|
+
for (let i = 0; i < data.text.length; i++) {
|
|
481
642
|
// Security: Do not extract email addresses from quoted strings
|
|
482
643
|
if (!data.textWasQuoted[i]) {
|
|
483
|
-
|
|
484
|
-
|
|
644
|
+
// the strict tier found nothing, so it spliced nothing and the indexes still line up
|
|
645
|
+
const part = candidates[i];
|
|
646
|
+
let remainder = data.text[i];
|
|
485
647
|
const at = _looseAddressStart(part);
|
|
486
648
|
if (at >= 0) {
|
|
487
649
|
LOOSE_TEXT_ADDR.lastIndex = at;
|
|
@@ -530,16 +692,19 @@ function _handleAddress(tokens, depth) {
|
|
|
530
692
|
// on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
|
|
531
693
|
// back first is what lets the recovery tell the whitespace an addr-spec may carry
|
|
532
694
|
// from the wreckage trailing one, as only a quoted local part may hold whitespace
|
|
533
|
-
|
|
695
|
+
// The quotes are gone at this point, so the '@' the domain splits off at is the
|
|
696
|
+
// last one, not the single one _collapseAtSpacing insists on
|
|
697
|
+
data.address = _quoteLocalPart(_joinAtSpacing(data.text, _domainAt(data.text)));
|
|
534
698
|
data.text = '';
|
|
535
699
|
}
|
|
536
700
|
_recoverAddrSpec(data);
|
|
537
701
|
// a comment names the mailbox only when nothing else does, as one outside the brackets
|
|
538
702
|
if (!data.text && addressComments.length) {
|
|
539
703
|
data.text = addressComments.join(' ');
|
|
704
|
+
textFromComment = true;
|
|
540
705
|
}
|
|
541
706
|
const address = {
|
|
542
|
-
address: data.address || data.text || '',
|
|
707
|
+
address: data.address || (textFromComment ? '' : data.text) || '',
|
|
543
708
|
name: data.text || data.address || ''
|
|
544
709
|
};
|
|
545
710
|
if (address.address === address.name) {
|
|
@@ -569,6 +734,10 @@ class Tokenizer {
|
|
|
569
734
|
this.escaped = false;
|
|
570
735
|
this.inDomainLiteral = false;
|
|
571
736
|
this.afterAt = false;
|
|
737
|
+
this.innerQuoted = false;
|
|
738
|
+
this.innerComment = 0;
|
|
739
|
+
this.innerLiteral = false;
|
|
740
|
+
this.innerEscaped = false;
|
|
572
741
|
this.list = [];
|
|
573
742
|
/**
|
|
574
743
|
* Operator tokens and which tokens are expected to end the sequence
|
|
@@ -635,10 +804,32 @@ class Tokenizer {
|
|
|
635
804
|
this.inDomainLiteral = false;
|
|
636
805
|
}
|
|
637
806
|
}
|
|
807
|
+
// The closer of an angle-addr or a group is only read outside the quoted strings,
|
|
808
|
+
// comments and domain literals it holds. Looking for nothing but the closer let a '>'
|
|
809
|
+
// inside a quoted local part end the angle-addr, '<"a@evil.com>"@good.com>' was read
|
|
810
|
+
// as an address at evil.com, and a ';' inside a quoted display name or a comment end
|
|
811
|
+
// the group and release the rest of that text as recipients. The characters are still
|
|
812
|
+
// collected as they are, the angle-addr and the group content are read again later
|
|
813
|
+
let nested = false;
|
|
814
|
+
if (this.operatorExpecting === '>' || this.operatorExpecting === ';') {
|
|
815
|
+
nested = this._trackInner(chr);
|
|
816
|
+
}
|
|
817
|
+
else if (this.operatorExpecting === ')' && !this.escaped) {
|
|
818
|
+
// Comments nest (RFC 5322 3.2.2). Ending one at the first ')' let the ')' of a
|
|
819
|
+
// nested comment close the outer one and released the rest of it, an address
|
|
820
|
+
// included, into the header
|
|
821
|
+
if (chr === '(') {
|
|
822
|
+
this.innerComment++;
|
|
823
|
+
}
|
|
824
|
+
else if (chr === ')' && this.innerComment) {
|
|
825
|
+
this.innerComment--;
|
|
826
|
+
nested = true;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
638
829
|
if (this.escaped) {
|
|
639
830
|
// ignore next condition blocks
|
|
640
831
|
}
|
|
641
|
-
else if (chr === this.operatorExpecting) {
|
|
832
|
+
else if (chr === this.operatorExpecting && !nested) {
|
|
642
833
|
this.node = {
|
|
643
834
|
type: 'operator',
|
|
644
835
|
value: chr
|
|
@@ -654,6 +845,7 @@ class Tokenizer {
|
|
|
654
845
|
}
|
|
655
846
|
this.operatorExpecting = '';
|
|
656
847
|
this.escaped = false;
|
|
848
|
+
this._resetInner();
|
|
657
849
|
return;
|
|
658
850
|
}
|
|
659
851
|
else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ':') && chr in this.operators) {
|
|
@@ -668,6 +860,7 @@ class Tokenizer {
|
|
|
668
860
|
}
|
|
669
861
|
this.operatorExpecting = this.operators[chr];
|
|
670
862
|
this.escaped = false;
|
|
863
|
+
this._resetInner();
|
|
671
864
|
return;
|
|
672
865
|
}
|
|
673
866
|
else if (['"', "'", ')'].includes(this.operatorExpecting) && chr === '\\') {
|
|
@@ -701,13 +894,86 @@ class Tokenizer {
|
|
|
701
894
|
}
|
|
702
895
|
this.escaped = false;
|
|
703
896
|
}
|
|
897
|
+
/**
|
|
898
|
+
* Tracks the quoted strings, comments and domain literals inside an angle-addr or a
|
|
899
|
+
* group, which the closer of either must not be read from. A backslash escapes the next
|
|
900
|
+
* character inside a quoted string and a comment. A domain literal is only tracked in an
|
|
901
|
+
* angle-addr, where it opens right after an "@" and ends at the "]". dtext holds the list
|
|
902
|
+
* separators as well, and inside the brackets they never split the list anyway. In a group
|
|
903
|
+
* they do, so a literal is not tracked there and an unclosed "[" can not swallow the
|
|
904
|
+
* recipients after it.
|
|
905
|
+
*
|
|
906
|
+
* @param chr Character from the address field
|
|
907
|
+
* @return true while the character sits inside one of them, the one that closes it included
|
|
908
|
+
*/
|
|
909
|
+
_trackInner(chr) {
|
|
910
|
+
if (this.innerEscaped) {
|
|
911
|
+
this.innerEscaped = false;
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
if (this.innerQuoted) {
|
|
915
|
+
if (chr === '\\') {
|
|
916
|
+
this.innerEscaped = true;
|
|
917
|
+
}
|
|
918
|
+
else if (chr === '"') {
|
|
919
|
+
this.innerQuoted = false;
|
|
920
|
+
}
|
|
921
|
+
return true;
|
|
922
|
+
}
|
|
923
|
+
if (this.innerComment) {
|
|
924
|
+
if (chr === '\\') {
|
|
925
|
+
this.innerEscaped = true;
|
|
926
|
+
}
|
|
927
|
+
else if (chr === '(') {
|
|
928
|
+
this.innerComment++;
|
|
929
|
+
}
|
|
930
|
+
else if (chr === ')') {
|
|
931
|
+
this.innerComment--;
|
|
932
|
+
}
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
if (this.innerLiteral) {
|
|
936
|
+
if (chr === ']') {
|
|
937
|
+
this.innerLiteral = false;
|
|
938
|
+
}
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
941
|
+
if (chr === '"') {
|
|
942
|
+
this.innerQuoted = true;
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
if (chr === '(') {
|
|
946
|
+
this.innerComment = 1;
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
if (chr === '[' && this.afterAt && this.operatorExpecting === '>') {
|
|
950
|
+
this.innerLiteral = true;
|
|
951
|
+
return true;
|
|
952
|
+
}
|
|
953
|
+
// afterAt is otherwise only kept outside the operators and is cleared when one opens
|
|
954
|
+
// or ends, so inside an angle-addr it serves the literal tracked here
|
|
955
|
+
if (!_isSpaceCode(chr.charCodeAt(0))) {
|
|
956
|
+
this.afterAt = chr === '@';
|
|
957
|
+
}
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Forgets the nesting collected for the operator that just opened or ended
|
|
962
|
+
*/
|
|
963
|
+
_resetInner() {
|
|
964
|
+
this.innerQuoted = false;
|
|
965
|
+
this.innerComment = 0;
|
|
966
|
+
this.innerLiteral = false;
|
|
967
|
+
this.innerEscaped = false;
|
|
968
|
+
}
|
|
704
969
|
}
|
|
705
970
|
/**
|
|
706
971
|
* Maximum recursion depth for parsing nested groups.
|
|
707
972
|
* RFC 5322 doesn't allow nested groups, so this is a safeguard against
|
|
708
|
-
* malicious input that could cause stack overflow.
|
|
973
|
+
* malicious input that could cause stack overflow. Every level parses what is left of the
|
|
974
|
+
* value once more, so the limit is also the factor a crafted header multiplies the parse by.
|
|
709
975
|
*/
|
|
710
|
-
const MAX_NESTED_GROUP_DEPTH =
|
|
976
|
+
const MAX_NESTED_GROUP_DEPTH = 10;
|
|
711
977
|
/**
|
|
712
978
|
* Parses an address list, recursing into the groups it holds.
|
|
713
979
|
*
|
|
@@ -64,6 +64,10 @@ const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
|
|
|
64
64
|
// else goes into angle brackets so that the header can not be read as more addresses than
|
|
65
65
|
// the envelope carries
|
|
66
66
|
const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
|
|
67
|
+
/**
|
|
68
|
+
* A domain literal that ends an address, see _normalizeAddress
|
|
69
|
+
*/
|
|
70
|
+
const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
|
|
67
71
|
/**
|
|
68
72
|
* Removes the whitespace inside every <...> of a References value. A "<" with no ">" after
|
|
69
73
|
* it is left as it is. An index scan, the regex /<[^>]*>/g rescans the rest of the value
|
|
@@ -1480,7 +1484,10 @@ class MimeNode {
|
|
|
1480
1484
|
// callers use an empty value to detect a missing address
|
|
1481
1485
|
return address;
|
|
1482
1486
|
}
|
|
1483
|
-
|
|
1487
|
+
// dtext includes '@', so a domain literal is split off at the '@' in front of it and
|
|
1488
|
+
// not at the last one, which delivered 'a@[x@evil.com]' to evil.com
|
|
1489
|
+
const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
|
|
1490
|
+
const lastAt = literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
|
|
1484
1491
|
if (lastAt < 0) {
|
|
1485
1492
|
// Bare username, there is no domain to split off
|
|
1486
1493
|
return this._normalizeLocalPart(address);
|
package/dist/cjs/package-info.js
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A domain literal that ends an address. dtext includes '@' (RFC 5322 3.4.1), so the '@'
|
|
3
|
+
* in front of the literal is the one that splits the domain off, not the last one: reading
|
|
4
|
+
* 'a@[x@evil.com]' at its last '@' made it an address at evil.com.
|
|
5
|
+
*/
|
|
6
|
+
const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
|
|
7
|
+
/**
|
|
8
|
+
* Offset of the '@' that splits the domain off an address, or -1 when there is none
|
|
9
|
+
*
|
|
10
|
+
* @param address Address to split
|
|
11
|
+
* @return Offset of the '@'
|
|
12
|
+
*/
|
|
13
|
+
function _domainAt(address) {
|
|
14
|
+
const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
|
|
15
|
+
return literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
|
|
16
|
+
}
|
|
1
17
|
/**
|
|
2
18
|
* Restores the quoting of a local part that was read out of a quoted string.
|
|
3
19
|
*
|
|
@@ -16,7 +32,7 @@
|
|
|
16
32
|
* @return Address with the local part as a quoted-string
|
|
17
33
|
*/
|
|
18
34
|
function _quoteLocalPart(address) {
|
|
19
|
-
const lastAt = address
|
|
35
|
+
const lastAt = _domainAt(address);
|
|
20
36
|
if (lastAt < 0) {
|
|
21
37
|
// no domain to split off, nothing can be misrouted
|
|
22
38
|
return address;
|
|
@@ -42,6 +58,23 @@ const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
|
|
|
42
58
|
* One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
|
|
43
59
|
*/
|
|
44
60
|
const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
|
|
61
|
+
/**
|
|
62
|
+
* The same with a domain literal for the domain, which may hold an '@' of its own.
|
|
63
|
+
*/
|
|
64
|
+
const LITERAL_ADDR_SPEC = /^[^@\s]+@\[[^[\]\\\s]*\]$/;
|
|
65
|
+
/**
|
|
66
|
+
* The '@' and domain that follow a quoted local part in the same run.
|
|
67
|
+
*/
|
|
68
|
+
const QUOTED_LOCAL_DOMAIN = /^@(?:[^@\s"]+|\[[^[\]\\\s]*\])$/;
|
|
69
|
+
/**
|
|
70
|
+
* Whether a whitespace free run reads as an addr-spec under the strict grammar
|
|
71
|
+
*
|
|
72
|
+
* @param value Run to check
|
|
73
|
+
* @return true when it does
|
|
74
|
+
*/
|
|
75
|
+
function _isAddrSpec(value) {
|
|
76
|
+
return ADDR_SPEC.test(value) || LITERAL_ADDR_SPEC.test(value);
|
|
77
|
+
}
|
|
45
78
|
/**
|
|
46
79
|
* The looser reading applied once the strict one finds nothing, which tolerates the
|
|
47
80
|
* further '@' that a domain should not have but malformed headers carry anyway.
|
|
@@ -108,6 +141,62 @@ function _indexOfAt(text, from, to) {
|
|
|
108
141
|
}
|
|
109
142
|
return -1;
|
|
110
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* A quoted-string a value opens with.
|
|
146
|
+
*/
|
|
147
|
+
const LEADING_QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"/;
|
|
148
|
+
/**
|
|
149
|
+
* Drops the whitespace on either side of the '@' at an offset.
|
|
150
|
+
*
|
|
151
|
+
* obs-local-part and obs-domain allow CFWS around the '@' (RFC 5322 4.4), so 'user @ good.com'
|
|
152
|
+
* names user@good.com. Kept, the whitespace went into the envelope as '"user "@ good.com'.
|
|
153
|
+
* The scan is by index, a pattern such as /\s+@/ backtracks over a long whitespace run that
|
|
154
|
+
* no '@' follows.
|
|
155
|
+
*
|
|
156
|
+
* @param value Value holding the '@'
|
|
157
|
+
* @param at Offset of the '@', or -1
|
|
158
|
+
* @return The value with the whitespace beside that '@' removed
|
|
159
|
+
*/
|
|
160
|
+
function _joinAtSpacing(value, at) {
|
|
161
|
+
if (at < 0) {
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
let start = at;
|
|
165
|
+
while (start > 0 && _isSpaceCode(value.charCodeAt(start - 1))) {
|
|
166
|
+
start--;
|
|
167
|
+
}
|
|
168
|
+
let end = at + 1;
|
|
169
|
+
while (end < value.length && _isSpaceCode(value.charCodeAt(end))) {
|
|
170
|
+
end++;
|
|
171
|
+
}
|
|
172
|
+
return start === at && end === at + 1 ? value : value.slice(0, start) + '@' + value.slice(end);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Drops the whitespace beside the '@' that splits the domain off a value. That is the '@'
|
|
176
|
+
* right after a quoted local part the value opens with, or else the only '@' it holds outside
|
|
177
|
+
* any quoted string. With several it is not clear which one the whitespace belongs to, and
|
|
178
|
+
* '<x@evil.com @good.com>' is left to the addr-spec recovery to read as it always has.
|
|
179
|
+
*
|
|
180
|
+
* @param value Value to clean up
|
|
181
|
+
* @return The value with the whitespace beside that '@' removed
|
|
182
|
+
*/
|
|
183
|
+
function _collapseAtSpacing(value) {
|
|
184
|
+
const quoted = LEADING_QUOTED_STRING.exec(value);
|
|
185
|
+
if (quoted) {
|
|
186
|
+
let at = quoted[0].length;
|
|
187
|
+
while (at < value.length && _isSpaceCode(value.charCodeAt(at))) {
|
|
188
|
+
at++;
|
|
189
|
+
}
|
|
190
|
+
if (value.charAt(at) === '@' && value.indexOf('@', at + 1) < 0) {
|
|
191
|
+
return _joinAtSpacing(value, at);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const at = value.indexOf('@');
|
|
195
|
+
if (at < 0 || value.indexOf('@', at + 1) >= 0 || value.lastIndexOf('"') > at) {
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
return _joinAtSpacing(value, at);
|
|
199
|
+
}
|
|
111
200
|
/**
|
|
112
201
|
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
|
|
113
202
|
*
|
|
@@ -197,6 +286,7 @@ function _looseAddressStart(text) {
|
|
|
197
286
|
* @param data Collected address parts, mutated in place
|
|
198
287
|
*/
|
|
199
288
|
function _recoverAddrSpec(data) {
|
|
289
|
+
data.address = _collapseAtSpacing(data.address);
|
|
200
290
|
if (!HAS_WHITESPACE.test(data.address)) {
|
|
201
291
|
return;
|
|
202
292
|
}
|
|
@@ -223,7 +313,7 @@ function _recoverAddrSpec(data) {
|
|
|
223
313
|
return;
|
|
224
314
|
}
|
|
225
315
|
const parts = data.address.split(/\s+/);
|
|
226
|
-
let addrIndex = parts.findIndex(
|
|
316
|
+
let addrIndex = parts.findIndex(_isAddrSpec);
|
|
227
317
|
if (addrIndex < 0) {
|
|
228
318
|
addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
|
|
229
319
|
}
|
|
@@ -250,6 +340,15 @@ function _recoverAddrSpec(data) {
|
|
|
250
340
|
* A comment is folding whitespace, so it is read as a space that splits two atoms and as
|
|
251
341
|
* nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted
|
|
252
342
|
* strings and domain literals are copied through untouched, a '(' in there is not a comment.
|
|
343
|
+
* An unexpected '<' outside them drops everything in front of it, comments included.
|
|
344
|
+
*
|
|
345
|
+
* RFC 822 also let an angle-addr name the relays to go through in front of the mailbox,
|
|
346
|
+
* '<@relay1,@relay2:user@example.com>', which RFC 5322 still parses as obs-route. The route
|
|
347
|
+
* is not part of the mailbox, and folded into the local part it made
|
|
348
|
+
* '"@relay1,@relay2:user"@example.com' a recipient that does not exist. A local part can not
|
|
349
|
+
* start with '@', so contents that do, up to the first ':' outside a domain literal and in
|
|
350
|
+
* front of any quoted string, are a route and are dropped, as long as a mailbox with an '@'
|
|
351
|
+
* of its own follows.
|
|
253
352
|
*
|
|
254
353
|
* @param address Contents of the angle brackets
|
|
255
354
|
* @return The address with the comments removed, and the text of the comments
|
|
@@ -263,6 +362,16 @@ function _stripAddressComments(address) {
|
|
|
263
362
|
// carried along rather than read back off the growing result, which would flatten it on
|
|
264
363
|
// every comment (see lastChars in _handleAddress)
|
|
265
364
|
let lastChar = '';
|
|
365
|
+
// A comment that just closed stands for a space, written once the next character shows
|
|
366
|
+
// whether one is wanted: not beside an '@', and not on top of whitespace that follows.
|
|
367
|
+
// Deciding on the spot saw only the character right after the ')', so a second comment
|
|
368
|
+
// in a row hid the '@' behind it and 'a(x)(y)@b' read as 'a @b'
|
|
369
|
+
let pendingSpace = false;
|
|
370
|
+
// first character of the result and whether a quoted string has opened since, which
|
|
371
|
+
// together tell a route from a local part
|
|
372
|
+
let firstChar = '';
|
|
373
|
+
let quoteSeen = false;
|
|
374
|
+
let routeEnd = -1;
|
|
266
375
|
for (let i = 0, len = address.length; i < len; i++) {
|
|
267
376
|
const chr = address.charAt(i);
|
|
268
377
|
if (depth) {
|
|
@@ -276,16 +385,20 @@ function _stripAddressComments(address) {
|
|
|
276
385
|
else if (chr === ')' && !--depth) {
|
|
277
386
|
comments.push(comment.trim());
|
|
278
387
|
comment = '';
|
|
279
|
-
|
|
280
|
-
result += ' ';
|
|
281
|
-
lastChar = ' ';
|
|
282
|
-
}
|
|
388
|
+
pendingSpace = lastChar !== '@';
|
|
283
389
|
}
|
|
284
390
|
else {
|
|
285
391
|
comment += chr;
|
|
286
392
|
}
|
|
287
393
|
continue;
|
|
288
394
|
}
|
|
395
|
+
if (pendingSpace && chr !== '(') {
|
|
396
|
+
pendingSpace = false;
|
|
397
|
+
if (chr !== '@' && chr !== '<' && !_isSpaceCode(address.charCodeAt(i))) {
|
|
398
|
+
result += ' ';
|
|
399
|
+
lastChar = ' ';
|
|
400
|
+
}
|
|
401
|
+
}
|
|
289
402
|
if (closer) {
|
|
290
403
|
if (chr === '\\' && closer === '"' && i < len - 1) {
|
|
291
404
|
result += chr + address.charAt(++i);
|
|
@@ -298,6 +411,10 @@ function _stripAddressComments(address) {
|
|
|
298
411
|
}
|
|
299
412
|
else if (chr === '"') {
|
|
300
413
|
closer = '"';
|
|
414
|
+
quoteSeen = true;
|
|
415
|
+
}
|
|
416
|
+
else if (chr === ':' && routeEnd < 0 && firstChar === '@' && !quoteSeen) {
|
|
417
|
+
routeEnd = result.length + 1;
|
|
301
418
|
}
|
|
302
419
|
else if (chr === '[') {
|
|
303
420
|
closer = ']';
|
|
@@ -306,8 +423,27 @@ function _stripAddressComments(address) {
|
|
|
306
423
|
depth = 1;
|
|
307
424
|
continue;
|
|
308
425
|
}
|
|
426
|
+
else if (chr === '<') {
|
|
427
|
+
// Apple Mail drops everything between an unexpected '<' and the address after it.
|
|
428
|
+
// Read here rather than off the raw brackets so that a '<' inside a quoted string,
|
|
429
|
+
// a comment or a domain literal stays where it is: '<"a<b"@victim.com>' cut at
|
|
430
|
+
// the first '<' left the address 'b"@victim.com'
|
|
431
|
+
result = '';
|
|
432
|
+
lastChar = '';
|
|
433
|
+
comments.length = 0;
|
|
434
|
+
firstChar = '';
|
|
435
|
+
quoteSeen = false;
|
|
436
|
+
routeEnd = -1;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
309
439
|
result += chr;
|
|
310
440
|
lastChar = chr;
|
|
441
|
+
if (!firstChar && !_isSpaceCode(address.charCodeAt(i))) {
|
|
442
|
+
firstChar = chr;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (routeEnd >= 0 && result.indexOf('@', routeEnd) >= 0) {
|
|
446
|
+
result = result.slice(routeEnd);
|
|
311
447
|
}
|
|
312
448
|
if (depth) {
|
|
313
449
|
// an unterminated comment runs to the end of the address
|
|
@@ -331,9 +467,13 @@ function _handleAddress(tokens, depth) {
|
|
|
331
467
|
comment: [],
|
|
332
468
|
group: [],
|
|
333
469
|
text: [],
|
|
334
|
-
textWasQuoted: []
|
|
470
|
+
textWasQuoted: [],
|
|
471
|
+
quotedLocalAt: []
|
|
335
472
|
};
|
|
336
473
|
let insideQuotes = false;
|
|
474
|
+
// whether the last text run holds nothing but quoted text so far, which is what a quoted
|
|
475
|
+
// local part is until the '@' after it joins the run
|
|
476
|
+
let lastTextPureQuoted = false;
|
|
337
477
|
// Last character of the run each state is currently accumulating. Reading it back off
|
|
338
478
|
// the accumulator with slice(-1) makes the engine flatten the whole growing string on
|
|
339
479
|
// every token, which is quadratic over an address built from many comment-joined atoms
|
|
@@ -383,11 +523,6 @@ function _handleAddress(tokens, depth) {
|
|
|
383
523
|
!!prevToken.noBreak &&
|
|
384
524
|
prevPrevToken?.type === 'operator' &&
|
|
385
525
|
prevPrevToken.value === '"';
|
|
386
|
-
if (state === 'address') {
|
|
387
|
-
// Handle unquoted name that includes a "<".
|
|
388
|
-
// Apple Mail truncates everything between an unexpected < and an address.
|
|
389
|
-
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
|
390
|
-
}
|
|
391
526
|
// A comment is folding whitespace. It may sit inside an addr-spec, on either side
|
|
392
527
|
// of the '@', but it cannot join two atoms into one: gluing across it would read
|
|
393
528
|
// 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
|
|
@@ -398,12 +533,19 @@ function _handleAddress(tokens, depth) {
|
|
|
398
533
|
parts.length &&
|
|
399
534
|
(prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@');
|
|
400
535
|
if (joins) {
|
|
401
|
-
data[state]
|
|
536
|
+
const last = data[state].length - 1;
|
|
537
|
+
if (state === 'text' && !insideQuotes) {
|
|
538
|
+
if (lastTextPureQuoted && token.value.charAt(0) === '@') {
|
|
539
|
+
data.quotedLocalAt[last] = data.text[last].length;
|
|
540
|
+
}
|
|
541
|
+
lastTextPureQuoted = false;
|
|
542
|
+
}
|
|
543
|
+
data[state][last] += token.value;
|
|
402
544
|
if (token.value) {
|
|
403
545
|
lastChars[state] = token.value.charAt(token.value.length - 1);
|
|
404
546
|
}
|
|
405
547
|
if (state === 'text' && insideQuotes) {
|
|
406
|
-
data.textWasQuoted[
|
|
548
|
+
data.textWasQuoted[last] = true;
|
|
407
549
|
}
|
|
408
550
|
}
|
|
409
551
|
else {
|
|
@@ -411,14 +553,21 @@ function _handleAddress(tokens, depth) {
|
|
|
411
553
|
lastChars[state] = token.value.charAt(token.value.length - 1);
|
|
412
554
|
if (state === 'text') {
|
|
413
555
|
data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
|
|
556
|
+
// an empty quoted string leaves no text of its own, so the '@' opens the run
|
|
557
|
+
data.quotedLocalAt.push(opensAfterEmptyQuotedString && token.value.charAt(0) === '@' ? 0 : -1);
|
|
558
|
+
lastTextPureQuoted = insideQuotes;
|
|
414
559
|
}
|
|
415
560
|
}
|
|
416
561
|
}
|
|
417
562
|
}
|
|
418
|
-
// If there is no text but a comment, replace the two
|
|
563
|
+
// If there is no text but a comment, replace the two. A comment is never part of a
|
|
564
|
+
// mailbox, so text that came out of one only ever names it: reading an address out of it
|
|
565
|
+
// made '(a@evil.com)' a recipient where no conformant reading has a mailbox at all
|
|
566
|
+
let textFromComment = false;
|
|
419
567
|
if (!data.text.length && data.comment.length) {
|
|
420
568
|
data.text = data.comment;
|
|
421
569
|
data.comment = [];
|
|
570
|
+
textFromComment = true;
|
|
422
571
|
}
|
|
423
572
|
if (isGroup) {
|
|
424
573
|
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
|
@@ -447,10 +596,6 @@ function _handleAddress(tokens, depth) {
|
|
|
447
596
|
const addressComments = [];
|
|
448
597
|
const addressParts = [];
|
|
449
598
|
for (const part of data.address) {
|
|
450
|
-
if (part.indexOf('(') < 0) {
|
|
451
|
-
addressParts.push(part);
|
|
452
|
-
continue;
|
|
453
|
-
}
|
|
454
599
|
const stripped = _stripAddressComments(part);
|
|
455
600
|
for (const comment of stripped.comments) {
|
|
456
601
|
addressComments.push(comment);
|
|
@@ -461,25 +606,42 @@ function _handleAddress(tokens, depth) {
|
|
|
461
606
|
}
|
|
462
607
|
data.address = addressParts;
|
|
463
608
|
// If no address was found, try to detect one from regular text
|
|
464
|
-
if (!data.address.length && data.text.length) {
|
|
465
|
-
|
|
609
|
+
if (!data.address.length && data.text.length && !textFromComment) {
|
|
610
|
+
// Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both
|
|
611
|
+
// tiers read the collapsed runs, and only a run an address comes out of is replaced
|
|
612
|
+
// by its collapsed form, the display text keeps its spacing
|
|
613
|
+
const candidates = data.text.map((part) => _collapseAtSpacing(part));
|
|
614
|
+
// The first addr-spec in the text is the mailbox, as it is for a conformant parser
|
|
615
|
+
// reading as far as it can. Taking the last one let a comment part two of them and
|
|
616
|
+
// pick the second, 'user@good.com (x) evil@evil.com' went to evil.com, while the
|
|
617
|
+
// same two without the comment, one run, went to good.com
|
|
618
|
+
for (let i = 0; i < data.text.length; i++) {
|
|
466
619
|
// Security: Do not extract email addresses from quoted strings.
|
|
467
620
|
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
|
|
468
621
|
// Extracting emails from quoted text leads to misrouting vulnerabilities.
|
|
469
|
-
|
|
470
|
-
|
|
622
|
+
// A run that is a quoted local part with its '@' and domain is an addr-spec of
|
|
623
|
+
// its own, with the quoting put back on, and takes its turn like any other
|
|
624
|
+
const local = data.quotedLocalAt[i];
|
|
625
|
+
const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local))
|
|
626
|
+
? _quoteLocalPart(data.text[i])
|
|
627
|
+
: '';
|
|
628
|
+
if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) {
|
|
629
|
+
data.address = [quotedLocal || candidates[i]];
|
|
630
|
+
data.text.splice(i, 1);
|
|
471
631
|
data.textWasQuoted.splice(i, 1);
|
|
632
|
+
data.quotedLocalAt.splice(i, 1);
|
|
472
633
|
break;
|
|
473
634
|
}
|
|
474
635
|
}
|
|
475
636
|
// Try a looser regex match if strict match found nothing
|
|
476
637
|
if (!data.address.length) {
|
|
477
638
|
let extracted = false;
|
|
478
|
-
for (let i = data.text.length
|
|
639
|
+
for (let i = 0; i < data.text.length; i++) {
|
|
479
640
|
// Security: Do not extract email addresses from quoted strings
|
|
480
641
|
if (!data.textWasQuoted[i]) {
|
|
481
|
-
|
|
482
|
-
|
|
642
|
+
// the strict tier found nothing, so it spliced nothing and the indexes still line up
|
|
643
|
+
const part = candidates[i];
|
|
644
|
+
let remainder = data.text[i];
|
|
483
645
|
const at = _looseAddressStart(part);
|
|
484
646
|
if (at >= 0) {
|
|
485
647
|
LOOSE_TEXT_ADDR.lastIndex = at;
|
|
@@ -528,16 +690,19 @@ function _handleAddress(tokens, depth) {
|
|
|
528
690
|
// on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
|
|
529
691
|
// back first is what lets the recovery tell the whitespace an addr-spec may carry
|
|
530
692
|
// from the wreckage trailing one, as only a quoted local part may hold whitespace
|
|
531
|
-
|
|
693
|
+
// The quotes are gone at this point, so the '@' the domain splits off at is the
|
|
694
|
+
// last one, not the single one _collapseAtSpacing insists on
|
|
695
|
+
data.address = _quoteLocalPart(_joinAtSpacing(data.text, _domainAt(data.text)));
|
|
532
696
|
data.text = '';
|
|
533
697
|
}
|
|
534
698
|
_recoverAddrSpec(data);
|
|
535
699
|
// a comment names the mailbox only when nothing else does, as one outside the brackets
|
|
536
700
|
if (!data.text && addressComments.length) {
|
|
537
701
|
data.text = addressComments.join(' ');
|
|
702
|
+
textFromComment = true;
|
|
538
703
|
}
|
|
539
704
|
const address = {
|
|
540
|
-
address: data.address || data.text || '',
|
|
705
|
+
address: data.address || (textFromComment ? '' : data.text) || '',
|
|
541
706
|
name: data.text || data.address || ''
|
|
542
707
|
};
|
|
543
708
|
if (address.address === address.name) {
|
|
@@ -567,6 +732,10 @@ class Tokenizer {
|
|
|
567
732
|
this.escaped = false;
|
|
568
733
|
this.inDomainLiteral = false;
|
|
569
734
|
this.afterAt = false;
|
|
735
|
+
this.innerQuoted = false;
|
|
736
|
+
this.innerComment = 0;
|
|
737
|
+
this.innerLiteral = false;
|
|
738
|
+
this.innerEscaped = false;
|
|
570
739
|
this.list = [];
|
|
571
740
|
/**
|
|
572
741
|
* Operator tokens and which tokens are expected to end the sequence
|
|
@@ -633,10 +802,32 @@ class Tokenizer {
|
|
|
633
802
|
this.inDomainLiteral = false;
|
|
634
803
|
}
|
|
635
804
|
}
|
|
805
|
+
// The closer of an angle-addr or a group is only read outside the quoted strings,
|
|
806
|
+
// comments and domain literals it holds. Looking for nothing but the closer let a '>'
|
|
807
|
+
// inside a quoted local part end the angle-addr, '<"a@evil.com>"@good.com>' was read
|
|
808
|
+
// as an address at evil.com, and a ';' inside a quoted display name or a comment end
|
|
809
|
+
// the group and release the rest of that text as recipients. The characters are still
|
|
810
|
+
// collected as they are, the angle-addr and the group content are read again later
|
|
811
|
+
let nested = false;
|
|
812
|
+
if (this.operatorExpecting === '>' || this.operatorExpecting === ';') {
|
|
813
|
+
nested = this._trackInner(chr);
|
|
814
|
+
}
|
|
815
|
+
else if (this.operatorExpecting === ')' && !this.escaped) {
|
|
816
|
+
// Comments nest (RFC 5322 3.2.2). Ending one at the first ')' let the ')' of a
|
|
817
|
+
// nested comment close the outer one and released the rest of it, an address
|
|
818
|
+
// included, into the header
|
|
819
|
+
if (chr === '(') {
|
|
820
|
+
this.innerComment++;
|
|
821
|
+
}
|
|
822
|
+
else if (chr === ')' && this.innerComment) {
|
|
823
|
+
this.innerComment--;
|
|
824
|
+
nested = true;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
636
827
|
if (this.escaped) {
|
|
637
828
|
// ignore next condition blocks
|
|
638
829
|
}
|
|
639
|
-
else if (chr === this.operatorExpecting) {
|
|
830
|
+
else if (chr === this.operatorExpecting && !nested) {
|
|
640
831
|
this.node = {
|
|
641
832
|
type: 'operator',
|
|
642
833
|
value: chr
|
|
@@ -652,6 +843,7 @@ class Tokenizer {
|
|
|
652
843
|
}
|
|
653
844
|
this.operatorExpecting = '';
|
|
654
845
|
this.escaped = false;
|
|
846
|
+
this._resetInner();
|
|
655
847
|
return;
|
|
656
848
|
}
|
|
657
849
|
else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ':') && chr in this.operators) {
|
|
@@ -666,6 +858,7 @@ class Tokenizer {
|
|
|
666
858
|
}
|
|
667
859
|
this.operatorExpecting = this.operators[chr];
|
|
668
860
|
this.escaped = false;
|
|
861
|
+
this._resetInner();
|
|
669
862
|
return;
|
|
670
863
|
}
|
|
671
864
|
else if (['"', "'", ')'].includes(this.operatorExpecting) && chr === '\\') {
|
|
@@ -699,13 +892,86 @@ class Tokenizer {
|
|
|
699
892
|
}
|
|
700
893
|
this.escaped = false;
|
|
701
894
|
}
|
|
895
|
+
/**
|
|
896
|
+
* Tracks the quoted strings, comments and domain literals inside an angle-addr or a
|
|
897
|
+
* group, which the closer of either must not be read from. A backslash escapes the next
|
|
898
|
+
* character inside a quoted string and a comment. A domain literal is only tracked in an
|
|
899
|
+
* angle-addr, where it opens right after an "@" and ends at the "]". dtext holds the list
|
|
900
|
+
* separators as well, and inside the brackets they never split the list anyway. In a group
|
|
901
|
+
* they do, so a literal is not tracked there and an unclosed "[" can not swallow the
|
|
902
|
+
* recipients after it.
|
|
903
|
+
*
|
|
904
|
+
* @param chr Character from the address field
|
|
905
|
+
* @return true while the character sits inside one of them, the one that closes it included
|
|
906
|
+
*/
|
|
907
|
+
_trackInner(chr) {
|
|
908
|
+
if (this.innerEscaped) {
|
|
909
|
+
this.innerEscaped = false;
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
if (this.innerQuoted) {
|
|
913
|
+
if (chr === '\\') {
|
|
914
|
+
this.innerEscaped = true;
|
|
915
|
+
}
|
|
916
|
+
else if (chr === '"') {
|
|
917
|
+
this.innerQuoted = false;
|
|
918
|
+
}
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
if (this.innerComment) {
|
|
922
|
+
if (chr === '\\') {
|
|
923
|
+
this.innerEscaped = true;
|
|
924
|
+
}
|
|
925
|
+
else if (chr === '(') {
|
|
926
|
+
this.innerComment++;
|
|
927
|
+
}
|
|
928
|
+
else if (chr === ')') {
|
|
929
|
+
this.innerComment--;
|
|
930
|
+
}
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
if (this.innerLiteral) {
|
|
934
|
+
if (chr === ']') {
|
|
935
|
+
this.innerLiteral = false;
|
|
936
|
+
}
|
|
937
|
+
return true;
|
|
938
|
+
}
|
|
939
|
+
if (chr === '"') {
|
|
940
|
+
this.innerQuoted = true;
|
|
941
|
+
return true;
|
|
942
|
+
}
|
|
943
|
+
if (chr === '(') {
|
|
944
|
+
this.innerComment = 1;
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
if (chr === '[' && this.afterAt && this.operatorExpecting === '>') {
|
|
948
|
+
this.innerLiteral = true;
|
|
949
|
+
return true;
|
|
950
|
+
}
|
|
951
|
+
// afterAt is otherwise only kept outside the operators and is cleared when one opens
|
|
952
|
+
// or ends, so inside an angle-addr it serves the literal tracked here
|
|
953
|
+
if (!_isSpaceCode(chr.charCodeAt(0))) {
|
|
954
|
+
this.afterAt = chr === '@';
|
|
955
|
+
}
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Forgets the nesting collected for the operator that just opened or ended
|
|
960
|
+
*/
|
|
961
|
+
_resetInner() {
|
|
962
|
+
this.innerQuoted = false;
|
|
963
|
+
this.innerComment = 0;
|
|
964
|
+
this.innerLiteral = false;
|
|
965
|
+
this.innerEscaped = false;
|
|
966
|
+
}
|
|
702
967
|
}
|
|
703
968
|
/**
|
|
704
969
|
* Maximum recursion depth for parsing nested groups.
|
|
705
970
|
* RFC 5322 doesn't allow nested groups, so this is a safeguard against
|
|
706
|
-
* malicious input that could cause stack overflow.
|
|
971
|
+
* malicious input that could cause stack overflow. Every level parses what is left of the
|
|
972
|
+
* value once more, so the limit is also the factor a crafted header multiplies the parse by.
|
|
707
973
|
*/
|
|
708
|
-
const MAX_NESTED_GROUP_DEPTH =
|
|
974
|
+
const MAX_NESTED_GROUP_DEPTH = 10;
|
|
709
975
|
/**
|
|
710
976
|
* Parses an address list, recursing into the groups it holds.
|
|
711
977
|
*
|
|
@@ -26,6 +26,10 @@ const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
|
|
|
26
26
|
// else goes into angle brackets so that the header can not be read as more addresses than
|
|
27
27
|
// the envelope carries
|
|
28
28
|
const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
|
|
29
|
+
/**
|
|
30
|
+
* A domain literal that ends an address, see _normalizeAddress
|
|
31
|
+
*/
|
|
32
|
+
const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
|
|
29
33
|
/**
|
|
30
34
|
* Removes the whitespace inside every <...> of a References value. A "<" with no ">" after
|
|
31
35
|
* it is left as it is. An index scan, the regex /<[^>]*>/g rescans the rest of the value
|
|
@@ -1442,7 +1446,10 @@ class MimeNode {
|
|
|
1442
1446
|
// callers use an empty value to detect a missing address
|
|
1443
1447
|
return address;
|
|
1444
1448
|
}
|
|
1445
|
-
|
|
1449
|
+
// dtext includes '@', so a domain literal is split off at the '@' in front of it and
|
|
1450
|
+
// not at the last one, which delivered 'a@[x@evil.com]' to evil.com
|
|
1451
|
+
const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
|
|
1452
|
+
const lastAt = literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
|
|
1446
1453
|
if (lastAt < 0) {
|
|
1447
1454
|
// Bare username, there is no domain to split off
|
|
1448
1455
|
return this._normalizeLocalPart(address);
|
package/dist/esm/package-info.js
CHANGED