{"id":"GHSA-g73g-hqqh-jr95","kind":"vulnerability","aliases":["GHSA-g73g-hqqh-jr95"],"related":[],"title":"Nodemailer: RFC 5322 comment inside an angle-bracket address is never stripped","description":"## Summary\n\nNodemailer'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.\n\nThat 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.\n\n```\nLegit User <user@good-corp.com(x)evil.com>\n```\n\nthe 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:\n\n* against nodemailer's own `jsonTransport`/`streamTransport` (`envelope.to` and the emitted `To:` header both carry the malformed value), and\n* 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).\n\nThis 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.\n\n## Root cause\n\n### 1. The tokenizer has a single-slot operator stack\n\n`src/addressparser/index.ts`, `Tokenizer.checkChar()`:\n\n```ts\n} else if (!this.operatorExpecting && !this.inDomainLiteral && chr in this.operators) {\n    this.node = { type: 'operator', value: chr };\n    this.list.push(this.node);\n    this.node = null;\n    this.operatorExpecting = this.operators[chr];\n    this.escaped = false;\n    return;\n}\n```\n(currently around line 670)\n\n`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.\n\n### 2. The `_handleAddress` state machine loses the `address` state on any operator boundary\n\n`src/addressparser/index.ts`, inside `_handleAddress()` (currently around line 365-388):\n\n```ts\nswitch (token.value) {\n    case '<':\n        state = 'address';\n        insideQuotes = false;\n        break;\n    case '(':\n        state = 'comment';\n        insideQuotes = false;\n        break;\n    ...\n    default:\n        state = 'text';\n        insideQuotes = false;\n        break;\n}\n```\n\nEven 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.\n\n### 3. The one existing safety net requires whitespace, and this value has none\n\n`_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:\n\n```ts\nfunction _recoverAddrSpec(data: { address: string; text: string }): void {\n    if (!HAS_WHITESPACE.test(data.address)) {\n        return;\n    }\n    ...\n```\n\n`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.\n\n## Proof of Concept\n\nEnvironment: Node.js, current `master` (commit `30e4cfd5902fd5da4d1bc76dc8e4bc92d288adef`, package.json version `10.0.12`), no special transport configuration required.\n\n### Parser-level confirmation\n\n```js\nconst addressparser = require('nodemailer/lib/addressparser');\nconsole.log(addressparser('Legit User <user@good-corp.com(x)evil.com>'));\n// [ { address: 'user@good-corp.com(x)evil.com', name: 'Legit User' } ]\n```\n\nCompare with the already-fixed, unbracketed form of the exact same bytes:\n\n```js\nconsole.log(addressparser('user@good-corp.com(x)evil.com'));\n// [ { address: 'user@good-corp.com', name: 'evil.com' } ]   <- correctly stripped\n```\n\n### `sendMail` / envelope confirmation (jsonTransport, no network)\n\n```js\nconst nodemailer = require('nodemailer');\n\n(async () => {\n    const t = nodemailer.createTransport({ jsonTransport: true });\n    const info = await t.sendMail({\n        from: 'app@good-corp.com',\n        to: 'Legit User <user@good-corp.com(x)evil.com>',\n        subject: 'hi',\n        text: 'x'\n    });\n    console.log('envelope.to:', info.envelope.to);\n})();\n```\n\nObserved output:\n\n```\nenvelope.to: [ 'user@good-corp.com(x)evil.com' ]\n```\n\nThe raw `streamTransport` message shows the same value in the emitted header:\n\n```\nFrom: app@good-corp.com\nTo: Legit User <user@good-corp.com(x)evil.com>\nSubject: hi\n```\n\n### Wire-level confirmation against a real SMTP server\n\n```js\nconst net = require('net');\nconst nodemailer = require('nodemailer');\n\nconst server = net.createServer(sock => {\n    let buf = '';\n    sock.write('220 sink ESMTP\\r\\n');\n    sock.on('data', d => {\n        buf += d;\n        let i;\n        while ((i = buf.indexOf('\\r\\n')) >= 0) {\n            const line = buf.slice(0, i);\n            buf = buf.slice(i + 2);\n            const u = line.toUpperCase();\n            if (u.startsWith('EHLO')) sock.write('250-sink\\r\\n250 8BITMIME\\r\\n');\n            else if (u.startsWith('MAIL')) sock.write('250 ok\\r\\n');\n            else if (u.startsWith('RCPT')) { console.log('wire RCPT TO :', line); sock.write('250 ok\\r\\n'); }\n            else if (u.startsWith('DATA')) sock.write('354 go\\r\\n');\n            else if (line === '.') sock.write('250 ok\\r\\n');\n            else if (u.startsWith('QUIT')) { sock.write('221 bye\\r\\n'); sock.end(); }\n            else sock.write('250 ok\\r\\n');\n        }\n    });\n});\n\nserver.listen(0, '127.0.0.1', async () => {\n    const t = nodemailer.createTransport({ host: '127.0.0.1', port: server.address().port, secure: false });\n    const info = await t.sendMail({ from: 'app@good-corp.com', to: 'Legit User <user@good-corp.com(x)evil.com>', subject: 'hi', text: 'x' });\n    console.log('envelope.to:', info.envelope.to);\n    t.close(); server.close();\n});\n```\n\nObserved:\n\n```\nwire RCPT TO : RCPT TO:<user@good-corp.com(x)evil.com>\nenvelope.to: [ 'user@good-corp.com(x)evil.com' ]\n```\n\nThe same recipient string sent at a real, conformant SMTP server (`aiosmtpd`, a widely used reference-grade Python SMTP implementation) instead of the sink above:\n\n```\nError: Can't send mail - all recipients were rejected: 501 Syntax: RCPT TO: <address> [SP <mail-parameters>]\ncode: 'EENVELOPE', responseCode: 501, command: 'RCPT TO',\nrejected: [ 'user@good-corp.com(x)evil.com' ]\n```\n\nSo against a strict receiver the immediate, concretely demonstrated outcome is that the message is bounced: an ordinary-looking recipient address that a sender/application believes is valid (and, see below, that some app-side validators *do* read as valid, at `good-corp.com`) silently fails to send.\n\n### Interpretation-conflict confirmation (same class as GHSA-cc9r-2j5m-2m83)\n\nThe same string, read by two other parsers an application might use to gate on the recipient's domain, produces two more, mutually different readings:\n\n```python\n>>> import email.utils\n>>> email.utils.parseaddr('Legit User <user@good-corp.com(x)evil.com>')\n('Legit User (x)', 'user@good-corp.comevil.com')\n```\n\n```python\n>>> import email.policy, email.parser\n>>> msg = email.parser.Parser(policy=email.policy.default).parsestr(\n...     'To: Legit User <user@good-corp.com(x)evil.com>\\n\\n')\n>>> addr = msg['To'].addresses[0]\n>>> addr.username, addr.domain\n('user', 'good-corp.com')\n```\n\nSo for the exact same bytes:\n\n| Parser | Domain read | \n|---|---|\n| Python `email.utils.parseaddr` | `good-corp.comevil.com` (attacker-registrable) |\n| Python `email.policy.default` (defects not inspected) | `good-corp.com` (clean, would pass an allow-list check) |\n| Nodemailer `addressparser` (this report) | `good-corp.com(x)evil.com` (illegal characters, rejected by a strict MTA) |\n\nThis is the exact \"which parser sees what\" comparison table from GHSA-cc9r-2j5m-2m83, reproduced through a different, unpatched code path: an application validating with `email.policy.default` without checking `.defects` (a documented pattern in that advisory) approves `good-corp.com` and hands the string to Nodemailer, which then emits a different, malformed value on the wire.\n\n### Version range\n\nThe built `addressparser`/`sendMail` output was tested for this exact input across published releases:\n\n| Version | `envelope.to` |\n|---|---|\n| `6.9.16` | `user@good-corp.com(x)evil.com` |\n| `9.0.6` | `user@good-corp.com(x)evil.com` |\n| `9.1.0` (fixed GHSA-cc9r-2j5m-2m83) | `user@good-corp.com(x)evil.com` |\n| `10.0.1` | `user@good-corp.com(x)evil.com` |\n| `10.0.12` / current HEAD (fixed GHSA-g57g-f23g-4646) | `user@good-corp.com(x)evil.com` |\n\nUnchanged across every one of the three address-parser advisory fixes, because none of them touch the tokenizer's single-slot `operatorExpecting` state or the `_handleAddress` state-machine reset on `)`.\n\n## Impact\n\n* **Confirmed, demonstrated impact:** a recipient address that contains a perfectly legal RFC 5322 comment inside angle brackets — the single most common way a `To`/`Cc`/`Bcc`/`From`/`Reply-To` value is written (`Display Name <user@domain>`) — is parsed into a value containing SMTP-illegal characters (`(`, `)`) that a conformant receiving SMTP server rejects outright, silently failing message delivery to what the sender/application believed was a valid, already-validated recipient (CWE-20, availability/reliability impact on outbound mail).\n* **Demonstrated interpretation conflict (CWE-436):** the malformed value that reaches `envelope.to` and the wire `RCPT TO` command disagrees with how other parsers, including ones explicitly called out as an at-risk validation pattern in GHSA-cc9r-2j5m-2m83 (Python's `email.policy.default` without defect inspection), read the exact same bytes. An application gating outbound mail on \"is the recipient's domain in my allow-list\" using such a validator can approve a value that Nodemailer then sends corrupted, and depending on the receiving MTA's own comment handling, a different domain than either the sender or the validator believed was being used.\n* **Reachability:** every code path that ends up calling `MimeNode._parseAddresses()` — `to`, `cc`, `bcc`, `from`, `replyTo`, and the explicit `envelope` option — is affected identically, since they all funnel through the same `addressparser()`/`_handleAddress()` logic.\n\n## Suggested fix\n\nThe underlying issue is architectural: `Tokenizer.operatorExpecting` is a single value, so it cannot represent \"currently inside an angle-address, and also currently inside a comment nested within it.\" A minimal fix is to make the tokenizer maintain a stack of expected closing characters (pushing `')'` when entering a comment while another operator scope, e.g. `'>'`, is already open, and popping back to the outer expectation on `)`), so a comment can be recognized and stripped no matter what scope it is nested inside. `_handleAddress`'s state machine would then need to restore the previous `state` on a comment close instead of unconditionally resetting to `'text'`, so recognized-but-stripped comment content inside `<...>` continues to accumulate as `address` rather than leaking into `text`.\n\nAs defense in depth, `_recoverAddrSpec()` (or an equivalent check run specifically on `data.address` for the angle-bracket case) should also reject/clean any parsed address value that still contains RFC 5322 special characters such as `(`/`)` regardless of whether whitespace is present, since `envelope.to` is used directly to build an SMTP command and any character outside `Mailbox` (RFC 5321 §4.1.2) reaching that string is already the wrong outcome, independent of how it got there.\n\n## Regression test suggestion\n\n`Name <user@example.com(x)evil.com>` and the bcc/cc/from/replyTo/envelope equivalents should all parse to `{ address: 'user@example.com', name: 'Name evil.com' }` or be rejected outright by `sendMail`, never silently carry `(`/`)` into `envelope.to`.","summary":"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.","severity":"medium","cvss":5.3,"epss":null,"exploited":false,"exploited_at":null,"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"],"how_to_check":"- Run `npm ls nodemailer` in the project to find the installed version.\n- 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.\n- Until upgraded, validate recipient addresses strictly before passing them to nodemailer and reject any containing `(` or `)`.","status":"open","published_at":"2026-10-27T15:04:36+00:00","first_seen_at":"2026-10-27T15:08:21+00:00","triaged_at":"2026-10-27T15:08:28+00:00","updated_at":"2026-10-27T15:08:28+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-27T15:08:28Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade `nodemailer` to `10.0.13` or later, the fixed version named in the advisory.\n- Until upgraded, validate recipient addresses strictly before passing them to nodemailer and reject any containing `(` or `)`.","reach":"unknown","title":"Nodemailer: comment inside angle-bracket address corrupts recipient on the wire","impact":"low","summary":"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.","category":"package","nicknames":[],"who_cares":["nodemailer","node.js","email"],"confidence":0.8,"needs_auth":"unknown","worst_case":"Mail to the recipient fails, and a differing domain may be read by other validators.","analyst_note":"","how_to_check":"- Run `npm ls nodemailer` in the project to find the installed version.\n- Check whether any code passes user-supplied recipient strings to `to`, `cc`, `bcc`, `from`, `replyTo` or `envelope` in nodemailer.","needs_analyst":false,"not_affected_if":["fixed-recipients","version-10.0.13-or-later"],"who_is_affected":"Applications that send mail with nodemailer versions from 3.0.0 through 10.0.12 and build recipient strings that may contain user-controlled or externally sourced display-name and address text. The bug is in the parser, so any code path that reaches it is exposed, including the envelope option. Teams that only send to fixed, hard-coded recipients with no parsed input can skip it.","user_interaction":"no","needs_special_setup":"no","severity_if_unknown":"medium"},"cost_usd":0.001111,"input_tokens":7989,"output_tokens":625,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-20':26B '-388':502C '-436':28B '/the':583C '1':357C,603C '1/point':620C '10.0.12':323C '2':482C,621C '2j5m':106C '2m83':107C '3':641C '365':501C '4646':119C '501':302C '5322':3A,42B,95C,212C,465C '6.9.16':319C '670':395C '9fqp':141C 'accumul':165C 'activ':551C 'addr':101C,584C 'addr-spec':100C 'address':10A,35B,50B,86C,197C,240C,335C,407C,489C,508C,563C 'address-pars':334C 'advisori':337C 'aiosmtpd':292C 'angl':8A,48B,195C,206C,531C,628C 'angle-bracket':7A,47B,194C,627C 'append':450C 'appli':597C 'around':393C,499C 'as-i':245C 'atom':112C 'back':546C,615C 'bare':562C 'boundari':494C 'bracket':9A,49B,196C,207C,532C,629C 'branch':414C 'break':511C,517C,523C 'bucket':580C,610C 'carri':282C 'case':506C,512C,561C,630C 'categor':631C 'caus':346C,356C 'cc9r':105C 'charact':234C,421C,449C 'chr':374C,381C,388C 'close':533C 'code':349C 'command':62B,262C 'comment':4A,43B,96C,108C,213C,221C,466C,514C,527C,554C,573C 'common':182C 'confirm':264C 'contain':209C 'current':322C,392C,498C 'cwe':25B,27B 'default':518C,539C 'differ':72B,77B,344C,632C 'display':189C 'domain':78B,111C,200C 'e.g':214C 'els':370C 'email':29B 'emit':278C 'end':266C,268C 'enter':471C 'envelope.to':56B,249C,275C 'even':524C 'everi':314C 'everyth':458C 'evil.com':219C 'exercis':159C 'exist':639C,644C 'extract':586C 'f23g':118C 'fall':536C,614C 'fals':390C,431C,510C,516C,522C 'fatal':298C 'find':589C 'first':600C 'fix':92C,132C,145C,352C,560C,640C 'follow':191C 'free':578C 'g57g':117C 'g73g':16A,21A 'gate':425C 'ghsa':15A,20A,104C,116C,134C,139C 'ghsa-cc9r-2j5m-2m83':103C 'ghsa-g57g-f23g':115C 'ghsa-g73g-hqqh-jr95':14A,19A 'ghsa-prgh-xp8r-p3m5':133C 'ghsa-v53p-9fqp-m79j':138C 'glu':109C 'glue':122C 'handl':97C 'handleaddress':148C,484C,497C 'head':324C 'header':280C 'hqqh':17A,22A 'i.e':325C 'input':203C 'insid':5A,45B,98C,204C,237C,434C,496C,593C 'insidequot':509C,515C,521C 'interact':622C 'join':150C 'jr95':18A,23A 'jsontransport':273C 'land':575C 'leak':572C 'legit':215C 'line':394C,500C 'liter':480C 'live':146C 'local':126C,162C,199C,565C 'logic':152C,157C,172C,587C 'lose':487C 'm79j':142C 'machin':486C 'make':625C 'malform':284C,295C 'name':190C,198C 'net':612C,646C 'never':12A,174C,353C,437C 'new':419C 'next':462C 'node.js':30B 'nodemail':1A,31B,33B,84C,270C 'nodemailer':24A 'none':594C,653C 'null':385C 'one':114C,643C 'onto':254C 'open':405C,555C 'oper':227C,365C,379C,420C,441C,493C 'operatorexpect':396C,409C 'ordinari':448C 'outsid':530C 'p3m5':137C 'packag':32B 'parenthes':53B 'parenthesi':233C 'pars':239C 'parser':36B,73B,87C,336C 'part':127C,566C 'patch':91C 'path':166C,350C 'place':44B,601C 'point':602C,619C 'predat':327C 'prgh':135C 'publish':90C 'quot':125C,564C 'raw':253C 'rcpt':60B,259C,304C 'reach':175C 'read':75B 'real':289C 'receiv':65B 'recipi':69B,185C,308C 'recogn':224C,417C,469C,529C 'recoverad':654C 'recoveraddrspec':151C,582C 'redo':131C 'reject':67B,309C 'relat':130C 'reproduc':311C 'requir':647C 'reset':541C 'return':391C 'rfc':2A,41B,94C,211C,464C 'ride':476C 'root':345C,355C 'run':170C,453C 'safeti':611C,645C 'scope':408C 'server':66B,291C 'set':411C 'singl':180C,363C,399C 'single-slot':362C 'slot':364C 'smtp':59B,261C,290C 'sp':306C 'spec':102C,585C 'src/addressparser/index.ts':154C,367C,495C 'stack':366C,403C 'state':485C,490C,507C,513C,519C,542C,549C 'straight':477C 'streamtransport':274C 'strict':64B 'string':82B,400C 'strip':13A,39B,230C,474C,591C 'summari':83C 'surviv':54B,235C,329C 'switch':504C 'syntax':303C 'tail':574C 'test':317C 'text':164C,454C,481C,520C,544C,579C,609C 'text-accumul':163C 'text-bucket':608C 'this.escaped':389C 'this.indomainliteral':373C 'this.list.push':382C 'this.node':377C,383C,384C 'this.operatorexpecting':372C,386C,427C 'this.operators':376C,387C 'three':89C,144C,331C,638C 'togeth':456C 'token':169C,359C,438C,455C 'token.value':505C 'tokenizer.checkchar':368C 'touch':354C 'transact':301C 'ts':369C,503C 'two':110C 'type':378C 'unchang':339C 'unmodifi':312C 'unprotect':635C 'use':244C 'user':216C 'user@good-corp.com':217C 'v53p':140C 'valu':242C,285C,296C,380C,651C 'verbatim':236C 'version':315C 'way':183C 'whatev':548C 'whenev':160C 'whitespac':648C 'whole':618C 'wire':256C 'work':568C 'written':187C,252C 'x':218C 'xp8r':136C","nicknames":[],"mitigations":[],"who_affected":"Applications that send mail with nodemailer versions from 3.0.0 through 10.0.12 and build recipient strings that may contain user-controlled or externally sourced display-name and address text. The bug is in the parser, so any code path that reaches it is exposed, including the envelope option. Teams that only send to fixed, hard-coded recipients with no parsed input can skip it.","tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-27T15:04:36+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-g73g-hqqh-jr95","url":"https://github.com/advisories/GHSA-g73g-hqqh-jr95","severity":"medium","published_at":"2026-10-27T15:04:36+00:00","modified_at":"2026-10-27T15:04:36+00:00","first_seen_at":"2026-10-27T15:08:21+00:00","fetched_at":"2026-10-27T15:08:21+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}