A denial-of-service flaw disclosed Friday and given a CVE ID Sunday was introduced by a security fix in nodemailer 9.1.0 — the hardening that closed one address-parsing hole opened a quadratic one.
What happened
On September 13, 2026, NVD published CVE-2026-90776, an algorithmic-complexity denial-of-service flaw in Nodemailer, the most widely used email library for Node.js. The record carries a CVSS 3.1 base score of 7.5 (High) and a CVSS 4.0 base score of 8.7 (High), both from VulnCheck, the CNA that assigned the ID, and it is classified CWE-407, inefficient algorithmic complexity. The underlying advisory, GHSA-prgh-xp8r-p3m5, was published two days earlier, on September 11.
The affected range is precise: Nodemailer 9.1.0 through 10.0.4. The fix shipped in 10.0.5, published to npm at 12:10 p.m. UTC on September 11 — one minute after 10.0.4. The current release is 10.0.9. Versions before 9.1.0 are not affected.
That range is the story. The vulnerable code did not drift in over years; it was added deliberately in 9.1.0, and it was added as a security fix. Nodemailer’s address parser assembles an email address by accumulating its atoms — the pieces between separators — into a growing string. Before 9.1.0, a comment inside an address was allowed to glue two atoms together. That is a real problem: an address written [email protected](x)evil.com could be flattened into the single domain example.comevil.com, delivering mail to a domain the sender never named. The 9.1.0 release closed that gap with a guard that refused to join two atoms across a comment unless the run already ended in @. The guard read the last character of the accumulator back off the string with slice(-1) on every token.
Reading the last character that way forces the JavaScript engine to walk the whole accumulated string each time. Over an address built from many comment-joined atoms — a@b(c)@b(c)@b(c)... — the work grows with the square of the input. The comment in the patched code names the trade directly: the slice(-1) “makes the engine flatten the whole growing string on every token, which is quadratic over an address built from many comment-joined atoms.” The security fix worked; it just carried a quadratic tail no one caught in review.
What we verified
We downloaded the shipped packages from the npm registry and ran the parser against the advisory’s own proof of concept — an address of roughly 130,000 comment-joined atoms, about 635 KB — on Node.js 22. The results track the affected range exactly:
- 9.0.6 (before the regression): 474 ms
- 9.1.0 (regression introduced): 8,198 ms
- 9.1.1: 7,424 ms
- 10.0.4 (last affected): 7,216 ms
- 10.0.5 (fixed): 237 ms
- 10.0.9 (current): 147 ms
A single input froze the parser — and, in a single-threaded Node.js process, the entire event loop — for more than seven seconds on affected versions, against a quarter-second on the versions on either side of the affected range. We confirmed the source of the difference by reading the code: 10.0.4 still carries the parts[parts.length - 1].slice(-1) line; 10.0.5 replaces it with a lastChars[state] value carried forward as each token is appended, so the last character is known without re-reading the string. The guard against comment-joined domain spoofing is preserved; only the quadratic read is gone.
Why it matters
This is a clean example of a pattern worth naming: a fix for one class of bug that introduces another, in the same function, in the same release. The 9.1.0 hardening was correct security work — the domain-spoofing gap it closed was the more dangerous of the two flaws. But the reviewer’s attention was on whether the guard was right, not on what it cost, and the cost was a remote denial of service that sat in the tree for two weeks across five releases before it was noticed. Anyone who upgraded to 9.1.0 specifically to get the spoofing fix took the DoS along with it. The lesson is not “don’t fix bugs”; it is that a change to a hot parsing path deserves a complexity read, not only a correctness read.
The reachability of this one matters as much as the severity, and it cuts against Nodemailer’s usual shape. Nodemailer is an outbound library — its job is sending mail — and an application usually controls the addresses it sends to. If the address parser only ever sees strings the application itself supplies, an attacker has no lever. The advisory is explicit that the dangerous path is a different one: the same parser is reachable via mailparser, the companion library that processes inbound email. Inbound headers are attacker-controlled by definition. A service that parses incoming mail — a ticketing system, a mailing-list processor, an inbound webhook that reads the From or To line — hands the parser a string it did not write, and a single crafted message can stall it. The advisory reports the flaw is reachable through mailparser’s inbound header parsing without authentication.
The blast radius is a function of how the process is deployed. A worker that parses one message at a time, synchronously, is stalled for the duration of each malicious message — a queue of them is a sustained outage. A web server sharing the event loop with request handling stops answering everything, not just mail, while the parse runs. Because the cost is quadratic, the attacker’s effort is cheap: doubling the payload roughly quadruples the freeze, so a few megabytes buys a much longer stall than the seven seconds we measured at 635 KB.
There is no exploitation observed. This is a disclosed-and-patched flaw with a public proof of concept, not an incident. The reason to move is that the proof of concept is trivial to reproduce — we reproduced it from the advisory in one run — and the patched and unpatched releases differ by a single npm version bump, which makes the diff easy for anyone to read and weaponize.
What to do
Upgrade Nodemailer to 10.0.5 or later; the current release is 10.0.9. If you cannot move off the 9.x line immediately, note that there is no fixed 9.x release — the patch shipped only on 10.x — so the upgrade is a major-version bump, and 10.0.0 landed on September 4, 2026 with its own set of changes worth reading before you jump.
Prioritize by exposure to untrusted input. If your application only sends mail to addresses it controls, this is a low-urgency dependency update. If you parse inbound mail with mailparser, or feed any user-controlled string to the address parser, treat it as the real risk it is and patch first. Check your lockfile for the resolved Nodemailer version rather than the range in package.json; a caret range can resolve to an affected build. As a stopgap where you cannot upgrade, cap the size of address strings you accept before parsing — the attack needs a large input to be worth anything, so a length limit on inbound header fields blunts it.
Sourcing note
We verified the affected and fixed ranges by downloading Nodemailer 6.10.1, 9.0.6, 9.1.0, 9.1.1, 10.0.4, 10.0.5, 10.0.6, 10.0.7, 10.0.8, and 10.0.9 from the npm registry and timing the shipped address parser against the advisory’s proof of concept on Node.js 22.22.2; the numbers above are our own measurements. We read the parser source across versions and confirmed the 9.1.0 diff that introduced the slice(-1) guard and the 10.0.5 diff (commit c07f175) that replaced it. The CVSS scores, CWE, and timestamps are from NVD’s record for CVE-2026-90776 (published September 13, 2026, 12:17 p.m. UTC); the affected range, patched version, credit to mmadersbacher, and the “reachable via mailparser” characterization are from GHSA-prgh-xp8r-p3m5 (published September 11, 2026). npm registry metadata supplied the release timestamps. One item we did not independently test: the advisory’s specific claim about mailparser’s inbound path — we confirmed the shared parser is the vulnerable component and reproduced the DoS directly against it, but did not run a crafted message end-to-end through mailparser. VulnCheck is the CNA and the scoring source; there is no second independent advisory, and no exploitation has been reported.