GitHub Security Advisory: GHSA-2v35-w6hq-6mfw
Package: @xmldom/xmldom (NPM)
Severity: HIGH (CVSS 0.0)
CWE: CWE-674: Uncontrolled Recursion
Vulnerable versions: >= 0.9.0, < 0.9.10
Patched in: 0.9.10

Summary: xmldom: Uncontrolled recursion in XML serialization leads to DoS

Details:
## 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/9ef2fd297ca527a05ecb[PII_REDACTED]a927cd20c/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/9ef2fd297ca527a05ecb[PII_REDACTED]a927cd20c/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