Root cause
Resource abuse & availability introduced
The join check reads the last character of the accumulated address before every append.
How AI contributed: Direct introductionPublished Sep 11, 2026 in
nodemailer/nodemailerAll 2 findings in this repository
Provisional finding. Full causal and release verification remains incomplete.
An AI-assisted address-parser change introduced repeated reads of a growing comment-joined string, causing excessive copying on an applicable JavaScript engine path.
Nodemailer's address parser appends each address part to one growing string. A Claude-co-authored change in 9.1.0 reads that string's last character before every append, which V8 can turn into a copy of the whole string, so comment-separated addresses parse in quadratic time. Mailparser feeds inbound headers here. Release 10.0.5 caches the last character.
Root cause
The join check reads the last character of the accumulated address before every append.
Fix
The parser caches the last character for each accumulation state.
Fix authorship not yet analyzed
Case facts
@@ -184,7 +184,14 @@ function _handleAddress(tokens, depth) { token.value = token.value.replace(/^[^<]*<\s*/, ''); } - if (prevToken && prevToken.noBreak && data[state].length) {+ // A comment is folding whitespace. It may sit inside an addr-spec, on either+ // side of the '@', but it cannot join two atoms into one: gluing across it+ // would read '[email protected](x)evil.com' as the single domain+ // 'example.comevil.com' and deliver to a domain the sender never named.+ const tail = data[state].length ? data[state][data[state].length - 1] : '';+ const joinsAcrossComment = tail.slice(-1) === '@' || token.value.charAt(0) === '@';++ if (prevToken && prevToken.noBreak && data[state].length && (!prevToken.cfws || joinsAcrossComment)) { data[state][data[state].length - 1] += token.value; if (state === 'text' && insideQuotes) { data.textWasQuoted[data.textWasQuoted.length - 1] = true;@@ -399,6 +406,11 @@ class Tokenizer { this.node.noBreak = true; } + if (chr === ')') {+ // Closing a comment, which is folding whitespace rather than content+ this.node.cfws = true;+ }+ this.list.push(this.node); this.node = null; this.operatorExpecting = '';@@ -1206,4 +1206,49 @@ describe('#addressparser', () => { ]); }); });+ describe('RFC 5322 comments', () => {+ // A comment is folding whitespace, so it terminates the domain instead of joining+ // the text around it. Gluing read '[email protected](x)evil.com' as the domain+ // 'good-corp.comevil.com', which an attacker can register, so an application that+ // validated 'good-corp.com' mailed a domain it never approved.+ it('should not glue a domain to the text after a comment', () => {+ assert.deepStrictEqual(addressparser('[email protected](x)evil.com'), [{ address: '[email protected]', name: 'evil.com' }]);+ });++ it('should not glue across empty, repeated or nested comments', () => {+ for (const input of [+ '[email protected]()evil.com',+ '[email protected](a)(b)evil.com',+ '[email protected](a)(b)(c)evil.com',+ '[email protected](a(b)evil.com'+ ]) {+ assert.strictEqual(addressparser(input)[0].address, '[email protected]', input);+ }+ });++ it('should keep both recipients when a comment splits an address list', () => {+ const parsed = addressparser('[email protected](c)d.com, [email protected]');+ assert.strictEqual(parsed.length, 2);+ assert.strictEqual(parsed[0].address, '[email protected]');+ assert.strictEqual(parsed[1].address, '[email protected]');+ });++ // CFWS is allowed on either side of the '@', so a comment there still belongs to+ // the same addr-spec and the address must survive intact.+ it('should still resolve an address with a comment beside the @', () => {+ assert.strictEqual(addressparser('user@(x)good-corp.com')[0].address, '[email protected]');+ assert.strictEqual(addressparser('user(x)@good-corp.com')[0].address, '[email protected]');+ assert.strictEqual(addressparser('(lead)[email protected]')[0].address, '[email protected]');+ });++ it('should keep quoted string and angle address joining intact', () => {+ assert.strictEqual(addressparser('"foo"[email protected]')[0].address, '[email protected]');+ assert.strictEqual(addressparser('<[email protected]>x')[0].address, '[email protected]');+ });++ it('should read a comment as the display name when there is none', () => {+ assert.deepStrictEqual(addressparser('Foo (Bar) <[email protected]>'), [{ address: '[email protected]', name: 'Foo' }]);+ assert.deepStrictEqual(addressparser('(Bar) <[email protected]>'), [{ address: '[email protected]', name: 'Bar' }]);+ });+ }); });@@ -2342,4 +2342,20 @@ describe('MimeNode Tests', { timeout: 50 * 1000 }, () => { assert.deepStrictEqual(mb.getEnvelope().to, ['[email protected]', '[email protected]']); }); });+ describe('comment handling in recipients', () => {+ it('should deliver to the domain before a comment, not the text after it', () => {+ const mb = new MimeNode('text/plain');+ mb.setHeader('From', '[email protected]');+ mb.setHeader('To', '[email protected](x)evil.com');++ assert.deepStrictEqual(mb.getEnvelope().to, ['[email protected]']);+ });++ it('should keep a comment beside the @ inside the same address', () => {+ const mb = new MimeNode('text/plain');+ mb.setHeader('To', 'user@(x)good-corp.com');++ assert.deepStrictEqual(mb.getEnvelope().to, ['[email protected]']);+ });+ }); });@@ -55,6 +55,11 @@ interface AddressParts { textWasQuoted: boolean[]; } +/**+ * The run of an address the token walk is collecting into at a given point+ */+type AddressPartsState = 'text' | 'address' | 'comment' | 'group';+ /** * Restores the quoting of a local part that was read out of a quoted string. *@@ -193,7 +198,7 @@ function _recoverAddrSpec(data: { address: string; text: string }): void { */ function _handleAddress(tokens: Token[], depth: number): Address[] { let isGroup = false;- let state: 'text' | 'address' | 'comment' | 'group' = 'text';+ let state: AddressPartsState = 'text'; const addresses: Address[] = []; const data: AddressParts = { address: [],@@ -203,6 +208,12 @@ function _handleAddress(tokens: Token[], depth: number): Address[] { textWasQuoted: [] }; let insideQuotes = false;+ // Last character of the run each state is currently accumulating. Reading it back off+ // the accumulator with slice(-1) makes the engine flatten the whole growing string on+ // every token, which is quadratic over an address built from many comment-joined atoms+ // (GHSA-prgh-xp8r-p3m5). A run only ever grows by the token appended below, so the+ // character is carried along instead of re-read.+ const lastChars: Record<AddressPartsState, string> = { address: '', comment: '', group: '', text: '' }; // Filter out <addresses>, (comments) and regular text for (let i = 0, len = tokens.length; i < len; i++) {@@ -248,15 +259,19 @@ function _handleAddress(tokens: Token[], depth: number): Address[] { prevToken && prevToken.noBreak && parts.length &&- (prevToken.value !== ')' || parts[parts.length - 1].slice(-1) === '@' || token.value.charAt(0) === '@');+ (prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@'); if (joins) { data[state][data[state].length - 1] += token.value;+ if (token.value) {+ lastChars[state] = token.value.charAt(token.value.length - 1);+ } if (state === 'text' && insideQuotes) { data.textWasQuoted[data.textWasQuoted.length - 1] = true; } } else { data[state].push(token.value);+ lastChars[state] = token.value.charAt(token.value.length - 1); if (state === 'text') { data.textWasQuoted.push(insideQuotes); }@@ -1179,6 +1179,30 @@ describe('#addressparser', () => { assert.ok(elapsed < 5000, `merging ${count} fragments took ${elapsed}ms`); }); + // The join check read the accumulator's last character back with slice(-1), which+ // flattens the growing run on every token. An address made of many comment-joined+ // atoms is one run, so the parse went quadratic: 1.5MB took ~10s of blocked event+ // loop, reachable unauthenticated through anything that parses inbound headers+ // (GHSA-prgh-xp8r-p3m5). Both shapes are covered because the atom after the comment+ // opening with '@' and the run ending with '@' are separate arms of that check.+ for (const [label, build] of [+ ['comment-joined atoms', (count: number) => 'a' + '@b(c)'.repeat(count)],+ ['comment-separated atoms', (count: number) => 'a@' + '(c)b@'.repeat(count)]+ ] as [string, (count: number) => string][]) {+ it(`should parse an address built from ${label} in linear time`, () => {+ // ~1.5MB, where the quadratic parse took ~10s and the linear one takes ~80ms+ const count = 320000;+ const input = build(count);++ const started = Date.now();+ const result = addressparser(input);+ const elapsed = Date.now() - started;++ assert.strictEqual(result.length, 1);+ assert.ok(elapsed < 5000, `parsing a ${input.length} byte address took ${elapsed}ms`);+ });+ }+ it('should keep fragment merging identical to the previous implementation', () => { assert.deepStrictEqual(addressparser('Joe Foo, PhD <[email protected]>'), [{ address: '[email protected]', name: 'Joe Foo, PhD' }]); assert.deepStrictEqual(addressparser('a, b, c <[email protected]>'), [{ address: '[email protected]', name: 'a, b, c' }]);AI-assisted change c4b9631e6fd55ad2fbe1ea940192ada2286ea32cfa1c8126e30227431ca03eab · Fix c138abba526cfe2896cdae65d343beda06f3f5c2565580149c92edff35813bc0