Skip to content

fix(deps): update dependency nodemailer to v10.1.4 [security] - #5027

Closed
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-nodemailer-vulnerability
Closed

renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-nodemailer-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
nodemailer (source) 10.0.9 → 10.1.4 age confidence

Warning

Some dependencies could not be looked up. Check the Dependency Dashboard for more information.


Nodemailer: Quadratic (O(n^2)) DKIM header unfolding lets a large folded header cause remote denial of service during signing

CVE-2026-110871 / GHSA-39m8-27wv-hr27

More information

Details

Summary

When nodemailer DKIM-signs an outbound message, its DKIM message parser unfolds RFC 5322 header continuation lines with a loop that concatenates each continuation line onto the preceding line and then re-tests the anchored regex /^[ \t]/ against the growing merged line on every iteration. Re-testing the regex against the ever-growing accumulated line forces a full string flatten proportional to the accumulated length on each iteration, yielding O(n^2) CPU in the number of continuation lines. An application user who influences the size of a header that folds into many continuation lines (most naturally a large recipient list, but also a long custom header, Subject, or References) can drive tens of thousands of continuation lines and block the single-threaded Node.js event loop for seconds during signing, a repeatable denial of service.

Root Cause

The invariant that signing a message performs work linear in the message size is violated in the header-unfolding step. In src/dkim/message-parser.ts, parseHeaders() splits the buffered header block into lines and iterates backward; for each line matching the continuation pattern /^[ \t]/, it does lines[i - 1] += '\n' + lines[i] and lines.splice(i, 1). The dominant cost is not the array splice: iterating backward keeps each splice near the shrinking tail, so it shifts only the few trailing header lines. The quadratic cost is the anchored regex test /^[ \t]/.test(lines[i]) (together with the += concatenation) evaluated against the growing merged line on every iteration. The += builds an O(1) rope, but the next iteration's regex test forces a full flatten of the entire accumulated line, so the total cost is the sum over all continuation lines, which is O(n^2). The header block is buffered by checkHeaders without any size or line-count cap; the DKIM signer's MAX_MESSAGE_SIZE and cacheTreshold knobs gate the body stream only, not the header block, so nothing bounds the number of continuation lines fed to the loop.

Affected file and function (verified at the published v10.0.9 artifact, in src/ and compiled verbatim into the published dist output):
- src/dkim/message-parser.ts: MessageParser.parseHeaders (the continuation-unfold loop that re-tests /^[ \t]/ against the growing merged line)
Impact

An attacker or user who can make an outbound, DKIM-signed message carry a header that folds into a large number of continuation lines can freeze the sending Node.js process during signing. Because Node.js is single-threaded, the synchronous unfold loop starves the entire event loop, halting all other work in the process, and it is repeatable on every such message. The natural lever is a large recipient list: nodemailer's own MIME composer folds a To or Cc value at column 76 into space-prefixed continuation lines, and the count scales linearly with the number of recipients, so a caller-supplied recipient list (common in bulk-mail, notification, and multi-tenant email APIs) turns into tens of thousands of continuation lines. The single precondition is that DKIM signing is enabled, which is a mainstream deliverability configuration. Independent reproduction on Node.js 24 measured roughly 2.8 seconds of event-loop blocking at about 20,000 continuation lines and roughly 11.5 seconds at about 40,000, scaling quadratically; the full composer-to-signer chain with a 60,000-recipient To header blocked for roughly 6.6 seconds. The applications that cap recipient count or header size shrink the exposed population but do not change the per-message availability impact once a large header is accepted.

Proof of Concept

Node.js 24; the shipped DKIM signer (require('nodemailer/lib/dkim')) or the exact unfold loop.

Steps: (1) construct a message whose header block contains one header value folded into many space-prefixed continuation lines (for example by supplying a very large recipient list, which nodemailer's MIME composer folds automatically); (2) DKIM-sign it and observe the event loop stall while parseHeaders unfolds the block. A minimal microbenchmark of the exact unfold logic demonstrates the complexity:

// Reproduces the parseHeaders unfold loop verbatim (Node 24).
function unfold(headerBlock) {
    const lines = headerBlock.split(/\r?\n/);
    for (let i = lines.length - 1; i > 0; i--) {
        if (/^[ \t]/.test(lines[i])) {
            lines[i - 1] += '\n' + lines[i];
            lines.splice(i, 1);
        }
    }
    return lines;
}
function run(n) {
    let block = 'To: a@b.c\r\n';
    for (let i = 0; i < n; i++) block += ' cont-' + i + '@example.com,\r\n';
    for (let k = 0; k < 5; k++) block += 'X-H' + k + ': v\r\n';
    const t = Date.now();
    unfold(block);
    return Date.now() - t;
}
for (const n of [5000, 10000, 20000, 40000]) console.log(n, run(n) + 'ms');
5000   27ms
10000  542ms
20000  2853ms
40000  11503ms
(2x input yields roughly 4x-5x time: quadratic. The cost is the regex re-flatten of the growing merged line, not the splice.)
Attack Chain
  1. Exposure: An application DKIM-signs outbound mail (a mainstream deliverability configuration, enabled per transport or per message) for a message whose header size is influenced by a user, for example a large recipient list, a long custom header, Subject, or References.
  2. Control: The attacker or user controls the size of that header. The MIME composer folds it deterministically at column 76 into space-prefixed continuation lines that match the continuation pattern, so the continuation-line count scales linearly with the supplied size.
  3. Path: Signing pipes the composed message through the DKIM MessageParser; checkHeaders buffers the whole header block into rawHeaders and calls parseHeaders(), the single sink, on every signed message.
  4. Guard: No header-size or line-count cap exists between the input and the sink; the signer's body-only size thresholds do not apply to the header block.
  5. Primitive: The unfold loop re-tests the anchored regex against the growing merged line on each continuation line, forcing a full flatten per iteration and yielding O(n^2) synchronous CPU.
  6. Result: Event-loop starvation of the single-threaded sending process for an attacker-scaled duration, repeatable per message: a denial of service.
Bypass Evidence

The relevant guard would be a cap on header-block size or continuation-line count before unfolding. A search of the DKIM module confirms none exists: checkHeaders accumulates header chunks unbounded until the header terminator, and the only size knobs (MAX_MESSAGE_SIZE, cacheTreshold) are checked against the body stream, not the header block. The strongest disproof attempt was that the backward splice makes the loop cheap for a single consecutive folded block (so it would not be quadratic); this was refuted by a mechanism-isolation benchmark: removing the splice entirely leaves the loop just as slow (about 11 seconds at 40,000 lines), while precomputing the continuation test on the original lines so the growing merged line is never re-tested collapses it to a few milliseconds. This proves the quadratic driver is the regex re-flatten of the accumulated line, not the array shift, and confirms the loop is quadratic even for a single consecutive folded block. No published advisory covers this file: the disclosed complexity issues all live in the address parser and recipient-normalization code, and the DKIM message parser has never received a complexity fix.

Affected Versions
  • Ecosystem: npm
  • Package: nodemailer
  • Confirmed affected range: <= 10.0.9
  • Latest release checked: nodemailer 10.0.9 (npm registry; vulnerable code verified at git tag v10.0.9 and in the published dist output)
  • Fix status: not fixed (no released version removes the quadratic unfold or caps header size)

The unfold loop is long-standing DKIM code that predates the address-parser complexity fix family, so the exact introducing release is not positively established and the lower bound is left open; the upper bound is verified against the current published artifact, which is vulnerable with no fix.

Suggested Fix

Enforce linear-time header unfolding and bound the header block. The key correction is to stop re-scanning the growing accumulated line: classify each line's continuation status on the original split lines before merging, and build the unfolded value with an array plus a single join rather than repeatedly re-testing and concatenating the growing string. Simply replacing splice with a filter or two-pointer compaction while still re-testing the merged line would not fix the issue, because the regex re-flatten of the accumulated line is the quadratic driver. Independently, impose a configurable maximum on the buffered header-block size or continuation-line count in the message parser and fail closed when it is exceeded, so an oversized folded header cannot force unbounded work. As an interim mitigation, applications should cap recipient counts and header-value sizes for messages that will be DKIM-signed.

Reported by zx (GitHub: @​manus-pi).

Severity

  • CVSS Score: 6.5 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Nodemailer: Quadratic (O(n^2)) SMTP client reply parsing lets a malicious server or MITM cause a pre-auth remote denial of service

CVE-2026-110872 / GHSA-4g23-2xm8-66gc

More information

Details

Summary

The SMTP client response parser reassembles multiline server replies by appending each continuation line onto a single response-queue entry and re-testing whether that entry is still a partial (continuation) reply on every line. The partial-response test runs str.slice(str.lastIndexOf('\n') + 1) on the growing entry, which forces a full flatten of the entire accumulated string on each line. A malicious or compromised SMTP server (or an on-path attacker on a plaintext or pre-STARTTLS hop) that streams continuation lines and never sends a completion line drives O(n^2) CPU on the single-threaded Node.js event loop before any authentication, freezing the whole process for an attacker-controlled duration.

Root Cause

The invariant that a server cannot cause superlinear client-side work while a reply is being reassembled is violated. In src/smtp-connection/index.ts, _onData splits inbound bytes into lines and, while the last queued entry is a continuation reply, concatenates each new line onto that same entry with this._responseQueue[len - 1] += '\n' + lines[i]. On every iteration it calls isPartialResponse(lastline), which evaluates /^\d+-/.test(str.slice(str.lastIndexOf('\n') + 1)). Because the queued entry grows by one line per continuation line and is stored as a V8 rope (ConsString), each lastIndexOf/slice forces a full string flatten of length proportional to the accumulated reply. Executed once per line over n continuation lines, this is O(n^2) CPU. While the reply stays partial the function early-returns and never resets the entry, so the growth and the per-line reflatten continue unbounded. There is no response-size or line-count cap in the connection code; the only limits are time-based (greetingTimeout, and an inactivity socketTimeout), and neither bounds accumulated reply size.

Affected file and functions (verified at the published v10.0.9 artifact, in src/ and in the built dist/cjs and dist/esm output):
- src/smtp-connection/index.ts: isPartialResponse (the /^\d+-/ test over str.slice(str.lastIndexOf('\n')+1))
- src/smtp-connection/index.ts: SMTPConnection._onData (the continuation-line append loop with no size cap)
Impact

An attacker who is, or can impersonate, the SMTP peer the client connects to can freeze the connecting Node.js process. Because Node.js is single-threaded, event-loop starvation halts all other work in that process (every other connection, request, and timer), not just the offending SMTP session. The reply is parsed during the greeting and EHLO phases, before the client authenticates or issues any command, so no credentials on the client are required. The blocking duration scales with the volume of continuation data the attacker streams and can be sustained indefinitely by continuing to stream, and memory for the accumulated entry also grows without bound. Independent reproduction on Node.js 24 measured roughly 11.6 seconds of event-loop blocking for about 640 KB of continuation data and roughly 47 seconds for about 1.3 MB, scaling quadratically. This is a repeatable remote denial of service against the sending process.

Proof of Concept

Node.js 24; a raw TCP listener acting as a hostile SMTP server; the client uses the shipped SMTPConnection (require('nodemailer/lib/smtp-connection')).

Steps: (1) start a TCP server that accepts a connection and streams continuation lines of the form 220-flood...\r\n in large batches, never emitting a completion line such as 220 ready; (2) point an SMTPConnection at it and observe the client event loop stall while the single response-queue entry grows and is reflattened on every line. A minimal microbenchmark of the exact parsing logic demonstrates the complexity:

// Reproduces isPartialResponse + the _onData append loop verbatim (Node 24).
function isPartialResponse(str) { return /^\d+-/.test(str.slice(str.lastIndexOf('\n') + 1)); }
function run(n) {
    const queue = [];
    let remainder = '';
    function onData(chunk) {
        const lines = (remainder + chunk).split(/\r?\n/);
        remainder = lines.pop();
        for (let i = 0; i < lines.length; i++) {
            if (queue.length && isPartialResponse(queue[queue.length - 1])) {
                queue[queue.length - 1] += '\n' + lines[i];
                continue;
            }
            queue.push(lines[i]);
        }
    }
    let buf = '';
    for (let i = 0; i < n; i++) buf += '220-flood line ' + i + '\r\n';
    const t = Date.now();
    onData(buf);
    return Date.now() - t;
}
for (const n of [8000, 16000, 32000, 64000]) console.log(n, run(n) + 'ms');
8000   26.5ms
16000  357.6ms
32000  2712.0ms
64000  11645.1ms
(2x input yields roughly 4x-7x time: quadratic. ~640 KB of continuation data blocks the event loop ~11.6s.)
Attack Chain
  1. Exposure: The client opens an SMTP connection whose peer is attacker-controlled or attacker-influenced: a compromised or malicious configured server, a tenant-supplied outbound server in a multi-tenant "bring your own SMTP" deployment, a deliverability or mailbox-probing tool that connects to arbitrary servers, or an on-path attacker on a plaintext or pre-STARTTLS hop.
  2. Control: The peer fully controls the bytes of its greeting and EHLO replies, including whether a reply ever completes.
  3. Path: Inbound bytes reach the socket data handler _onData, the sole data handler; every reply phase traverses the same continuation-append loop.
  4. Guard: No response-size or line-count cap exists. The greeting timer is time-based and cleared after the greeting, and its callback is itself starved while _onData runs synchronously; the inactivity timer resets on every inbound chunk, so an actively streaming attacker never trips it.
  5. Primitive: Each continuation line appended to the single growing queue entry triggers a full string flatten in isPartialResponse, yielding O(n^2) synchronous CPU on the event loop.
  6. Result: Whole-process event-loop starvation for an attacker-controlled, sustainable duration, plus unbounded memory growth: a remote denial of service reachable before authentication.
Bypass Evidence

The relevant guard would be a cap on accumulated reply size or line count. A search of the connection module (maxSize, maxLength, responseQueue.length >, byte or line ceilings) finds none; the only bounds are greetingTimeout and the inactivity-based socketTimeout, both time-based and neither bounding accumulated size. Disproof attempts: the greeting timeout does not help because it is time-based, fires only during the greeting phase, is cleared once the greeting completes, and its callback is delayed while _onData runs synchronously; a well-behaved server does not trigger the issue because a completion line resets the entry and keeps cost linear, but the modeled attacker is a hostile peer; segmented delivery does not help the defender because the response queue and line remainder persist across _onData calls so the accumulation and quadratic cost survive chunking; there is no safer alternate caller because _onData is the only data handler and the append loop is on every reply phase. Note on scope: core nodemailer connects to a configured host and does not perform MX resolution, so this is not reachable through a direct-to-MX transport; the malicious-peer, multi-tenant tenant-supplied-host, deliverability-probe, and MITM paths establish reachability.

Affected Versions
  • Ecosystem: npm
  • Package: nodemailer
  • Confirmed affected range: <= 10.0.9
  • Latest release checked: nodemailer 10.0.9 (npm registry; vulnerable code verified at git tag v10.0.9 and in the published dist output)
  • Fix status: 10.0.10

The quadratic reassembly shape is long-standing and predates the addressparser complexity fix family, so the exact introducing release is not positively established and the lower bound is left open; the upper bound is verified against the current published artifact, which is vulnerable with no fix.

Suggested Fix

Enforce that reassembling a multiline reply is linear in the reply size and that the reply size is bounded. Track the running length of the accumulated entry and the continuation state without re-scanning the whole entry on every line (for example, test the newly arrived line rather than slicing the tail of the growing accumulator, or collect lines in an array and join once at completion). Independently, impose a configurable maximum on the accumulated reply size or continuation-line count and fail the connection when it is exceeded, so a peer that never completes a reply cannot force unbounded work or memory. The remediation must cover every reply phase (greeting, EHLO, and command responses), because all of them flow through the same handler. As an interim mitigation, only connect to trusted SMTP servers over authenticated TLS (avoiding plaintext or STARTTLS-strippable hops), and in multi-tenant deployments isolate outbound SMTP sessions to tenant-supplied hosts so one tenant cannot starve a shared process.

Reported by zx (GitHub: @​manus-pi).

Severity

  • CVSS Score: 5.9 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Nodemailer: Quadratic backtracking in SMTP AUTH mechanism detection regexes allows pre-auth remote denial of service

CVE-2026-110869 / GHSA-4ffr-jq9g-5ffx

More information

Details

Summary

Nodemailer's SMTP client applies four regular expressions to a peer's EHLO response to detect the supported SASL mechanisms. Each pattern uses an alternation with two overlapping whitespace-matching sub-patterns, which produces quadratic (O(n^2)) backtracking whenever the mechanism name is absent from the line. A malicious or MITM SMTP server that answers EHLO with 250-AUTH followed by a long whitespace-only run and a non-matching token blocks the Node.js event loop for tens of seconds to hours during a pre-authentication handshake. No authentication to the target SMTP server is required, and any application that connects to an attacker-influenced host is affected.

Root Cause

Each of the four AUTH mechanism regexes in SMTPConnection._actionEHLO contains the sub-pattern (?:(\s+|=)[^\n]*\s+|\s+|=) before the mechanism literal. [^\n]* and the trailing \s+ both match whitespace and can consume the same characters, so when the mechanism literal is absent, the regex engine must try every partition of the whitespace run between [^\n]* and \s+ before failing. The four regexes run unconditionally on every EHLO response, with no length cap, no shape validation, and no early rejection before the engine enters the ambiguous alternation.

Component: nodemailer (npm), SMTP client
Function:  SMTPConnection._actionEHLO
Location:  src/smtp-connection/index.ts, lines 2162, 2167, 2172, 2177
Patterns:  /[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)PLAIN/i
           /[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)LOGIN/i
           /[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)CRAM-MD5/i
           /[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)XOAUTH2/i
Impact

Algorithmic-complexity denial of service against any Node.js process that opens an SMTP connection to an attacker-controlled or attacker-influenced host. Node.js is single threaded, so the process stalls entirely while the regex engine backtracks: no other requests are served, no timers fire, and the built-in socket timeout (10 minutes) cannot interrupt synchronous JavaScript. A 4,000-character whitespace run inside a single continuation line already blocks each regex for tens of seconds; the client bounds a response at 1 MB, which leaves headroom for blocking runs measured in hours across the four patterns. Two realistic attacker positions reach the sink pre-authentication: a hosted mailer that lets tenants configure their own SMTP server, and any network-adjacent attacker able to man-in-the-middle a cleartext SMTP connection (including before STARTTLS negotiation).

Proof of Concept

The PoC exercises the exact four regexes copied from src/smtp-connection/index.ts against a synthesized 250-AUTH<whitespace>NOMATCH line, so no live SMTP server is needed to reproduce the timing.

Runtime:      Node.js >= 20
Dependencies: none
Invocation:   node poc.js
'use strict';

const AUTH_REGEXES = [
    /[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)PLAIN/i,
    /[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)LOGIN/i,
    /[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)CRAM-MD5/i,
    /[ -]AUTH(?:(\s+|=)[^\n]*\s+|\s+|=)XOAUTH2/i,
];

function bench(n) {
    const line = '250-AUTH' + ' '.repeat(n) + 'NOMATCH';
    const start = Date.now();
    for (const re of AUTH_REGEXES) re.test(line);
    return Date.now() - start;
}

for (const n of [500, 1000, 2000, 3000]) {
    console.log(`n=${n}  four-regex time=${bench(n)} ms`);
}

const control = '250-AUTH PLAIN LOGIN CRAM-MD5 XOAUTH2';
const t0 = Date.now();
for (const re of AUTH_REGEXES) re.test(control);
console.log(`well-formed control  four-regex time=${Date.now() - t0} ms`);
n=500   four-regex time=914 ms
n=1000  four-regex time=1348 ms
n=2000  four-regex time=10558 ms
n=3000  four-regex time=35251 ms
well-formed control  four-regex time=0 ms

Doubling the whitespace run multiplies the wall time by roughly four, which matches the expected O(n^2) growth. The well-formed control shows that a normal EHLO response is parsed in effectively zero time.

Attack Chain
  1. Exposure: The victim application, or any dependent that uses nodemailer to send mail, opens a TCP or TLS connection to an SMTP host the attacker controls. This happens automatically for multi-tenant mailers whose tenants configure their own outbound SMTP server, and for any client whose plaintext SMTP session can be intercepted by a network-adjacent attacker before STARTTLS negotiation.
  2. Control: The attacker fully controls the bytes returned in response to EHLO. In particular, the attacker chooses the length and content of every 250- continuation line up to the client's 1 MB response cap.
  3. Path: Socket bytes are reassembled by _onData, decoded by _processResponse, and dispatched to _actionEHLO. On the first EHLO after the greeting (malicious server with no STARTTLS), on the second EHLO after a successful STARTTLS upgrade, or on the first EHLO when ignoreTLS is set, control reaches the AUTH detection block near the end of _actionEHLO.
  4. Guard: The only bounds on this path are maxResponseSize (1 MB by default) and the 421 or non-2xx status-code check. Neither prevents O(n^2) backtracking: 4,000 whitespace characters already block each regex for tens of seconds, well below the 1 MB cap. No length, shape, or sanitization check runs before the regexes.
  5. Primitive: Four RegExp.prototype.test calls execute against the crafted line, each entering catastrophic backtracking because [^\n]* and \s+ share the whitespace alphabet and the mechanism literal never matches.
  6. Result: The Node.js event loop is blocked for the duration of the four regex evaluations. All other work in the process, including timers, unrelated requests, keep-alive traffic, and the SMTP socket timeout itself, is suspended until the regexes return.
Bypass Evidence

The only response-side protection in this code path is maxResponseSize, introduced by the fix for GHSA-4g23-2xm8-66gc (commit f2d82fa). That fix targeted quadratic reassembly of long multiline replies inside _onData and left the AUTH regex evaluation in _actionEHLO untouched. maxResponseSize does not prevent this issue: at 4,000 whitespace characters, roughly 0.4 percent of the default 1 MB cap, a single continuation line already blocks each of the four regexes for tens of seconds. No status-code or length gate rejects the payload before the regex engine enters the ambiguous alternation, and Node.js's single-threaded event loop cannot service the socket timeout while a synchronous test() call is running. Two other recent linear-time fixes in this project (the addressparser fallback for GHSA-v53p-9fqp-m79j and the DKIM header unfolding for GHSA-39m8-27wv-hr27) address entirely different modules and inputs and do not touch the AUTH mechanism regexes.

Affected Versions
  • Ecosystem: npm
  • Package: nodemailer
  • Confirmed affected range: >= 3.0.0, <= 10.0.12
  • Latest release checked: 10.0.12 (npm registry)
  • Fix status: 10.0.13

The four AUTH mechanism regexes were introduced when the SMTP client was bundled into nodemailer at 3.0.0 (previously a separate smtp-connection package). The vulnerable alternation (?:(\s+|=)[^\n]*\s+|\s+|=) is present, unchanged, in every published release from 3.0.0 (lib/smtp-connection/index.js) through 10.0.12 (dist/esm/smtp-connection/index.js and dist/cjs/smtp-connection/index.js). A later refactor added a leading [ -] character class but did not alter the ambiguous inner alternation. No release, and no development branch inspected at the current HEAD (30e4cfd), contains a fix for this pattern.

Suggested Fix

Replace the four backtracking regexes with a non-ambiguous parse of the AUTH extension. The invariant the fix must enforce is that mechanism detection runs in time linear in the response length regardless of whitespace shape. A minimal approach: match the AUTH extension value with a single non-backtracking pattern (for example, /[ -]AUTH(?:\s+|=)([^\r\n]*)/i) and then check for each mechanism name against the whitespace-split tokens with a Set lookup or includes. The fix must cover both continuation forms (250-AUTH ... and 250 AUTH ...) and both delimiters (space and =), and must handle the "AUTH advertised with no mechanisms" case without walking the whitespace run. As an interim mitigation, callers who never connect to an untrusted SMTP peer can restrict SMTP endpoints to a known allowlist; this does not help clients that must interoperate with arbitrary user-configured servers or with any cleartext SMTP path exposed to a network-adjacent attacker before STARTTLS.


Reported by zx (GitHub: @​manus-pi).

Severity

  • CVSS Score: 5.9 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


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

CVE-2026-110870 / GHSA-g73g-hqqh-jr95

More information

Details

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

} 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;
    }
    ...

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

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)
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
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 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 <user@good-corp.com(x)evil.com>')
('Legit User (x)', 'user@good-corp.comevil.com')
>>> import email.policy, email.parser
>>> msg = email.parser.Parser(policy=email.policy.default).parsestr(
...     'To: Legit User <user@good-corp.com(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 user@good-corp.com(x)evil.com
9.0.6 user@good-corp.com(x)evil.com
9.1.0 (fixed GHSA-cc9r-2j5m-2m83) user@good-corp.com(x)evil.com
10.0.1 user@good-corp.com(x)evil.com
10.0.12 / current HEAD (fixed GHSA-g57g-f23g-4646) user@good-corp.com(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 ).

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.
Suggested fix

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.

Regression test suggestion

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.

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Nodemailer: A ">" inside a quoted local part ends an angle-addr early in addressparser, delivering to a domain the header does not name

CVE-2026-121426 / GHSA-85x2-q946-cp5h

More information

Details

Vulnerability summary

addressparser ends an angle-addr at the first > it sees, including one inside a quoted local part, a comment or a domain literal. The domain is then read off the text in front of that >. <"a@evil.com>"@good.com> is the mailbox "a@evil.com>"@good.com under RFC 5322, and Nodemailer read it as an address at evil.com. The unbracketed spelling of the same value was already read correctly. Affected: 3.0.0 through 10.1.3.

Attacker capabilities

The attacker controls the text of a recipient or sender address that an application passes to Nodemailer, for example a To value entered in a contact or invite form, or the header of an inbound message parsed with mailparser. No access to the application, its configuration or its credentials is needed. The value is a syntactically valid RFC 5322 address that conformant parsers accept without defects.

Realistic exploitation scenario

An application lets users send invitations or notifications only to addresses at an allowed domain and validates the submitted value with a conformant parser or by checking its domain. The attacker submits <"a@evil.com>"@good.com>, which validates as a good.com mailbox. Nodemailer then parses the same value, issues RCPT TO:<"\"a"@evil.com> and the message, with whatever content the application puts into it, is delivered to the attacker's domain. The security boundary crossed is the application's recipient allow list. In the inbound direction, mailparser reports a different address than a conformant reader of the same header, so a display or policy layer built on the two disagrees.

Proof of concept
const addressparser = require('nodemailer/lib/addressparser');
console.log(addressparser("<\"a@evil.com>\"@good.com>"));
// [ { address: '"a@evil.com', name: '@good.com>' } ]

Python's email._header_value_parser.get_address_list reads the same value as "a@evil.com>"@good.com, with no defects reported.

The envelope a message gets is built from the same parse, so sendMail({ to: "<\"a@evil.com>\"@good.com>" }) issues the RCPT TO shown above.

Security impact

Mail that an application intends for an allowed domain is delivered to a domain chosen by the attacker, which discloses its content. It is a security vulnerability rather than a parsing bug because the misreading only occurs for values that a conformant validator approves, so validation done the right way is bypassed. Affected are applications that pass user influenced addresses to Nodemailer and consumers of the standal

[!IMPORTANT]
✂ PR body was truncated to here.

@renovate
renovate Bot force-pushed the renovate/npm-nodemailer-vulnerability branch from 4b7fa88 to 13619ac Compare October 24, 2026 01:06
@renovate renovate Bot changed the title fix(deps): update dependency nodemailer to v10.0.10 [security] fix(deps): update dependency nodemailer to v10.1.4 [security] Oct 24, 2026
@renovate
renovate Bot force-pushed the renovate/npm-nodemailer-vulnerability branch from 13619ac to 529d09d Compare October 30, 2026 10:46
@tale-code-bot tale-code-bot mentioned this pull request Oct 30, 2026
59 tasks
@renovate
renovate Bot force-pushed the renovate/npm-nodemailer-vulnerability branch from 529d09d to 2a12b80 Compare November 5, 2026 17:02
@tale-code-bot tale-code-bot added the bug Bug label Nov 7, 2026
yannickmonney added a commit that referenced this pull request Nov 7, 2026
@yannickmonney

Copy link
Copy Markdown
Contributor

Landed on train as fc7558b (squashed from head 2a12b80). It reaches main with the next train pull request.

@renovate

renovate Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Renovate Ignore Notification

Because you closed this PR without merging, Renovate will ignore this update (10.1.4). You will get a PR once a newer version is released. To ignore this dependency forever, add it to the ignoreDeps array of your Renovate config.

If you accidentally closed this PR, or if you changed your mind: rename this PR to get a fresh replacement PR.

@renovate
renovate Bot deleted the renovate/npm-nodemailer-vulnerability branch November 7, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants