Skip to content

GHSA-2v35-w6hq-6mfw CVE-2026-41673

xmldom: Uncontrolled recursion in XML serialization leads to DoS

High npm
Severity
0.0 / 10
High · CVSS 4.0
Exploit chance (EPSS)
0.64%
Chance of exploitation in the next 30 days. Higher than 50.82% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-674 Uncontrolled Recursion
Fix
0.8.13
Upgrade @xmldom/xmldom to this version or later

Description

Summary

Seven recursive traversals in lib/dom.js operate without a depth limit. A sufficiently deeply nested DOM tree causes a RangeError: Maximum call stack size exceeded, crashing the application.

Reported operations:

  • Node.prototype.normalize() — reported by @praveen-kv (email 2026-04-05) and @KarimTantawey (GHSA-fwmp-8wwc-qhv6, via DOMParser.parseFromString())
  • XMLSerializer.serializeToString() — reported by @Jvr2022 (GHSA-2v35-w6hq-6mfw) and @KarimTantawey (GHSA-j2hf-fqwf-rrjf)

Additionally, discovered in research:

  • Element.getElementsByTagName() / getElementsByTagNameNS() / getElementsByClassName() / getElementById()
  • Node.cloneNode(true)
  • Document.importNode(node, true)
  • node.textContent (getter)
  • Node.isEqualNode(other)

All seven share the same root cause: pure-JavaScript recursive tree traversal with no depth guard. A single deeply nested document (parsed successfully) triggers any or all of these operations.


Details

Root cause

lib/dom.js implements DOM tree traversals as depth-first recursive functions. Each level of element nesting adds one JavaScript call frame. The JS engine's call stack is finite; once exhausted, a RangeError: Maximum call stack size exceeded is thrown. This error may not be caught reliably at stack-exhaustion depths because the catch handler itself requires stack frames to execute — especially in async scenarios, where an uncaught RangeError inside a callback or promise chain can crash the entire Node.js process.

Parsing a deeply nested document succeeds — the SAX parser in lib/sax.js is iterative. The crash occurs during subsequent operations on the parsed DOM.

Node.prototype.normalize() — reported by @praveen-kv

lib/dom.js:1296–1308 (main):

normalize: function () {
    var child = this.firstChild;
    while (child) {
        var next = child.nextSibling;
        if (next && next.nodeType == TEXT_NODE && child.nodeType == TEXT_NODE) {
            this.removeChild(next);
            child.appendData(next.data);
        } else {
            child.normalize();   // recursive call — no depth guard
            child = next;
        }
    }
},

Crash threshold (Node.js 18, default stack): ~10,000 levels.

XMLSerializer.serializeToString() — reported by @Jvr2022

lib/dom.js:2790–2974 (main): The internal serializeToString worker recurses into child nodes at four call sites, each passing a visibleNamespaces.slice() copy. The per-frame allocation causes earlier stack exhaustion than normalize().

Crash threshold (Node.js 18, default stack): ~5,000 levels.

Additional recursive entry points

All five crash at ~10,000 levels on Node.js 18.

Function Definition Public API entry point(s) Crash depth (Node.js 18)
_visitNode lib/dom.js:1529 getElementsByTagName(), getElementsByTagNameNS(), getElementsByClassName(), getElementById() ~10,000 levels
cloneNode (module fn) lib/dom.js:3037 Node.prototype.cloneNode(true) ~10,000 levels
importNode (module fn) lib/dom.js:2975 Document.prototype.importNode(node, true) ~10,000 levels
getTextContent (inner fn) lib/dom.js:3130 node.textContent (getter) ~10,000 levels
isEqualNode lib/dom.js:1120 Node.prototype.isEqualNode(other) ~10,000 levels

Both active branches (main and release-0.8.x) are identically affected. The unscoped xmldom package (≤ 0.6.0) carries the same recursive patterns from its initial commit.

Browser behavior

Tested with Chromium 147 (Playwright headless). Chromium's native C++ implementations of all seven DOM methods are iterative — they traverse the DOM without consuming JS call stack frames. All seven succeed at depths up to 20,000 without any crash.

When @xmldom/xmldom is bundled and run in a browser context the same recursive JS code executes under the browser's V8 stack limit (~12,000–13,000 frames). The crash thresholds are similar to those observed on Node.js 18 (~5,000 for serializeToString, ~10,000 for the remaining six).

The vulnerability is specific to xmldom's pure-JavaScript recursive implementation, not an inherent property of the DOM operations.


PoC

normalize() (from @praveen-kv report, 2026-04-05)

const { DOMParser } = require('@xmldom/xmldom');

function generateNestedXML(depth) {
    return '<root>' + '<a>'.repeat(depth) + 'text' + '</a>'.repeat(depth) + '</root>';
}

const doc = new DOMParser().parseFromString(generateNestedXML(10000), 'text/xml');
doc.documentElement.normalize();
// RangeError: Maximum call stack size exceeded

XMLSerializer.serializeToString() (from GHSA-2v35-w6hq-6mfw)

const { DOMParser, XMLSerializer } = require('@xmldom/xmldom');

const depth = 5000;
const xml = '<a>'.repeat(depth) + '</a>'.repeat(depth);
const doc = new DOMParser().parseFromString(xml, 'text/xml');
new XMLSerializer().serializeToString(doc);
// RangeError: Maximum call stack size exceeded

The other methods have been verified using similar pocs.


Impact

Any service that accepts attacker-controlled XML and subsequently calls any of the seven affected DOM operations can be forced into a reliable denial of service with a single crafted payload.

The immediate result is an uncaught RangeError and failed request processing. In deployments where uncaught exceptions terminate the worker or process, the impact can extend beyond a single request and disrupt service availability more broadly.

No authentication, special options, or invalid XML is required. A valid, deeply nested XML document is enough.


Disclosure

The normalize() vector was publicly disclosed at 2026-04-06T11:25:07Z via xmldom/xmldom#987 (closed without merge). serializeToString() and the five additional recursive entry points were not mentioned in that PR.


Fix Applied

All seven affected traversals have been converted from recursive to iterative implementations, eliminating call-stack consumption on deep trees.

walkDOM utility

A new walkDOM(node, context, callbacks) utility is introduced. It traverses the subtree rooted at node in depth-first order using an explicit JavaScript array as a stack, consuming heap memory instead of call-stack frames. context is an arbitrary value threaded through the walk — each callbacks.enter(node, context) call returns the context to pass to that node's children, enabling per-branch state (e.g. namespace snapshots in the serializer). callbacks.exit(node, context) (optional) is called in post-order after all children have been visited.

The following six operations are re-implemented on top of walkDOM:

Operation Public entry point(s)
_visitNode helper getElementsByTagName(), getElementsByTagNameNS(), getElementsByClassName(), getElementById()
getTextContent inner function node.textContent getter
cloneNode module function Node.prototype.cloneNode(true)
importNode module function Document.prototype.importNode(node, true)
serializeToString worker XMLSerializer.prototype.serializeToString(), Node.prototype.toString(), NodeList.prototype.toString()
normalize Node.prototype.normalize()

normalize uses walkDOM with a null context and an enter callback that merges adjacent Text children of the current node before walkDOM reads and queues those children — so the surviving post-merge children are what the walker descends into.

Custom iterative loop for isEqualNode

One function cannot use walkDOM:

Node.prototype.isEqualNode(other) (0.9.x only; absent from 0.8.x) compares two trees in parallel. It maintains an explicit stack of {node, other} node pairs — one node from each tree — which cannot be expressed with walkDOM's single-tree visitor.

After the fix

All seven entry points succeed on trees of arbitrary depth without throwing RangeError. The original PoCs still demonstrate the vulnerability on unpatched versions and confirm the fix on patched versions.

Affected versions

npm

PackageVulnerableFixed in
@xmldom/xmldom
< 0.8.13
0.8.13
@xmldom/xmldom
>= 0.9.0, < 0.9.10
0.9.10
xmldom
<= 0.6.0
No fix yet

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

@xmldom/xmldom npm → 0.8.13

npm
npm install @xmldom/xmldom@0.8.13
yarn
yarn add @xmldom/xmldom@0.8.13
pnpm
pnpm add @xmldom/xmldom@0.8.13

Severity breakdown

CVSS 4.0 0.0

CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

Attack vector
Network
Attack complexity
Low
Attack requirements
None
Privileges required
None
User interaction
None
Confidentiality (vulnerable system)
None
Integrity (vulnerable system)
None
Availability (vulnerable system)
High
Confidentiality (subsequent systems)
None
Integrity (subsequent systems)
None
Availability (subsequent systems)
None

CVSS 3.1 7.5

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High

Weakness

CWE-674: Uncontrolled Recursion

The product does not properly control the amount of recursion that takes place, consuming excessive resources, such as allocated memory or the program stack.

How to prevent it
  • Implementation: Ensure that an end condition will be reached under all logic conditions. The end condition may include checking against the depth of recursion and exiting with an error if the recursion goes too deep. The complexity of the end condition contributes to the effectiveness of this action.
  • Implementation: Increase the stack size.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-x4fp-j954-r2f4
xmldom: End-tag Whitespace-Trim Regex ReDoS — quadratic backtracking in the 0.8.x end-tag parser
High Also affects @xmldom/xmldom
GHSA-vr34-hp96-76pp
xmldom: requireWellFormed DocType publicId/systemId validation is bypassable via an embedded line terminator
High Also affects @xmldom/xmldom
GHSA-jxjr-3g7g-3944
xmldom: requireWellFormed element/attribute name validation is bypassable via an embedded line terminator
High Also affects @xmldom/xmldom
GHSA-c7q8-3ch8-vqpv
xmldom: Processing Instruction Target Injection Bypasses requireWellFormed
High Also affects xmldom
GHSA-965w-775f-mr7g
xmldom: Quadratic-memory consumption
High Also affects xmldom
GHSA-93r5-fhx6-vmg9
xmldom: Quadratic-time parsing via the malformed-input recovery path — `parseElementStartPart` re-scan and `normalize()` adjacent-text merge
High Also affects xmldom
GHSA-8344-3jmq-59r6
xmldom: Quadratic-time attribute deduplication
High Also affects xmldom
GHSA-6mj3-qw4j-hgrw
xmldom: HTML raw-text closing-tag case mismatch causes output amplification
High Also affects @xmldom/xmldom
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor