GHSA-39M8-27WV-HR27
Vulnerability from github – Published: 2026-10-22 16:07 – Updated: 2026-10-22 16:07Summary
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
- 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.
- 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.
- Path: Signing pipes the composed message through the DKIM MessageParser;
checkHeadersbuffers the whole header block intorawHeadersand callsparseHeaders(), the single sink, on every signed message. - 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.
- 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.
- 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: npmPackage: nodemailerConfirmed affected range: <= 10.0.9Latest 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).
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 10.0.9"
},
"package": {
"ecosystem": "npm",
"name": "nodemailer"
},
"ranges": [
{
"events": [
{
"introduced": "3.0.0"
},
{
"fixed": "10.0.10"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-110871"
],
"database_specific": {
"cwe_ids": [
"CWE-400",
"CWE-407"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-22T16:07:04Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nWhen 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.\n\n## Root Cause\n\nThe 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] += \u0027\\n\u0027 + 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\u0027s 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\u0027s `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.\n\n```text\nAffected file and function (verified at the published v10.0.9 artifact, in src/ and compiled verbatim into the published dist output):\n- src/dkim/message-parser.ts: MessageParser.parseHeaders (the continuation-unfold loop that re-tests /^[ \\t]/ against the growing merged line)\n```\n\n## Impact\n\nAn 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\u0027s 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.\n\n## Proof of Concept\n\n`Node.js 24; the shipped DKIM signer (require(\u0027nodemailer/lib/dkim\u0027)) or the exact unfold loop.`\n\nSteps: (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\u0027s 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:\n\n```js\n// Reproduces the parseHeaders unfold loop verbatim (Node 24).\nfunction unfold(headerBlock) {\n const lines = headerBlock.split(/\\r?\\n/);\n for (let i = lines.length - 1; i \u003e 0; i--) {\n if (/^[ \\t]/.test(lines[i])) {\n lines[i - 1] += \u0027\\n\u0027 + lines[i];\n lines.splice(i, 1);\n }\n }\n return lines;\n}\nfunction run(n) {\n let block = \u0027To: a@b.c\\r\\n\u0027;\n for (let i = 0; i \u003c n; i++) block += \u0027 cont-\u0027 + i + \u0027@example.com,\\r\\n\u0027;\n for (let k = 0; k \u003c 5; k++) block += \u0027X-H\u0027 + k + \u0027: v\\r\\n\u0027;\n const t = Date.now();\n unfold(block);\n return Date.now() - t;\n}\nfor (const n of [5000, 10000, 20000, 40000]) console.log(n, run(n) + \u0027ms\u0027);\n```\n\n```text\n5000 27ms\n10000 542ms\n20000 2853ms\n40000 11503ms\n(2x input yields roughly 4x-5x time: quadratic. The cost is the regex re-flatten of the growing merged line, not the splice.)\n```\n\n## Attack Chain\n\n1. **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.\n2. **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.\n3. **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.\n4. **Guard:** No header-size or line-count cap exists between the input and the sink; the signer\u0027s body-only size thresholds do not apply to the header block.\n5. **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.\n6. **Result:** Event-loop starvation of the single-threaded sending process for an attacker-scaled duration, repeatable per message: a denial of service.\n\n## Bypass Evidence\n\nThe 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.\n\n## Affected Versions\n\n- `Ecosystem: npm`\n- `Package: nodemailer`\n- `Confirmed affected range: \u003c= 10.0.9`\n- `Latest release checked: nodemailer 10.0.9 (npm registry; vulnerable code verified at git tag v10.0.9 and in the published dist output)`\n- `Fix status: not fixed (no released version removes the quadratic unfold or caps header size)`\n\nThe 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.\n\n## Suggested Fix\n\nEnforce linear-time header unfolding and bound the header block. The key correction is to stop re-scanning the growing accumulated line: classify each line\u0027s 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.\n\nReported by zx (GitHub: @manus-pi).",
"id": "GHSA-39m8-27wv-hr27",
"modified": "2026-10-22T16:07:05Z",
"published": "2026-10-22T16:07:04Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/nodemailer/nodemailer/security/advisories/GHSA-39m8-27wv-hr27"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-110871"
},
{
"type": "WEB",
"url": "https://github.com/nodemailer/nodemailer/commit/28a5909cec27646cb001dc7e97a8f5d26c973078"
},
{
"type": "PACKAGE",
"url": "https://github.com/nodemailer/nodemailer"
},
{
"type": "WEB",
"url": "https://github.com/nodemailer/nodemailer/releases/tag/v10.0.10"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/nodemailer-3.0.0-before-10.0.10-quadratic-dkim-header-unfolding-denial-of-service"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "Nodemailer: Quadratic (O(n^2)) DKIM header unfolding lets a large folded header cause remote denial of service during signing"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.