GHSA-2v35-w6hq-6mfw
xmldom: Uncontrolled recursion in XML serialization leads to DoS
Packages
| Ecosystem | Package | Affected | Fixed in |
|---|---|---|---|
| npm | @xmldom/xmldom | < 0.8.13 | 0.8.13 |
| npm | @xmldom/xmldom | >= 0.9.0, < 0.9.10 | 0.9.10 |
| npm | xmldom | <= 0.6.0 | no fix listed |
Details
As written in the advisory (Markdown source, shown as plain text).
## 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`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L1296-L1308) (main):
```js
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`](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 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`](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 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)
```js
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)
```js
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](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 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.
References
External links from the advisory. They lead to third-party sites.
- https://github.com/xmldom/xmldom/security/advisories/GHSA-2v35-w6hq-6mfw
- https://nvd.nist.gov/vuln/detail/CVE-2026-41673
- https://github.com/xmldom/xmldom/commit/17678a2a73ecbd1a2da90f3d47dc23da9cef81aa
- https://github.com/xmldom/xmldom/commit/291257493cb0eb6980eda83b162a9c4e6d7d2597
- https://github.com/xmldom/xmldom/commit/2d6d6916ed8a4c223db1f6d7560ab4544c465b0f
- https://github.com/xmldom/xmldom/commit/430357c7b6333108856e917bf2367afe5ceb6f8a
- https://github.com/xmldom/xmldom/commit/4845ef109221df0890825de2822fbe77afba3afe
- https://github.com/xmldom/xmldom/commit/8834218c85ac2a4d757b9587c9028e67c2f7b6c3
- https://github.com/xmldom/xmldom/commit/8b7cfd1491314abdc347261921d7334ff15f7112
- https://github.com/xmldom/xmldom/commit/b0620383abc1df067f3ce1014c43ae1bc1161eeb
- https://github.com/xmldom/xmldom/commit/e6edcab6bef5bcdba0b220bb35442aa72f452b84
- https://github.com/xmldom/xmldom
- https://github.com/xmldom/xmldom/releases/tag/0.8.13
- https://github.com/xmldom/xmldom/releases/tag/0.9.10