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:
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
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.
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 '' + ''.repeat(depth) + 'text' + ''.repeat(depth) + '';
}
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 = ''.repeat(depth) + ''.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.
Summary
Seven recursive traversals in
lib/dom.jsoperate without a depth limit. A sufficiently deeplynested 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, viaDOMParser.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.jsimplements DOM tree traversals as depth-first recursive functions. Each level ofelement nesting adds one JavaScript call frame. The JS engine's call stack is finite; once
exhausted, a
RangeError: Maximum call stack size exceededis thrown. This error may not becaught reliably at stack-exhaustion depths because the catch handler itself requires stack
frames to execute — especially in async scenarios, where an uncaught
RangeErrorinside acallback or promise chain can crash the entire Node.js process.
Parsing a deeply nested document succeeds — the SAX parser in
lib/sax.jsis iterative.The crash occurs during subsequent operations on the parsed DOM.
Node.prototype.normalize()— reported by @praveen-kvlib/dom.js:1296–1308(main):Crash threshold (Node.js 18, default stack): ~10,000 levels.
XMLSerializer.serializeToString()— reported by @Jvr2022lib/dom.js:2790–2974(main):The internal
serializeToStringworker recurses into child nodes at four call sites, eachpassing a
visibleNamespaces.slice()copy. The per-frame allocation causes earlier stackexhaustion 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 levelsnormalizeuseswalkDOMwith anullcontext and anentercallback that merges adjacent Text children of the current node beforewalkDOMreads and queues those children — so the surviving post-merge children are what the walker descends into.Custom iterative loop for
isEqualNodeOne 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 withwalkDOM's single-tree visitor.After the fix
All seven entry points succeed on trees of arbitrary depth without throwing
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.RangeError. The original PoCs still demonstrate the vulnerability on unpatched versions and confirm the fix on patched versions.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 '' + ''.repeat(depth) + 'text' + ''.repeat(depth) + '';
}
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 = ''.repeat(depth) + ''.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.