Логотип exploitDog
Консоль
Логотип exploitDog

exploitDog

github логотип

GHSA-93r5-fhx6-vmg9

Опубликовано: 08 сент. 2026
Источник: github
Github: Прошло ревью
CVSS4: 8.7

Описание

xmldom: Quadratic-time parsing via the malformed-input recovery path — parseElementStartPart re-scan and normalize() adjacent-text merge

Summary

xmldom's malformed-input error-recovery path has two quadratic-time (O(n²)) behaviors that a single crafted input triggers together, so a tiny, highly compressible document (tens of KB) stalls the Node.js event loop for multiple seconds. It is reachable from DOMParser.parseFromString under default options — i.e. from unauthenticated, network-delivered XML — making this an unauthenticated denial of service. One of the two behaviors, the normalize() adjacent-text merge, is additionally reachable programmatically — via a plain normalize() call on a DOM built with adjacent text nodes, independent of the parser — so its fix must live in normalize(), not only in a parser bound.

Details

Finding A — parseElementStartPart quadratic re-scan

A < character is not a delimiter in any tag-parsing state, so parseElementStartPart scans forward character-by-character over any embedded < until it reaches the next > (or end of input), then validates the accumulated slice as a tag name and throws invalid tagName: on failure. The main loop catches this, reports an error, sets end = -1, and recovers by advancing a single character (appendText(Math.max(tagStart, start) + 1)). With a long run of < and a distant >, each of the O(n) recovery retries performs an O(n) scan plus an O(n) anchored regex validation over the growing candidate ⇒ O(n²).

Code (0.9.x, bb7a085dc5ba1eea3212388509b97bb4b4af32b9):

Code (0.8.x, e5c14802592685bb872c042c54c3f73758875c85):

Finding B — normalize() adjacent-text O(K²) merge

endDocument() calls document.normalize(). For a parent with K adjacent text nodes (produced by the one-character recovery of Finding A), normalize() performs K−1 merges. Each merge does a removeChild — which re-indexes all child nodes of the parent (O(K)) — and an appendData — which rebuilds the accumulator string this.data + text (O(K)). Total: O(K²).

Well-formed XML cannot produce adjacent text-node siblings through the parser (each text run is one node; comments, CDATA, PIs, and elements sit between runs), so the parse-path trigger for Finding B is the malformed-input recovery that emits single-character text nodes. The same O(K²) merge is, however, independently reachable via the public normalize() API on a programmatically built tree (see "Finding B is additionally reachable programmatically" below).

Code (0.9.x, bb7a085dc5ba1eea3212388509b97bb4b4af32b9):

Code (0.8.x, e5c14802592685bb872c042c54c3f73758875c85):

Finding B is additionally reachable programmatically (no parser involved)

Node.prototype.normalize() is public API on every Document/Element. A tree built entirely through the ordinary DOM API — new DOMImplementation().createDocument(...), then K× createTextNode + appendChild on one parent — reaches the same O(K²) merge when the application calls normalize(), with no parsing and no error-recovery. The parser is only one of the two callers of the vulnerable merge:

  • the parser's automatic endDocument()document.normalize() (the parse-path trigger above), and
  • any explicit application call to the public normalize() on a tree with adjacent text nodes.

XMLSerializer does not call normalize(), so serializing an un-merged tree is O(total text), not O(K²); the O(K²) surface is exactly those two normalize() callers. Consequently a parser-side bound alone cannot remediate Finding B — the fix must live in normalize().

Affected Versions

Both findings are present across the full published @xmldom/xmldom history — both currently-maintained versions (0.8.x and 0.9.x) are affected — and across the retired unscoped xmldom line. Finding B's normalize() merge is additionally reachable programmatically: a direct normalize() call on a DOM built with adjacent text nodes hits the same O(K²) merge, independent of the parser — so, unlike Finding A, it does not require the malformed-input recovery path.

Proof of Concept

Default DOMParser, no options. The input is trivially compressible (a< / a<> repeated) and never throws — it is parsed via the recovery path.

const { DOMParser } = require('@xmldom/xmldom'); // Silence the expected `error`-level recovery reports (default handler logs // them to console.error without throwing; only fatalError throws). console.error = function () {}; function timeParse(label, xml, mime) { const t0 = process.hrtime.bigint(); new DOMParser().parseFromString(xml, mime); // completes; no exception 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: long re-scans, O(n^2) during parse. timeParse('A N=' + N, '<r>' + 'a<'.repeat(N) + '</r>', 'text/xml'); // Finding B: short re-scans (cheap parse) but K adjacent text nodes -> O(K^2) in normalize(). timeParse('B N=' + N, '<r>' + 'a<>'.repeat(N) + '</r>', 'text/html'); // Combined: ONE input hits both A and B under the default parser. timeParse('C N=' + N, '<r>' + 'a<'.repeat(N) + '</r>', 'text/xml'); }

Measured on Node v18.20.8 (absolute ms vary by host; the load-bearing fact is that doubling the input ~quadruples the time — canonical O(n²)):

Finding A, isolated ("<r>" + "a<"×N + "</r>", normalize disabled to isolate the re-scan):

Ninput bytes@xmldom/xmldom 0.9.100.8.13
2000400743 ms37 ms
40008007129 ms106 ms
800016007434 ms424 ms
16000320071629 ms1611 ms

Finding B, isolated ("<r>" + "a<>"×N + "</r>", time attributable to normalize()):

K (N)input bytes0.9.100.8.13
400012007120 ms165 ms
800024007589 ms771 ms
16000480073142 ms4448 ms
320009600712127 ms12951 ms

Combined (default parser, both findings; "<r>" + "a<"×N + "</r>"):

Ninput bytes0.9.100.8.13
40008007341 ms397 ms
8000160071894 ms1641 ms
16000320074398 ms7661 ms

~32 KB of input → several seconds of single-threaded event-loop stall.

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')); // K adjacent text nodes const t0 = process.hrtime.bigint(); doc.normalize(); // O(K^2) merge — no parsing involved const ms = Number(process.hrtime.bigint() - t0) / 1e6; console.log('K=' + K + ' time=' + ms.toFixed(1) + ' ms'); } for (const K of [2000, 4000, 8000, 16000, 32000]) timeNormalize(K);

Measured on Node v18.20.8 (doubling K ~quadruples the time — O(K²)):

K0.9.100.8.13
20005.7 ms5.6 ms
320001263 ms1704 ms

This path is reachable by any application that builds a DOM from attacker-influenced data and calls normalize(), entirely independent of DOMParser.

Impact

Availability only: a single parse of a small crafted document blocks the Node.js event loop for the duration of the quadratic work (multiple seconds at tens of KB; larger inputs scale as O(n²)). No memory blow-up beyond transient strings, no data exposure, no integrity impact. Because XML is routinely accepted from untrusted sources and parsed with default options, one request can stall a server. The payloads are highly compressible, so any endpoint accepting compressed XML faces additional amplification. Finding B is additionally reachable via an explicit normalize() call on a programmatically built DOM (see Proof of Concept), so applications that construct a document from attacker-influenced data and normalize it are exposed even without parsing.

Severity note

The complexity is quadratic, not exponential, so a multi-second stall requires tens-to-hundreds of KB of input. VA:H reflects that xmldom applies no input-size limit and the path runs on default-options parsing, so a single unbounded parse can fully stall the event loop.

Fix Applied

Two independent, non-breaking fixes shipped together — each alone leaves the other's quadratic cost dominating the default parse. Finding A — terminate the malformed tag-name scan at an embedded <, so error recovery is linear instead of O(n²). DOM output is unchanged; only the reported error-message text differs (error strings are not a semver contract). Finding B — merge adjacent text nodes in normalize() in O(K) instead of O(K²), which also closes the same slowdown reachable programmatically through a direct normalize() call. Both ship on both maintained versions.

Пакеты

Наименование

@xmldom/xmldom

npm
Затронутые версииВерсия исправления

>= 0.7.0, <= 0.8.14

0.8.15

Наименование

@xmldom/xmldom

npm
Затронутые версииВерсия исправления

>= 0.9.0, <= 0.9.11

0.9.12

Наименование

xmldom

npm
Затронутые версииВерсия исправления

>= 0.3.0, <= 0.6.0

Отсутствует

EPSS

Процентиль: 28%
0.00351
Низкий

8.7 High

CVSS4

Дефекты

CWE-400
CWE-407

Связанные уязвимости

ubuntu
15 дней назад

(xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) ...)

CVSS3: 7.5
redhat
15 дней назад

xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. Prior to @xmldom/xmldom versions 0.8.15 and 0.9.12, and in xmldom versions 0.3.0 through 0.6.0, two independent quadratic paths can cause denial of service. In lib/sax.js, parseElementStartPart repeatedly rescans a malformed tag name to the next > during single-character recovery; in lib/dom.js, normalize() repeatedly removes and appends adjacent text nodes, causing quadratic reindexing and string rebuilding. The first path is reachable through default DOMParser.parseFromString() processing, while the second is also reachable through a direct normalize() call on a programmatically constructed DOM, and endDocument invokes that normalization after parsing. This issue is fixed in @xmldom/xmldom versions 0.8.15 and 0.9.12; no fixed version is available for xmldom.

nvd
15 дней назад

xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. Prior to @xmldom/xmldom versions 0.8.15 and 0.9.12, and in xmldom versions 0.3.0 through 0.6.0, two independent quadratic paths can cause denial of service. In lib/sax.js, parseElementStartPart repeatedly rescans a malformed tag name to the next > during single-character recovery; in lib/dom.js, normalize() repeatedly removes and appends adjacent text nodes, causing quadratic reindexing and string rebuilding. The first path is reachable through default DOMParser.parseFromString() processing, while the second is also reachable through a direct normalize() call on a programmatically constructed DOM, and endDocument invokes that normalization after parsing. This issue is fixed in @xmldom/xmldom versions 0.8.15 and 0.9.12; no fixed version is available for xmldom.

msrc
10 дней назад

xmldom: Quadratic-time parsing via the malformed-input recovery path — `parseElementStartPart` re-scan and `normalize()` adjacent-text merge

debian
15 дней назад

xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) ...

EPSS

Процентиль: 28%
0.00351
Низкий

8.7 High

CVSS4

Дефекты

CWE-400
CWE-407