GHSA-G73G-HQQH-JR95

GHSA-G73G-HQQH-JR95 is a medium-severity improper input validation vulnerability in nodemailer (npm), affecting versions >= 3.0.0, <= 10.0.12. It is fixed in 10.0.13.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

Summary

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

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 <[email protected](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():

} 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):

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:

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

[email protected](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

const addressparser = require('nodemailer/lib/addressparser');
console.log(addressparser('Legit User <[email protected](x)evil.com>'));
// [ { address: '[email protected](x)evil.com', name: 'Legit User' } ]

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

console.log(addressparser('[email protected](x)evil.com'));
// [ { address: '[email protected]', name: 'evil.com' } ]   <- correctly stripped

sendMail / envelope confirmation (jsonTransport, no network)

const nodemailer = require('nodemailer');

(async () => {
    const t = nodemailer.createTransport({ jsonTransport: true });
    const info = await t.sendMail({
        from: '[email protected]',
        to: 'Legit User <[email protected](x)evil.com>',
        subject: 'hi',
        text: 'x'
    });
    console.log('envelope.to:', info.envelope.to);
})();

Observed output:

envelope.to: [ '[email protected](x)evil.com' ]

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

From: [email protected]
To: Legit User <[email protected](x)evil.com>
Subject: hi

Wire-level confirmation against a real SMTP server

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: '[email protected]', to: 'Legit User <[email protected](x)evil.com>', subject: 'hi', text: 'x' });
    console.log('envelope.to:', info.envelope.to);
    t.close(); server.close();
});

Observed:

wire RCPT TO : RCPT TO:<[email protected](x)evil.com>
envelope.to: [ '[email protected](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: [ '[email protected](x)evil.com' ]

So 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.

Interpretation-conflict confirmation (same class as GHSA-cc9r-2j5m-2m83)

The same string, read by two other parsers an application might use to gate on the recipient's domain, produces two more, mutually different readings:

>>> import email.utils
>>> email.utils.parseaddr('Legit User <[email protected](x)evil.com>')
('Legit User (x)', '[email protected]')
>>> import email.policy, email.parser
>>> msg = email.parser.Parser(policy=email.policy.default).parsestr(
...     'To: Legit User <[email protected](x)evil.com>\n\n')
>>> addr = msg['To'].addresses[0]
>>> addr.username, addr.domain
('user', 'good-corp.com')

So for the exact same bytes:

Parser Domain read
Python email.utils.parseaddr good-corp.comevil.com (attacker-registrable)
Python email.policy.default (defects not inspected) good-corp.com (clean, would pass an allow-list check)
Nodemailer addressparser (this report) good-corp.com(x)evil.com (illegal characters, rejected by a strict MTA)

This 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.

Version range

The built addressparser/sendMail output was tested for this exact input across published releases:

Version envelope.to
6.9.16 [email protected](x)evil.com
9.0.6 [email protected](x)evil.com
9.1.0 (fixed GHSA-cc9r-2j5m-2m83) [email protected](x)evil.com
10.0.1 [email protected](x)evil.com
10.0.12 / current HEAD (fixed GHSA-g57g-f23g-4646) [email protected](x)evil.com

Unchanged 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 ).

Regression test suggestion

Name <[email protected](x)evil.com> and the bcc/cc/from/replyTo/envelope equivalents should all parse to { address: '[email protected]', name: 'Name evil.com' } or be rejected outright by sendMail, never silently carry (/) into envelope.to.

Impact

  • 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).
  • 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.
  • 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.

The application does not adequately validate input before processing it, allowing unexpected values to reach sensitive code paths. Typical impact: varies by context: data corruption, logic bypass, or denial of service.

GHSA-G73G-HQQH-JR95 has a CVSS score of 5.3 (Medium). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (10.0.13); upgrading removes the vulnerable code path.

Affected versions

nodemailer (>= 3.0.0, <= 10.0.12)

Security releases

nodemailer → 10.0.13 (npm)

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

The 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.

As 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.

Frequently Asked Questions

  1. What is GHSA-G73G-HQQH-JR95? GHSA-G73G-HQQH-JR95 is a medium-severity improper input validation vulnerability in nodemailer (npm), affecting versions >= 3.0.0, <= 10.0.12. It is fixed in 10.0.13. The application does not adequately validate input before processing it, allowing unexpected values to reach sensitive code paths.
  2. How severe is GHSA-G73G-HQQH-JR95? GHSA-G73G-HQQH-JR95 has a CVSS score of 5.3 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of nodemailer are affected by GHSA-G73G-HQQH-JR95? nodemailer (npm) versions >= 3.0.0, <= 10.0.12 is affected.
  4. Is there a fix for GHSA-G73G-HQQH-JR95? Yes. GHSA-G73G-HQQH-JR95 is fixed in 10.0.13. Upgrade to this version or later.
  5. Is GHSA-G73G-HQQH-JR95 exploitable, and should I be worried? Whether GHSA-G73G-HQQH-JR95 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether GHSA-G73G-HQQH-JR95 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix GHSA-G73G-HQQH-JR95? Upgrade nodemailer to 10.0.13 or later.

Other vulnerabilities in nodemailer

Stop the waste.
Protect your environment with Kodem.