GHSA-2v35-w6hq-6mfw xmldom: Uncontrolled recursion in XML serialization leads to DoS
SummarySeven 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.DetailsRoot 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`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L1296-L1308) (main):Crash threshold (Node.js 18, default stack): ~10,000 levels.`XMLSerializer.serializeToString()` — reported by @Jvr2022[`lib/dom.js:2790–2974`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L2790-L2974) (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 pointsAll 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`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L1529) | `getElementsByTagName()`, `getElementsByTagNameNS()`, `getElementsByClassName()`, `getElementById()` | ~10,000 levels           |
| `cloneNode` (module fn)     | [`lib/dom.js:3037`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L3037) | `Node.prototype.cloneNode(true)`                                                                     | ~10,000 levels           |
| `importNode` (module fn)    | [`lib/dom.js:2975`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L2975) | `Document.prototype.importNode(node, true)`                                                          | ~10,000 levels           |
| `getTextContent` (inner fn) | [`lib/dom.js:3130`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L3130) | `node.textContent` (getter)                                                                          | ~10,000 levels           |
| `isEqualNode`               | [`lib/dom.js:1120`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L1120) | `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 behaviorTested 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)`XMLSerializer.serializeToString()` (from GHSA-2v35-w6hq-6mfw)The other methods have been verified using similar pocs.ImpactAny 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.DisclosureThe `normalize()` vector was publicly disclosed at 2026-04-06T11:25:07Z via
[xmldom/xmldom#987](https://github.com/xmldom/xmldom/pull/987) (closed without merge).
`serializeToString()` and the five additional recursive entry points were not mentioned in that PR.Fix AppliedAll seven affected traversals have been converted from recursive to iterative implementations, eliminating call-stack consumption on deep trees.`walkDOM` utilityA 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 fixAll 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.
CVSS v4.0 score: 8.7