Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
📦 npm

GHSA-2v35-w6hq-6mfw

Fix: xmldom/xmldom@17678a2

GHSA-2v35-w6hq-6mfw is a security vulnerability in @xmldom/xmldom. O3 Security confirms whether GHSA-2v35-w6hq-6mfw is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.

xmldom: Uncontrolled recursion in XML serialization leads to DoS

Also known asCVE-2026-41673
Published
Apr 22, 2026
Updated
May 8, 2026
Affected
3 pkgs
Patched
2 / 3
Exploits
None indexed

Real-World Exposure

3 pkgs affected
📦@xmldom/xmldom📦@xmldom/xmldom📦xmldom

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects npm packages — download data is not available via public APIs for these ecosystems.

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.

FunctionDefinitionPublic API entry point(s)Crash depth (Node.js 18)
_visitNodelib/dom.js:1529getElementsByTagName(), getElementsByTagNameNS(), getElementsByClassName(), getElementById()~10,000 levels
cloneNode (module fn)lib/dom.js:3037Node.prototype.cloneNode(true)~10,000 levels
importNode (module fn)lib/dom.js:2975Document.prototype.importNode(node, true)~10,000 levels
getTextContent (inner fn)lib/dom.js:3130node.textContent (getter)~10,000 levels
isEqualNodelib/dom.js:1120Node.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:

OperationPublic entry point(s)
_visitNode helpergetElementsByTagName(), getElementsByTagNameNS(), getElementsByClassName(), getElementById()
getTextContent inner functionnode.textContent getter
cloneNode module functionNode.prototype.cloneNode(true)
importNode module functionDocument.prototype.importNode(node, true)
serializeToString workerXMLSerializer.prototype.serializeToString(), Node.prototype.toString(), NodeList.prototype.toString()
normalizeNode.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 Packages

3 total 2 fixed
EcosystemPackageVulnerable rangeFix
📦npm@xmldom/xmldomall versions0.8.13
📦npm@xmldom/xmldom0.9.0&&< 0.9.100.9.10
📦npmxmldomall versionsNo fix

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for @xmldom/xmldom. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Fix

    Update @xmldom/xmldom to 0.8.13 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-2v35-w6hq-6mfw is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 pinpoints whether GHSA-2v35-w6hq-6mfw is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-2v35-w6hq-6mfw. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

## 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()` / `getElement
O3 Security · Impact-Aware SCA

Is GHSA-2v35-w6hq-6mfw in your dependencies?

O3 detects GHSA-2v35-w6hq-6mfw across npm dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.