nodemailer 10.1.4 → 10.1.6

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.
@@ -1,5 +1,83 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ /* eslint-disable no-bitwise -- the kind of a token is a set of bit flags packed into one slot of a typed array */
4
+ /** The token is an operator, a text run otherwise */
5
+ const TOKEN_OPERATOR = 1;
6
+ /** The next character continues the same run without whitespace in between */
7
+ const TOKEN_NO_BREAK = 2;
8
+ /** The text run holds characters its value leaves out or changes, so it is no plain slice */
9
+ const TOKEN_DIRTY = 4;
10
+ /** A backslash escapes the next character in the text run, inside a quoted string or a comment */
11
+ const TOKEN_ESCAPES = 8;
12
+ /** The text run opens on a character an escaping backslash in front of it has escaped */
13
+ const TOKEN_STARTS_ESCAPED = 16;
14
+ /**
15
+ * The tokens of an address field, kept as offsets into it.
16
+ *
17
+ * Every token used to be an object of its own, an operator for every ',' and a value string
18
+ * for every run of text, and a list of one letter names, 'a,a,a,...', took some 65 times its
19
+ * size in tokens before any address was read. A token is now three numbers in one typed
20
+ * array, its kind and the range of the field it covers, and its value is sliced off the field
21
+ * when it is read. The few text runs whose value differs from their slice, as they hold an
22
+ * escaping backslash, a line break or a control character, are rebuilt once and kept aside.
23
+ */
24
+ class TokenList {
25
+ constructor(str) {
26
+ this.str = str;
27
+ this.slots = new Int32Array(48);
28
+ this.length = 0;
29
+ this.cleaned = new Map();
30
+ }
31
+ /**
32
+ * Appends a token
33
+ *
34
+ * @param kind Kind and flags of the token
35
+ * @param start Offset of its first character
36
+ * @param end Offset just past its last character
37
+ * @return Index of the token
38
+ */
39
+ push(kind, start, end) {
40
+ if ((this.length + 1) * 3 > this.slots.length) {
41
+ const grown = new Int32Array(this.slots.length * 2);
42
+ grown.set(this.slots);
43
+ this.slots = grown;
44
+ }
45
+ const at = this.length * 3;
46
+ this.slots[at] = kind;
47
+ this.slots[at + 1] = start;
48
+ this.slots[at + 2] = end;
49
+ return this.length++;
50
+ }
51
+ /**
52
+ * The character of an operator token, an empty string for a text run
53
+ */
54
+ operatorAt(index) {
55
+ const at = index * 3;
56
+ return this.slots[at] & TOKEN_OPERATOR ? this.str.charAt(this.slots[at + 1]) : '';
57
+ }
58
+ /**
59
+ * Whether a token is an operator
60
+ */
61
+ isOperator(index) {
62
+ return (this.slots[index * 3] & TOKEN_OPERATOR) !== 0;
63
+ }
64
+ /**
65
+ * Whether the next character continues the run of a token without whitespace in between
66
+ */
67
+ noBreak(index) {
68
+ return (this.slots[index * 3] & TOKEN_NO_BREAK) !== 0;
69
+ }
70
+ /**
71
+ * Value of a token
72
+ */
73
+ value(index) {
74
+ const cleaned = this.cleaned.get(index);
75
+ if (cleaned !== undefined) {
76
+ return cleaned;
77
+ }
78
+ return this.str.slice(this.slots[index * 3 + 1], this.slots[index * 3 + 2]);
79
+ }
80
+ }
3
81
  /**
4
82
  * A domain literal that ends an address. dtext includes '@' (RFC 5322 3.4.1), so the '@'
5
83
  * in front of the literal is the one that splits the domain off, not the last one: reading
@@ -454,20 +532,19 @@ function _stripAddressComments(address) {
454
532
  return { address: result.trim(), comments: comments.filter(text => text) };
455
533
  }
456
534
  /**
457
- * Converts tokens for a single address into an address object
535
+ * Walks the tokens of one entry of an address list and sorts them into the runs of display
536
+ * text, angle-addr and comment they belong to
458
537
  *
459
- * @param tokens Tokens object
460
- * @param depth Current recursion depth for nested group protection
461
- * @return Address object
538
+ * @param tokens Tokens of the whole address list
539
+ * @param from Index of the first token of the entry
540
+ * @param to Index just past the last token of the entry
541
+ * @return The collected runs, and whether the display text came out of a comment
462
542
  */
463
- function _handleAddress(tokens, depth) {
464
- let isGroup = false;
543
+ function _collectAddressParts(tokens, from, to) {
465
544
  let state = 'text';
466
- const addresses = [];
467
545
  const data = {
468
546
  address: [],
469
547
  comment: [],
470
- group: [],
471
548
  text: [],
472
549
  textWasQuoted: [],
473
550
  quotedLocalAt: []
@@ -481,13 +558,15 @@ function _handleAddress(tokens, depth) {
481
558
  // every token, which is quadratic over an address built from many comment-joined atoms
482
559
  // (GHSA-prgh-xp8r-p3m5). A run only ever grows by the token appended below, so the
483
560
  // character is carried along instead of re-read.
484
- const lastChars = { address: '', comment: '', group: '', text: '' };
561
+ const lastChars = { address: '', comment: '', text: '' };
485
562
  // Filter out <addresses>, (comments) and regular text
486
- for (let i = 0, len = tokens.length; i < len; i++) {
487
- const token = tokens[i];
488
- const prevToken = i ? tokens[i - 1] : null;
489
- if (token.type === 'operator') {
490
- switch (token.value) {
563
+ for (let i = from; i < to; i++) {
564
+ const value = tokens.value(i);
565
+ // the operator in front of this token, if there is one, and whether it runs on into it
566
+ const prevOperator = i > from ? tokens.operatorAt(i - 1) : '';
567
+ const prevNoBreak = i > from && tokens.noBreak(i - 1);
568
+ if (tokens.isOperator(i)) {
569
+ switch (value) {
491
570
  case '<':
492
571
  state = 'address';
493
572
  insideQuotes = false;
@@ -496,11 +575,6 @@ function _handleAddress(tokens, depth) {
496
575
  state = 'comment';
497
576
  insideQuotes = false;
498
577
  break;
499
- case ':':
500
- state = 'group';
501
- isGroup = true;
502
- insideQuotes = false;
503
- break;
504
578
  case '"':
505
579
  insideQuotes = !insideQuotes;
506
580
  state = 'text';
@@ -511,7 +585,7 @@ function _handleAddress(tokens, depth) {
511
585
  break;
512
586
  }
513
587
  }
514
- else if (token.value) {
588
+ else {
515
589
  // An empty quoted string is dropped by the tokenizer, leaving no text token of its
516
590
  // own for textWasQuoted to be recorded on, so the pair of quote operators right in
517
591
  // front of this token is all that is left of it and the run it opens carries the
@@ -519,44 +593,34 @@ function _handleAddress(tokens, depth) {
519
593
  // the quotes never go back on, and the value is no longer an addr-spec a trailing
520
594
  // comment can be peeled off of. It only ever opens a run: a run that already holds
521
595
  // material collected outside the quotes is not a quoted string, whatever follows it
522
- const prevPrevToken = i > 1 ? tokens[i - 2] : null;
523
- const opensAfterEmptyQuotedString = prevToken?.type === 'operator' &&
524
- prevToken.value === '"' &&
525
- !!prevToken.noBreak &&
526
- prevPrevToken?.type === 'operator' &&
527
- prevPrevToken.value === '"';
596
+ const opensAfterEmptyQuotedString = prevOperator === '"' && prevNoBreak && i > from + 1 && tokens.operatorAt(i - 2) === '"';
528
597
  // A comment is folding whitespace. It may sit inside an addr-spec, on either side
529
598
  // of the '@', but it cannot join two atoms into one: gluing across it would read
530
599
  // 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
531
600
  // deliver to a domain the sender never named.
532
601
  const parts = data[state];
533
- const joins = prevToken &&
534
- prevToken.noBreak &&
535
- parts.length &&
536
- (prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@');
602
+ const joins = prevNoBreak && parts.length && (prevOperator !== ')' || lastChars[state] === '@' || value.charAt(0) === '@');
537
603
  if (joins) {
538
604
  const last = data[state].length - 1;
539
605
  if (state === 'text' && !insideQuotes) {
540
- if (lastTextPureQuoted && token.value.charAt(0) === '@') {
606
+ if (lastTextPureQuoted && value.charAt(0) === '@') {
541
607
  data.quotedLocalAt[last] = data.text[last].length;
542
608
  }
543
609
  lastTextPureQuoted = false;
544
610
  }
545
- data[state][last] += token.value;
546
- if (token.value) {
547
- lastChars[state] = token.value.charAt(token.value.length - 1);
548
- }
611
+ data[state][last] += value;
612
+ lastChars[state] = value.charAt(value.length - 1);
549
613
  if (state === 'text' && insideQuotes) {
550
614
  data.textWasQuoted[last] = true;
551
615
  }
552
616
  }
553
617
  else {
554
- data[state].push(token.value);
555
- lastChars[state] = token.value.charAt(token.value.length - 1);
618
+ data[state].push(value);
619
+ lastChars[state] = value.charAt(value.length - 1);
556
620
  if (state === 'text') {
557
621
  data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
558
622
  // 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);
623
+ data.quotedLocalAt.push(opensAfterEmptyQuotedString && value.charAt(0) === '@' ? 0 : -1);
560
624
  lastTextPureQuoted = insideQuotes;
561
625
  }
562
626
  }
@@ -571,154 +635,158 @@ function _handleAddress(tokens, depth) {
571
635
  data.comment = [];
572
636
  textFromComment = true;
573
637
  }
574
- if (isGroup) {
575
- // http://tools.ietf.org/html/rfc2822#appendix-A.1.3
576
- data.text = data.text.join(' ');
577
- // Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting)
578
- let groupMembers = [];
579
- if (data.group.length) {
580
- const parsedGroup = _parseAddressList(data.group.join(','), depth + 1);
581
- parsedGroup.forEach(member => {
582
- if (member.group) {
583
- groupMembers = groupMembers.concat(member.group);
584
- }
585
- else {
586
- groupMembers.push(member);
587
- }
588
- });
638
+ return { data, textFromComment };
639
+ }
640
+ /**
641
+ * Name of a group, from the tokens in front of its ':'. A comment names it when no text does,
642
+ * as it names a mailbox.
643
+ *
644
+ * @param tokens Tokens of the whole address list
645
+ * @param from Index of the first token in front of the ':'
646
+ * @param to Index of the ':'
647
+ * @return Name of the group
648
+ */
649
+ function _groupName(tokens, from, to) {
650
+ // http://tools.ietf.org/html/rfc2822#appendix-A.1.3
651
+ return _collectAddressParts(tokens, from, to).data.text.join(' ');
652
+ }
653
+ /**
654
+ * Converts tokens for a single address into an address object
655
+ *
656
+ * @param tokens Tokens of the whole address list
657
+ * @param from Index of the first token of this address
658
+ * @param to Index just past the last token of this address
659
+ * @return Address object
660
+ */
661
+ function _handleAddress(tokens, from, to) {
662
+ const collected = _collectAddressParts(tokens, from, to);
663
+ const data = collected.data;
664
+ let textFromComment = collected.textFromComment;
665
+ // Comments come out of the angle-addr before anything asks whether one was found, so
666
+ // that brackets holding nothing but a comment read the same as empty ones
667
+ const addressComments = [];
668
+ const addressParts = [];
669
+ for (const part of data.address) {
670
+ const stripped = _stripAddressComments(part);
671
+ for (const comment of stripped.comments) {
672
+ addressComments.push(comment);
673
+ }
674
+ if (stripped.address) {
675
+ addressParts.push(stripped.address);
589
676
  }
590
- addresses.push({
591
- name: data.text || '',
592
- group: groupMembers
593
- });
594
677
  }
595
- else {
596
- // Comments come out of the angle-addr before anything asks whether one was found, so
597
- // that brackets holding nothing but a comment read the same as empty ones
598
- const addressComments = [];
599
- const addressParts = [];
600
- for (const part of data.address) {
601
- const stripped = _stripAddressComments(part);
602
- for (const comment of stripped.comments) {
603
- addressComments.push(comment);
604
- }
605
- if (stripped.address) {
606
- addressParts.push(stripped.address);
678
+ data.address = addressParts;
679
+ // If no address was found, try to detect one from regular text
680
+ if (!data.address.length && data.text.length && !textFromComment) {
681
+ // Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both
682
+ // tiers read the collapsed runs, and only a run an address comes out of is replaced
683
+ // by its collapsed form, the display text keeps its spacing
684
+ const candidates = data.text.map((part) => _collapseAtSpacing(part));
685
+ // The first addr-spec in the text is the mailbox, as it is for a conformant parser
686
+ // reading as far as it can. Taking the last one let a comment part two of them and
687
+ // pick the second, 'user@good.com (x) evil@evil.com' went to evil.com, while the
688
+ // same two without the comment, one run, went to good.com
689
+ for (let i = 0; i < data.text.length; i++) {
690
+ // Security: Do not extract email addresses from quoted strings.
691
+ // RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
692
+ // Extracting emails from quoted text leads to misrouting vulnerabilities.
693
+ // A run that is a quoted local part with its '@' and domain is an addr-spec of
694
+ // its own, with the quoting put back on, and takes its turn like any other
695
+ const local = data.quotedLocalAt[i];
696
+ const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local))
697
+ ? _quoteLocalPart(data.text[i])
698
+ : '';
699
+ if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) {
700
+ data.address = [quotedLocal || candidates[i]];
701
+ data.text.splice(i, 1);
702
+ data.textWasQuoted.splice(i, 1);
703
+ data.quotedLocalAt.splice(i, 1);
704
+ break;
607
705
  }
608
706
  }
609
- data.address = addressParts;
610
- // If no address was found, try to detect one from regular text
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
707
+ // Try a looser regex match if strict match found nothing
708
+ if (!data.address.length) {
709
+ let extracted = false;
620
710
  for (let i = 0; i < data.text.length; i++) {
621
- // Security: Do not extract email addresses from quoted strings.
622
- // RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
623
- // Extracting emails from quoted text leads to misrouting vulnerabilities.
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);
633
- data.textWasQuoted.splice(i, 1);
634
- data.quotedLocalAt.splice(i, 1);
635
- break;
636
- }
637
- }
638
- // Try a looser regex match if strict match found nothing
639
- if (!data.address.length) {
640
- let extracted = false;
641
- for (let i = 0; i < data.text.length; i++) {
642
- // Security: Do not extract email addresses from quoted strings
643
- if (!data.textWasQuoted[i]) {
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];
647
- const at = _looseAddressStart(part);
648
- if (at >= 0) {
649
- LOOSE_TEXT_ADDR.lastIndex = at;
650
- const match = LOOSE_TEXT_ADDR.exec(part);
651
- if (match) {
652
- data.address = [match[0].trim()];
653
- extracted = true;
654
- remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
655
- }
656
- }
657
- data.text[i] = remainder.trim();
658
- if (extracted) {
659
- break;
711
+ // Security: Do not extract email addresses from quoted strings
712
+ if (!data.textWasQuoted[i]) {
713
+ // the strict tier found nothing, so it spliced nothing and the indexes still line up
714
+ const part = candidates[i];
715
+ let remainder = data.text[i];
716
+ const at = _looseAddressStart(part);
717
+ if (at >= 0) {
718
+ LOOSE_TEXT_ADDR.lastIndex = at;
719
+ const match = LOOSE_TEXT_ADDR.exec(part);
720
+ if (match) {
721
+ data.address = [match[0].trim()];
722
+ extracted = true;
723
+ remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
660
724
  }
661
725
  }
726
+ data.text[i] = remainder.trim();
727
+ if (extracted) {
728
+ break;
729
+ }
662
730
  }
663
731
  }
664
732
  }
665
- // If there's still no text but a comment exists, replace the two
666
- if (!data.text.length && data.comment.length) {
667
- data.text = data.comment;
668
- data.comment = [];
669
- }
670
- // Keep only the first address occurrence, push others to regular text
671
- if (data.address.length > 1) {
672
- data.text = data.text.concat(data.address.splice(1));
673
- }
674
- // An address is only taken from unquoted text, so anything left in the text at this
675
- // point that still has to serve as the address carries its quoting in this flag
676
- const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
677
- // Join values with spaces
678
- data.text = data.text.join(' ');
679
- data.address = data.address.join(' ');
680
- if (addressFromQuotedText && data.text.indexOf('@') >= 0) {
681
- // A quoted run with no '@' in it is a display name and nothing else, so it stays
682
- // where it is. Moving it over anyway made a comment the mailbox was read from:
683
- // '"Display Name" <(a comment)>' handed on 'Display Name' as the address with the
684
- // comment as the name, and a quoted name holding a ',' or a ';' became an address
685
- // that reads as two recipients once a consumer writes it back into a header.
686
- //
687
- // The mailbox is still sitting in the text, so it moves over here and is quoted
688
- // before the recovery below rather than after it. Anything else the text holds
689
- // came along with it: a comment ends the domain but leaves the atoms behind it in
690
- // the same text, and '"user"@example.com(x)evil.com' was handed on as the address
691
- // 'user@example.com evil.com', a second domain riding into the envelope recipient
692
- // on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
693
- // back first is what lets the recovery tell the whitespace an addr-spec may carry
694
- // from the wreckage trailing one, as only a quoted local part may hold whitespace
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)));
698
- data.text = '';
699
- }
700
- _recoverAddrSpec(data);
701
- // a comment names the mailbox only when nothing else does, as one outside the brackets
702
- if (!data.text && addressComments.length) {
703
- data.text = addressComments.join(' ');
704
- textFromComment = true;
733
+ }
734
+ // If there's still no text but a comment exists, replace the two
735
+ if (!data.text.length && data.comment.length) {
736
+ data.text = data.comment;
737
+ data.comment = [];
738
+ }
739
+ // Keep only the first address occurrence, push others to regular text
740
+ if (data.address.length > 1) {
741
+ data.text = data.text.concat(data.address.splice(1));
742
+ }
743
+ // An address is only taken from unquoted text, so anything left in the text at this
744
+ // point that still has to serve as the address carries its quoting in this flag
745
+ const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
746
+ // Join values with spaces
747
+ data.text = data.text.join(' ');
748
+ data.address = data.address.join(' ');
749
+ if (addressFromQuotedText && data.text.indexOf('@') >= 0) {
750
+ // A quoted run with no '@' in it is a display name and nothing else, so it stays
751
+ // where it is. Moving it over anyway made a comment the mailbox was read from:
752
+ // '"Display Name" <(a comment)>' handed on 'Display Name' as the address with the
753
+ // comment as the name, and a quoted name holding a ',' or a ';' became an address
754
+ // that reads as two recipients once a consumer writes it back into a header.
755
+ //
756
+ // The mailbox is still sitting in the text, so it moves over here and is quoted
757
+ // before the recovery below rather than after it. Anything else the text holds
758
+ // came along with it: a comment ends the domain but leaves the atoms behind it in
759
+ // the same text, and '"user"@example.com(x)evil.com' was handed on as the address
760
+ // 'user@example.com evil.com', a second domain riding into the envelope recipient
761
+ // on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
762
+ // back first is what lets the recovery tell the whitespace an addr-spec may carry
763
+ // from the wreckage trailing one, as only a quoted local part may hold whitespace
764
+ // The quotes are gone at this point, so the '@' the domain splits off at is the
765
+ // last one, not the single one _collapseAtSpacing insists on
766
+ data.address = _quoteLocalPart(_joinAtSpacing(data.text, _domainAt(data.text)));
767
+ data.text = '';
768
+ }
769
+ _recoverAddrSpec(data);
770
+ // a comment names the mailbox only when nothing else does, as one outside the brackets
771
+ if (!data.text && addressComments.length) {
772
+ data.text = addressComments.join(' ');
773
+ textFromComment = true;
774
+ }
775
+ const address = {
776
+ address: data.address || (textFromComment ? '' : data.text) || '',
777
+ name: data.text || data.address || ''
778
+ };
779
+ if (address.address === address.name) {
780
+ if (/@/.test(address.address || '')) {
781
+ address.name = '';
705
782
  }
706
- const address = {
707
- address: data.address || (textFromComment ? '' : data.text) || '',
708
- name: data.text || data.address || ''
709
- };
710
- if (address.address === address.name) {
711
- if (/@/.test(address.address || '')) {
712
- address.name = '';
713
- }
714
- else {
715
- address.address = '';
716
- }
783
+ else {
784
+ address.address = '';
717
785
  }
718
- addresses.push(address);
719
786
  }
720
- return addresses;
787
+ return address;
721
788
  }
789
+ /* eslint-disable no-bitwise -- see TokenList */
722
790
  /**
723
791
  * Creates a Tokenizer object for tokenizing address field strings
724
792
  *
@@ -730,15 +798,16 @@ class Tokenizer {
730
798
  this.str = (str || '').toString();
731
799
  this.operatorCurrent = '';
732
800
  this.operatorExpecting = '';
733
- this.node = null;
801
+ this.node = -1;
734
802
  this.escaped = false;
735
803
  this.inDomainLiteral = false;
736
804
  this.afterAt = false;
805
+ this.inGroup = false;
737
806
  this.innerQuoted = false;
738
807
  this.innerComment = 0;
739
808
  this.innerLiteral = false;
740
809
  this.innerEscaped = false;
741
- this.list = [];
810
+ this.list = new TokenList(this.str);
742
811
  /**
743
812
  * Operator tokens and which tokens are expected to end the sequence
744
813
  */
@@ -747,7 +816,9 @@ class Tokenizer {
747
816
  '(': ')',
748
817
  '<': '>',
749
818
  ',': '',
750
- ':': ';',
819
+ // A group is read in the same pass as the rest of the list, so its ':' only marks
820
+ // where the name ends and its members are tokenized like any other address
821
+ ':': '',
751
822
  // Semicolons are not a legal delimiter per the RFC2822 grammar other
752
823
  // than for terminating a group, but they are also not valid for any
753
824
  // other use in this context. Given that some mail clients have
@@ -763,27 +834,96 @@ class Tokenizer {
763
834
  * @return An array of operator|text tokens
764
835
  */
765
836
  tokenize() {
766
- const list = [];
767
- for (let i = 0, len = this.str.length; i < len; i++) {
768
- const chr = this.str.charAt(i);
769
- const nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
770
- this.checkChar(chr, nextChr);
837
+ const str = this.str;
838
+ const len = str.length;
839
+ for (let i = 0; i < len; i++) {
840
+ this.checkChar(str.charAt(i), i);
771
841
  }
772
- this.list.forEach(node => {
773
- node.value = (node.value || '').toString().trim();
774
- if (node.value) {
775
- list.push(node);
842
+ this._closeText(len);
843
+ // Compacted in place: a text run is trimmed to its value and one left empty is
844
+ // dropped, and the value of a run that is no plain slice is rebuilt and kept aside
845
+ const list = this.list;
846
+ const slots = list.slots;
847
+ let kept = 0;
848
+ for (let i = 0, count = list.length; i < count; i++) {
849
+ const kind = slots[i * 3];
850
+ let start = slots[i * 3 + 1];
851
+ let end = slots[i * 3 + 2];
852
+ if (kind & TOKEN_DIRTY) {
853
+ const value = this._cleanValue(start, end, kind).trim();
854
+ if (!value) {
855
+ continue;
856
+ }
857
+ list.cleaned.set(kept, value);
858
+ }
859
+ else if (!(kind & TOKEN_OPERATOR)) {
860
+ while (start < end && _isSpaceCode(str.charCodeAt(start))) {
861
+ start++;
862
+ }
863
+ while (end > start && _isSpaceCode(str.charCodeAt(end - 1))) {
864
+ end--;
865
+ }
866
+ if (start === end) {
867
+ continue;
868
+ }
776
869
  }
777
- });
870
+ slots[kept * 3] = kind;
871
+ slots[kept * 3 + 1] = start;
872
+ slots[kept * 3 + 2] = end;
873
+ kept++;
874
+ }
875
+ list.length = kept;
778
876
  return list;
779
877
  }
878
+ /**
879
+ * Ends the text run being collected, if there is one
880
+ *
881
+ * @param end Offset just past its last character
882
+ */
883
+ _closeText(end) {
884
+ if (this.node >= 0) {
885
+ this.list.slots[this.node * 3 + 2] = end;
886
+ this.node = -1;
887
+ }
888
+ }
889
+ /**
890
+ * Rebuilds the value of a text run that is no plain slice of the field, applying the
891
+ * rules checkChar collects characters by: an escaping backslash is dropped, a line break
892
+ * reads as a space and any other control character is left out
893
+ *
894
+ * @param start Offset of the first character of the run
895
+ * @param end Offset just past its last character
896
+ * @param kind Kind and flags of the run
897
+ * @return Value of the run
898
+ */
899
+ _cleanValue(start, end, kind) {
900
+ const str = this.str;
901
+ const escapes = (kind & TOKEN_ESCAPES) !== 0;
902
+ let escaped = (kind & TOKEN_STARTS_ESCAPED) !== 0;
903
+ let value = '';
904
+ for (let i = start; i < end; i++) {
905
+ const chr = str.charAt(i);
906
+ if (escapes && !escaped && chr === '\\') {
907
+ escaped = true;
908
+ continue;
909
+ }
910
+ escaped = false;
911
+ if (chr === '\n') {
912
+ value += ' ';
913
+ }
914
+ else if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') {
915
+ value += chr;
916
+ }
917
+ }
918
+ return value;
919
+ }
780
920
  /**
781
921
  * Checks if a character is an operator or text and acts accordingly
782
922
  *
783
923
  * @param chr Character from the address field
784
- * @param nextChr Character following chr, null at the end of the field
924
+ * @param i Offset of the character in the field
785
925
  */
786
- checkChar(chr, nextChr) {
926
+ checkChar(chr, i) {
787
927
  // Track RFC 5322 domain-literals ("[" *dtext "]"). The ":" of an IPv6
788
928
  // address-literal (user@[IPv6:2001:db8::1]) is dtext and must not be treated as
789
929
  // the group delimiter while inside the brackets. That is the only operator the
@@ -804,15 +944,23 @@ class Tokenizer {
804
944
  this.inDomainLiteral = false;
805
945
  }
806
946
  }
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
947
+ // The closer of an angle-addr is only read outside the quoted strings, comments and
948
+ // domain literals it holds. Looking for nothing but the closer let a '>' inside a
949
+ // quoted local part end the angle-addr, '<"a@evil.com>"@good.com>' was read as an
950
+ // address at evil.com. The characters are still collected as they are, the angle-addr
951
+ // is read again later
813
952
  let nested = false;
814
- if (this.operatorExpecting === '>' || this.operatorExpecting === ';') {
953
+ if (this.operatorExpecting === '>') {
815
954
  nested = this._trackInner(chr);
955
+ if (!nested && chr === ';' && this.inGroup) {
956
+ // The ';' of a group ends an angle-addr left open inside it, as it always did
957
+ // when a group was cut out of the field at its ';' and parsed on its own. Read
958
+ // as text instead, an unclosed '<' swallowed the end of the group and every
959
+ // recipient after it
960
+ this._closeText(i);
961
+ this.operatorExpecting = '';
962
+ this._resetInner();
963
+ }
816
964
  }
817
965
  else if (this.operatorExpecting === ')' && !this.escaped) {
818
966
  // Comments nest (RFC 5322 3.2.2). Ending one at the first ')' let the ')' of a
@@ -830,15 +978,9 @@ class Tokenizer {
830
978
  // ignore next condition blocks
831
979
  }
832
980
  else if (chr === this.operatorExpecting && !nested) {
833
- this.node = {
834
- type: 'operator',
835
- value: chr
836
- };
837
- if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
838
- this.node.noBreak = true;
839
- }
840
- this.list.push(this.node);
841
- this.node = null;
981
+ const nextChr = this.str.charAt(i + 1);
982
+ this._closeText(i);
983
+ this.list.push(nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr) ? TOKEN_OPERATOR | TOKEN_NO_BREAK : TOKEN_OPERATOR, i, i + 1);
842
984
  if (chr !== ')') {
843
985
  // a comment is folding whitespace, it does not part the "@" from the "["
844
986
  this.afterAt = false;
@@ -849,12 +991,14 @@ class Tokenizer {
849
991
  return;
850
992
  }
851
993
  else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ':') && chr in this.operators) {
852
- this.node = {
853
- type: 'operator',
854
- value: chr
855
- };
856
- this.list.push(this.node);
857
- this.node = null;
994
+ this._closeText(i);
995
+ this.list.push(TOKEN_OPERATOR, i, i + 1);
996
+ if (chr === ':') {
997
+ this.inGroup = true;
998
+ }
999
+ else if (chr === ';') {
1000
+ this.inGroup = false;
1001
+ }
858
1002
  if (chr !== '(') {
859
1003
  this.afterAt = false;
860
1004
  }
@@ -869,39 +1013,42 @@ class Tokenizer {
869
1013
  // only in quoted strings let an escaped parenthesis close the comment, which
870
1014
  // released the rest of it, an address included, into the header
871
1015
  this.escaped = true;
1016
+ if (this.node >= 0) {
1017
+ // the backslash is left out of the value
1018
+ this.list.slots[this.node * 3] |= TOKEN_DIRTY;
1019
+ }
872
1020
  return;
873
1021
  }
874
- if (!this.node) {
875
- this.node = {
876
- type: 'text',
877
- value: ''
878
- };
879
- this.list.push(this.node);
1022
+ if (this.node < 0) {
1023
+ this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) |
1024
+ (this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i);
880
1025
  }
881
1026
  if (chr === '\n') {
882
1027
  // Convert newlines to spaces. Carriage return is ignored as \r and \n usually
883
1028
  // go together anyway and there already is a WS for \n. Lone \r means something is fishy.
884
1029
  chr = ' ';
1030
+ this.list.slots[this.node * 3] |= TOKEN_DIRTY;
885
1031
  }
886
- if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) {
887
- // skip command bytes
888
- this.node.value += chr;
1032
+ if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') {
1033
+ // the character is part of the value, which is read off the field later
889
1034
  // text inside a quoted string or a comment is not part of an addr-spec. Tracked
890
1035
  // as it goes rather than read back off the value (see lastChars in _handleAddress)
891
1036
  if (!this.operatorExpecting && chr !== ' ' && chr !== '\t') {
892
1037
  this.afterAt = chr === '@';
893
1038
  }
894
1039
  }
1040
+ else {
1041
+ // skip command bytes
1042
+ this.list.slots[this.node * 3] |= TOKEN_DIRTY;
1043
+ }
895
1044
  this.escaped = false;
896
1045
  }
897
1046
  /**
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.
1047
+ * Tracks the quoted strings, comments and domain literals inside an angle-addr, which its
1048
+ * closer must not be read from. A backslash escapes the next character inside a quoted
1049
+ * string and a comment. A domain literal opens right after an "@" and ends at the "]".
1050
+ * dtext holds the list separators as well, and inside the brackets they never split the
1051
+ * list anyway.
905
1052
  *
906
1053
  * @param chr Character from the address field
907
1054
  * @return true while the character sits inside one of them, the one that closes it included
@@ -946,7 +1093,7 @@ class Tokenizer {
946
1093
  this.innerComment = 1;
947
1094
  return true;
948
1095
  }
949
- if (chr === '[' && this.afterAt && this.operatorExpecting === '>') {
1096
+ if (chr === '[' && this.afterAt) {
950
1097
  this.innerLiteral = true;
951
1098
  return true;
952
1099
  }
@@ -967,79 +1114,94 @@ class Tokenizer {
967
1114
  this.innerEscaped = false;
968
1115
  }
969
1116
  }
1117
+ /* eslint-enable no-bitwise */
970
1118
  /**
971
- * Maximum recursion depth for parsing nested groups.
972
- * RFC 5322 doesn't allow nested groups, so this is a safeguard against
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.
1119
+ * Folds the fragments an unquoted display name holding a comma is split into back together.
1120
+ *
1121
+ * "Joe Foo, PhD <joe@example.com>" is split on the comma into
1122
+ * [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}]. A name-only entry
1123
+ * followed by an entry with both name and address is recombined. Walked back to front so
1124
+ * that a run of fragments folds into one entry in a single pass. Splicing each fragment out
1125
+ * of the list instead would cost O(n^2).
1126
+ *
1127
+ * @param list Parsed entries, in order
1128
+ * @return The entries with the fragments folded in
975
1129
  */
976
- const MAX_NESTED_GROUP_DEPTH = 10;
1130
+ function _mergeNameFragments(list) {
1131
+ const merged = [];
1132
+ for (let i = list.length - 1; i >= 0; i--) {
1133
+ const current = list[i];
1134
+ const next = merged.length ? merged[merged.length - 1] : null;
1135
+ if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
1136
+ next.name = current.name + ', ' + next.name;
1137
+ }
1138
+ else {
1139
+ merged.push(current);
1140
+ }
1141
+ }
1142
+ return merged.reverse();
1143
+ }
977
1144
  /**
978
- * Parses an address list, recursing into the groups it holds.
1145
+ * Parses an address list, groups included, in a single pass over its tokens.
979
1146
  *
980
- * The depth is threaded through the calls rather than carried in an options object, so a
981
- * caller supplied one can not seed it and lift the recursion limit: `{ _depth: -1e9 }`
982
- * bought a billion levels of nesting and turned the guard below into the stack overflow it
983
- * is there to prevent.
1147
+ * A group used to be cut out of the field at the ';' that ends it and parsed again as an
1148
+ * address list of its own, recursively, so every level of nesting parsed what was left of
1149
+ * the field once more and the depth had to be capped. RFC 5322 does not nest groups at all,
1150
+ * and the nested ones were flattened anyway. The tokens are now walked once: a ':' opens a
1151
+ * group, its members are read as they come, a ';' closes it. A ':' inside an open group
1152
+ * stands for a group nested in it, whose name is dropped and whose members belong to the
1153
+ * outer one, which is how they were flattened before. The first ';' ends the group, nested
1154
+ * or not, as it did.
984
1155
  *
985
1156
  * @param str Address field
986
- * @param depth Current recursion depth for nested group protection
987
1157
  * @return An array of address objects
988
1158
  */
989
- function _parseAddressList(str, depth) {
990
- // Prevent stack overflow from deeply nested groups (DoS protection)
991
- if (depth > MAX_NESTED_GROUP_DEPTH) {
992
- return [];
993
- }
994
- const tokenizer = new Tokenizer(str);
995
- const tokens = tokenizer.tokenize();
996
- const addresses = [];
997
- let address = [];
1159
+ function _parseAddressList(str) {
1160
+ const tokens = new Tokenizer(str).tokenize();
998
1161
  const parsedAddresses = [];
999
- tokens.forEach(token => {
1000
- if (token.type === 'operator' && (token.value === ',' || token.value === ';')) {
1001
- if (address.length) {
1002
- addresses.push(address);
1003
- }
1004
- address = [];
1005
- }
1006
- else {
1007
- address.push(token);
1162
+ let group = null;
1163
+ let members = [];
1164
+ // Every address is handed over as the range of the token list it covers, as soon as the
1165
+ // separator after it is seen. Copying the tokens of each address into an array of its own,
1166
+ // and collecting those before handling any, held a second structure as large as the token
1167
+ // list for the whole parse, and an array that receives a single push is allocated room for
1168
+ // seventeen: a list of one letter names took some 80 times its size in that alone
1169
+ let from = 0;
1170
+ for (let i = 0, len = tokens.length; i <= len; i++) {
1171
+ const operator = i < len ? tokens.operatorAt(i) : '';
1172
+ if (i < len && operator !== ',' && operator !== ';' && operator !== ':') {
1173
+ continue;
1008
1174
  }
1009
- });
1010
- if (address.length) {
1011
- addresses.push(address);
1012
- }
1013
- addresses.forEach(addr => {
1014
- const handled = _handleAddress(addr, depth);
1015
- // Appended in place. Rebuilding the accumulator with concat() would copy every
1016
- // entry collected so far on each address, making a flat list cost O(n^2).
1017
- for (let i = 0; i < handled.length; i++) {
1018
- parsedAddresses.push(handled[i]);
1175
+ if (operator === ':') {
1176
+ if (!group) {
1177
+ group = { name: _groupName(tokens, from, i), group: [] };
1178
+ members = [];
1179
+ }
1180
+ from = i + 1;
1181
+ continue;
1019
1182
  }
1020
- });
1021
- // Merge fragments produced when unquoted display names contain commas.
1022
- // "Joe Foo, PhD <joe@example.com>" is split on the comma into
1023
- // [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}].
1024
- // Recombine: a name-only entry followed by an entry with both name and address.
1025
- // Walked back to front so that a run of fragments folds into one entry in a single
1026
- // pass. Splicing each fragment out of the list instead would cost O(n^2).
1027
- const mergedAddresses = [];
1028
- for (let i = parsedAddresses.length - 1; i >= 0; i--) {
1029
- const current = parsedAddresses[i];
1030
- const next = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null;
1031
- if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
1032
- next.name = current.name + ', ' + next.name;
1183
+ if (i > from) {
1184
+ // Appended in place. Rebuilding the accumulator with concat() would copy every
1185
+ // entry collected so far on each address, making a flat list cost O(n^2).
1186
+ const address = _handleAddress(tokens, from, i);
1187
+ if (group) {
1188
+ members.push(address);
1189
+ }
1190
+ else {
1191
+ parsedAddresses.push(address);
1192
+ }
1033
1193
  }
1034
- else {
1035
- mergedAddresses.push(current);
1194
+ from = i + 1;
1195
+ if (group && (operator === ';' || i === len)) {
1196
+ group.group = _mergeNameFragments(members);
1197
+ parsedAddresses.push(group);
1198
+ group = null;
1036
1199
  }
1037
1200
  }
1038
- mergedAddresses.reverse();
1039
- return mergedAddresses;
1201
+ return _mergeNameFragments(parsedAddresses);
1040
1202
  }
1041
1203
  function addressparser(str, options) {
1042
- const parsedAddresses = _parseAddressList(str, 0);
1204
+ const parsedAddresses = _parseAddressList(str);
1043
1205
  if (options?.flatten) {
1044
1206
  const flatAddresses = [];
1045
1207
  const walkAddressList = (list) => {