VYPR
High severityGHSA Advisory· Published May 7, 2026· Updated May 7, 2026

CVE-2026-41673

CVE-2026-41673

Description

xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. In @xmldom/xmldom prior to versions 0.9.10 and 0.8.13 and xmldom version 0.6.0 and prior, 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. This issue has been patched in versions @xmldom/xmldom versions 0.9.10 and 0.8.13.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@xmldom/xmldomnpm
< 0.8.130.8.13
@xmldom/xmldomnpm
>= 0.9.0, < 0.9.100.9.10
xmldomnpm
<= 0.6.0

Affected products

1

Patches

9
4845ef109221

fix: prevent stack overflow in isEqualNode (GHSA-2v35-w6hq-6mfw)

https://github.com/xmldom/xmldomkarfauApr 18, 2026via ghsa
4 files changed · +87 43
  • docs/walk-dom.md+23 0 modified
    @@ -117,3 +117,26 @@ current child list are never queued and therefore never visited. Neither
     Returning `walkDOM.STOP` from `enter` causes the function to return `STOP`
     immediately, discarding the rest of the stack. No further `enter` or `exit`
     calls are made — including any pending `EXIT` frames for ancestors.
    +
    +## When not to use `walkDOM`
    +
    +`walkDOM` visits a **single** tree and threads a single `context` value from
    +parent to children. This model breaks down when an algorithm must walk **two
    +trees in lockstep**, pairing corresponding nodes at each level.
    +
    +`Node.prototype.isEqualNode` is the canonical example. Equality requires
    +comparing `childNodes[0]` from tree A with `childNodes[0]` from tree B,
    +`childNodes[1]` with `childNodes[1]`, and so on — all the way down. There is
    +no way to encode "which node in the other tree corresponds to this one" as a
    +`context` value that `walkDOM` can thread, because `walkDOM` only knows about
    +one tree.
    +
    +Instead, `isEqualNode` maintains its own explicit stack of `{ node, other }`
    +pairs. The initial pair is `{ node: this, other: otherNode }`. For each pair
    +popped, the type-specific equality checks are applied and — if the child
    +counts match — each `{ node: childNodes[i], other: other.childNodes[i] }` pair
    +is pushed. Attribute nodes of each Element pair are pushed the same way:
    +`getAttributeNodeNS` looks up the matching attribute by namespace and local
    +name, and if none is found the comparison fails immediately. This produces
    +the same DFS order as the old recursive implementation without using the call
    +stack.
    
  • index.d.ts+6 1 modified
    @@ -500,7 +500,12 @@ declare module '@xmldom/xmldom' {
     		/**
     		 * Checks whether the given node is equal to this node.
     		 *
    -		 * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Node/isEqualNode)
    +		 * Two nodes are equal when they have the same type, defining characteristics (for the type),
    +		 * and the same `childNodes`. The comparison is iterative to avoid stack overflows on deeply
    +		 * nested trees. `Attribute` nodes of each `Element` pair are also compared iteratively.
    +		 *
    +		 * @see {@link https://dom.spec.whatwg.org/#concept-node-equals}
    +		 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Node/isEqualNode}
     		 */
     		isEqualNode(other: Node): boolean;
     
    
  • lib/dom.js+53 42 modified
    @@ -1141,57 +1141,68 @@ Node.prototype = {
     	/**
     	 * Checks whether the given node is equal to this node.
     	 *
    +	 * Two nodes are equal when they have the same type, defining characteristics (for the type),
    +	 * and the same childNodes. The comparison is iterative to avoid stack overflows on
    +	 * deeply-nested trees. Attribute nodes of each Element pair are also pushed onto the stack
    +	 * and compared the same way.
    +	 *
     	 * @param {Node} [otherNode]
    +	 * @returns {boolean}
     	 * @see https://dom.spec.whatwg.org/#concept-node-equals
    +	 * @see ../docs/walk-dom.md.
     	 */
     	isEqualNode: function (otherNode) {
     		if (!otherNode) return false;
     
    -		if (this.nodeType !== otherNode.nodeType) return false;
    -
    -		switch (this.nodeType) {
    -			case this.DOCUMENT_TYPE_NODE:
    -				if (this.name !== otherNode.name) return false;
    -				if (this.publicId !== otherNode.publicId) return false;
    -				if (this.systemId !== otherNode.systemId) return false;
    -				break;
    -			case this.ELEMENT_NODE:
    -				if (this.namespaceURI !== otherNode.namespaceURI) return false;
    -				if (this.prefix !== otherNode.prefix) return false;
    -				if (this.localName !== otherNode.localName) return false;
    -				if (this.attributes.length !== otherNode.attributes.length) return false;
    -				for (var i = 0; i < this.attributes.length; i++) {
    -					var attr = this.attributes.item(i);
    -					if (!attr.isEqualNode(otherNode.getAttributeNodeNS(attr.namespaceURI, attr.localName))) {
    -						return false;
    +		// Use an explicit {node, other} pair stack to avoid call-stack overflow on deep trees.
    +		// walkDOM cannot be used here — parallel two-tree traversal requires pairing
    +		// corresponding nodes at each step across both trees simultaneously.
    +		var stack = [{ node: this, other: otherNode }];
    +		while (stack.length > 0) {
    +			var pair = stack.pop();
    +			var node = pair.node;
    +			var other = pair.other;
    +
    +			if (node.nodeType !== other.nodeType) return false;
    +
    +			switch (node.nodeType) {
    +				case node.DOCUMENT_TYPE_NODE:
    +					if (node.name !== other.name) return false;
    +					if (node.publicId !== other.publicId) return false;
    +					if (node.systemId !== other.systemId) return false;
    +					break;
    +				case node.ELEMENT_NODE:
    +					if (node.namespaceURI !== other.namespaceURI) return false;
    +					if (node.prefix !== other.prefix) return false;
    +					if (node.localName !== other.localName) return false;
    +					if (node.attributes.length !== other.attributes.length) return false;
    +					for (var i = 0; i < node.attributes.length; i++) {
    +						var attr = node.attributes.item(i);
    +						var otherAttr = other.getAttributeNodeNS(attr.namespaceURI, attr.localName);
    +						if (!otherAttr) return false;
    +						stack.push({ node: attr, other: otherAttr });
     					}
    -				}
    -				break;
    -			case this.ATTRIBUTE_NODE:
    -				if (this.namespaceURI !== otherNode.namespaceURI) return false;
    -				if (this.localName !== otherNode.localName) return false;
    -				if (this.value !== otherNode.value) return false;
    -
    -				break;
    -			case this.PROCESSING_INSTRUCTION_NODE:
    -				if (this.target !== otherNode.target || this.data !== otherNode.data) {
    -					return false;
    -				}
    -				break;
    -			case this.TEXT_NODE:
    -			case this.CDATA_SECTION_NODE:
    -			case this.COMMENT_NODE:
    -				if (this.data !== otherNode.data) return false;
    -				break;
    -		}
    +					break;
    +				case node.ATTRIBUTE_NODE:
    +					if (node.namespaceURI !== other.namespaceURI) return false;
    +					if (node.localName !== other.localName) return false;
    +					if (node.value !== other.value) return false;
    +					break;
    +				case node.PROCESSING_INSTRUCTION_NODE:
    +					if (node.target !== other.target || node.data !== other.data) return false;
    +					break;
    +				case node.TEXT_NODE:
    +				case node.CDATA_SECTION_NODE:
    +				case node.COMMENT_NODE:
    +					if (node.data !== other.data) return false;
    +					break;
    +			}
     
    -		if (this.childNodes.length !== otherNode.childNodes.length) {
    -			return false;
    -		}
    +			if (node.childNodes.length !== other.childNodes.length) return false;
     
    -		for (var i = 0; i < this.childNodes.length; i++) {
    -			if (!this.childNodes[i].isEqualNode(otherNode.childNodes[i])) {
    -				return false;
    +			// Push children in reverse order so index 0 is processed first (LIFO).
    +			for (var i = node.childNodes.length - 1; i >= 0; i--) {
    +				stack.push({ node: node.childNodes[i], other: other.childNodes[i] });
     			}
     		}
     
    
  • test/recursion-regression.test.js+5 0 modified
    @@ -69,4 +69,9 @@ describe('deep tree stack overflow guard (GHSA-2v35-w6hq-6mfw)', () => {
     	test('normalize', () => {
     		expect(() => deepRoot.normalize()).not.toThrow();
     	});
    +	test('isEqualNode', () => {
    +		// cloneNode is already iterative — use it to build the matching second tree
    +		const deepClone = deepRoot.cloneNode(true);
    +		expect(() => deepRoot.isEqualNode(deepClone)).not.toThrow();
    +	});
     });
    
430357c7b633

fix: prevent stack overflow in normalize (GHSA-2v35-w6hq-6mfw)

https://github.com/xmldom/xmldomkarfauApr 14, 2026via ghsa
3 files changed · +26 12
  • docs/walk-dom.md+2 0 modified
    @@ -104,6 +104,8 @@ supported. Because `lastChild` and `previousSibling` are read after `enter` retu
     children added or removed there are correctly reflected when the walker
     schedules the next level of frames.
     
    +An example of that is the `Node.prototype.normalize` method.
    +
     Mutating anything else — siblings of the current node, ancestors, or unrelated
     subtrees — produces unpredictable results. Nodes already queued on the stack
     are visited regardless of subsequent DOM changes; nodes inserted outside the
    
  • lib/dom.js+21 12 modified
    @@ -1310,7 +1310,7 @@ Node.prototype = {
     	 * is `TEXT_NODE`) into a single node with the combined data. It also removes any empty text
     	 * nodes.
     	 *
    -	 * This method operates recursively, so it also normalizes any and all descendent nodes within
    +	 * This method iterativly traverses all child nodes to normalize all descendent nodes within
     	 * the subtree.
     	 *
     	 * @throws {DOMException}
    @@ -1319,19 +1319,28 @@ Node.prototype = {
     	 * @since Modified in DOM Level 2
     	 * @see {@link Node.removeChild}
     	 * @see {@link CharacterData.appendData}
    +	 * @see ../docs/walk-dom.md.
     	 */
     	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();
    -				child = next;
    -			}
    -		}
    +		walkDOM(this, null, {
    +			enter: function (node) {
    +				// Merge adjacent text children of node before walkDOM schedules them.
    +				// walkDOM reads lastChild/previousSibling after enter returns, so the
    +				// surviving post-merge children are what it descends into.
    +				var child = node.firstChild;
    +				while (child) {
    +					var next = child.nextSibling;
    +					if (next !== null && next.nodeType === TEXT_NODE && child.nodeType === TEXT_NODE) {
    +						node.removeChild(next);
    +						child.appendData(next.data);
    +						// Do not advance child: re-check new nextSibling for another text run
    +					} else {
    +						child = next;
    +					}
    +				}
    +				return true; // descend into surviving children
    +			},
    +		});
     	},
     	/**
     	 * Checks whether the DOM implementation implements a specific feature and its version.
    
  • test/recursion-regression.test.js+3 0 modified
    @@ -66,4 +66,7 @@ describe('deep tree stack overflow guard (GHSA-2v35-w6hq-6mfw)', () => {
     		const destDoc = new DOMImplementation().createDocument(null, 'dest');
     		expect(() => destDoc.importNode(deepRoot, true)).not.toThrow();
     	});
    +	test('normalize', () => {
    +		expect(() => deepRoot.normalize()).not.toThrow();
    +	});
     });
    
e6edcab6bef5

fix: make walkDOM iterative to prevent stack overflow (GHSA-2v35-w6hq-6mfw)

https://github.com/xmldom/xmldomkarfauApr 13, 2026via ghsa
6 files changed · +245 17
  • docs/walk-dom.md+117 0 added
    @@ -0,0 +1,117 @@
    +# `walkDOM` — iterative tree traversal
    +
    +**Source**: [`lib/dom.js`](../lib/dom.js)
    +
    +`walkDOM` visits every node in a DOM subtree in depth-first order, calling
    +caller-supplied `enter` and `exit` callbacks. It uses an explicit stack instead
    +of recursion, so it is safe on arbitrarily deep trees.
    +
    +## Stack frame
    +
    +Each item on the work stack is a frame with three fields:
    +
    +| Field     | Purpose                                              |
    +|-----------|------------------------------------------------------|
    +| `node`    | The DOM node to process                              |
    +| `context` | Opaque value threaded through the traversal          |
    +| `phase`   | `ENTER` — call `enter`, schedule children and exit  |
    +|           | `EXIT`  — call `exit` for the matching `enter`       |
    +
    +Each frame starts its life as `ENTER` and, after being processed, causes an
    +`EXIT` frame for the same node to be pushed. The `EXIT` frame fires only after
    +all descendants have been fully processed — which is exactly the post-order
    +guarantee.
    +
    +## Control flow
    +
    +```mermaid
    +flowchart TD
    +    start([push ENTER frame for root]) --> loop
    +
    +    loop{stack empty?} -- no --> pop[pop frame]
    +    loop -- yes --> done([return])
    +
    +    pop --> phase{frame.phase}
    +
    +    phase -- ENTER --> callEnter["childContext = enter(node, context)"]
    +    callEnter --> check{childContext?}
    +
    +    phase -- EXIT --> callExit["call exit(node, context)\nif provided"]
    +    callExit --> loop
    +
    +    check -- STOP --> ret([return STOP])
    +    check -- otherwise --> pushExit["push EXIT frame\n{node, childContext}"]
    +    pushExit --> descend{childContext\nnull or undefined?}
    +    descend -- yes\nskip children --> loop
    +    descend -- no --> pushChildren["push ENTER frames\nfrom lastChild via previousSibling\n(firstChild lands on top)"]
    +    pushChildren --> loop
    +```
    +
    +`EXIT` is always pushed — even when children are skipped — so `exit` is called
    +once for every `enter`, symmetric and unconditional.
    +
    +## Why pushing EXIT before children guarantees post-order
    +
    +The stack is last-in-first-out. When an `ENTER` frame is processed, the following are
    +pushed in this order:
    +
    +1. `EXIT` frame for the current node
    +2. `ENTER` frames for children, traversed from `lastChild` via `previousSibling` — so `firstChild` lands on top and is processed first
    +
    +Because children are on top, they are processed first. The parent's `EXIT`
    +frame sits below all of them and fires only after the last descendant finishes.
    +
    +### Stack trace
    +
    +```xml
    +<root>
    +  <A>
    +    <A1/>
    +  </A>
    +  <B/>
    +</root>
    +```
    +
    +| Step | Actions                                                                                         | Stack after <br/>(list order, last item = next to pop)          |
    +|------|-------------------------------------------------------------------------------------------------|-----------------------------------------------------------------|
    +| init | push `ENTER(root)`                                                                              | `ENTER(root)`                                                   |
    +| 1    | pop `ENTER(root)`,<br/> `enter(root)`,<br/> push `EXIT(root)`, `ENTER(B)`, `ENTER(A)` | `EXIT(root)`,<br/> `ENTER(B)`,<br/> `ENTER(A)`                  |
    +| 2    | pop `ENTER(A)`,<br/> `enter(A)`,<br/> push `EXIT(A)`, `ENTER(A1)`                          | `EXIT(root)`,<br/> `ENTER(B)`,<br/> `EXIT(A)`,<br/> `ENTER(A1)` |
    +| 3    | pop `ENTER(A1)`,<br/> `enter(A1)`,<br/> push `EXIT(A1)`                                         | `EXIT(root)`,<br/> `ENTER(B)`,<br/> `EXIT(A)`,<br/> `EXIT(A1)`  |
    +| 4    | pop `EXIT(A1)`,<br/> `exit(A1)`                                                                 | `EXIT(root)`,<br/> `ENTER(B)`,<br/> `EXIT(A)`                   |
    +| 5    | pop `EXIT(A)`,<br/> `exit(A)`                                                                   | `EXIT(root)`,<br/> `ENTER(B)`                                   |
    +| 6    | pop `ENTER(B)`,<br/> `enter(B)`,<br/> push `EXIT(B)`                                            | `EXIT(root)`,<br/> `EXIT(B)`                                    |
    +| 7    | pop `EXIT(B)`,<br/> `exit(B)`                                                                   | `EXIT(root)`                                                    |
    +| 8    | pop `EXIT(root)`,<br/> `exit(root)`                                                             | _(empty)_                                                       |
    +
    +Call order: `enter(root)` → `enter(A)` → `enter(A1)` → `exit(A1)` → `exit(A)` → `enter(B)` → `exit(B)` → `exit(root)`.
    +
    +## Context isolation
    +
    +The value returned by `enter` becomes `childContext`. It is stored in:
    +
    +- the `EXIT` frame, so `exit` receives exactly what `enter` returned
    +- all children's `ENTER` frames, so each child starts with the parent's output
    +
    +Siblings share the same `childContext` reference from their common parent.
    +Callers that need per-element isolation must produce a fresh value inside
    +`enter` (e.g. `namespaces.slice()` in `serializeToString`).
    +
    +## DOM mutation during traversal
    +
    +Only mutations to a node's **own children** inside its `enter` callback are
    +supported. Because `lastChild` and `previousSibling` are read after `enter` returns, any
    +children added or removed there are correctly reflected when the walker
    +schedules the next level of frames.
    +
    +Mutating anything else — siblings of the current node, ancestors, or unrelated
    +subtrees — produces unpredictable results. Nodes already queued on the stack
    +are visited regardless of subsequent DOM changes; nodes inserted outside the
    +current child list are never queued and therefore never visited. Neither
    +`enter` nor `exit` is guaranteed to be called for such nodes.
    +
    +## `walkDOM.STOP`
    +
    +Returning `walkDOM.STOP` from `enter` causes the function to return `STOP`
    +immediately, discarding the rest of the stack. No further `enter` or `exit`
    +calls are made — including any pending `EXIT` frames for ancestors.
    
  • lib/dom.js+49 17 modified
    @@ -1578,40 +1578,58 @@ function _visitNode(node, callback) {
      * sibling traversal continues normally.
      * 3. If `enter` returns `walkDOM.STOP`, the entire traversal is aborted immediately — no
      * further `enter` or `exit` calls are made.
    - * 4. `firstChild` is read **after** `enter` returns, so `enter` may safely modify the node's
    - * child list before the walker descends.
    + * 4. `lastChild` and `previousSibling` are read **after** `enter` returns, so `enter` may
    + * safely modify the node's own child list before the walker descends. Modifying siblings of
    + * the current node or any other part of the tree produces unpredictable results: nodes already
    + * queued on the stack are visited regardless of DOM changes, and newly inserted nodes outside
    + * the current child list are never visited.
      * 5. Calls `callbacks.exit(node, context)` (if provided) after all of a node's children have
      * been visited, passing the same `context` that `enter`
      * returned for that node.
      *
    - * Note: this implementation is recursive. It will be converted to an iterative form in a later
    - * step to eliminate stack-overflow risk on very deep trees.
    + * This implementation uses an explicit stack and does not recurse — it is safe on arbitrarily
    + * deep trees.
      *
      * @param {Node} node
      * Root of the subtree to walk.
      * @param {*} context
      * Initial context value passed to the root node's `enter`.
      * @param {{ enter: function(Node, *): *, exit?: function(Node, *): void }} callbacks
      * @returns {void | walkDOM.STOP}
    + * @see ../docs/walk-dom.md.
      */
     function walkDOM(node, context, callbacks) {
    -	var childContext = callbacks.enter(node, context);
    -	if (childContext === walkDOM.STOP) {
    -		return walkDOM.STOP;
    -	}
    -	if (childContext !== null && childContext !== undefined) {
    -		var child = node.firstChild;
    -		while (child) {
    -			var next = child.nextSibling;
    -			if (walkDOM(child, childContext, callbacks) === walkDOM.STOP) {
    +	// Each stack frame is {node, context, phase}:
    +	//   walkDOM.ENTER — call enter, then push children
    +	//   walkDOM.EXIT  — call exit
    +	var stack = [{ node: node, context: context, phase: walkDOM.ENTER }];
    +	while (stack.length > 0) {
    +		var frame = stack.pop();
    +		if (frame.phase === walkDOM.ENTER) {
    +			var childContext = callbacks.enter(frame.node, frame.context);
    +			if (childContext === walkDOM.STOP) {
     				return walkDOM.STOP;
     			}
    -			child = next;
    +			// Push exit frame before children so it fires after all children are processed (Last In First Out)
    +			stack.push({ node: frame.node, context: childContext, phase: walkDOM.EXIT });
    +			if (childContext === null || childContext === undefined) {
    +				continue; // skip children
    +			}
    +			// lastChild is read after enter returns, so enter may modify the child list.
    +			var child = frame.node.lastChild;
    +			// Traverse from lastChild backwards so that pushing onto the stack
    +			// naturally yields firstChild on top (processed first).
    +			while (child) {
    +				stack.push({ node: child, context: childContext, phase: walkDOM.ENTER });
    +				child = child.previousSibling;
    +			}
    +		} else {
    +			// frame.phase === walkDOM.EXIT
    +			if (callbacks.exit) {
    +				callbacks.exit(frame.node, frame.context);
    +			}
     		}
     	}
    -	if (callbacks.exit) {
    -		callbacks.exit(node, childContext);
    -	}
     }
     
     /**
    @@ -1621,6 +1639,20 @@ function walkDOM(node, context, callbacks) {
      * @type {symbol}
      */
     walkDOM.STOP = Symbol('walkDOM.STOP');
    +/**
    + * Phase constant for a stack frame that has not yet been visited.
    + * The `enter` callback is called and children are scheduled.
    + *
    + * @type {number}
    + */
    +walkDOM.ENTER = 0;
    +/**
    + * Phase constant for a stack frame whose subtree has been fully visited.
    + * The `exit` callback is called.
    + *
    + * @type {number}
    + */
    +walkDOM.EXIT = 1;
     
     /**
      * @typedef DocumentOptions
    
  • test/dom/document.test.js+11 0 modified
    @@ -197,6 +197,17 @@ describe('Document.prototype', () => {
     			expect(entries[0].nodeName).toBe('entry');
     			expect(feed.documentElement.childNodes.item(0).nodeName).toBe('entry');
     		});
    +		test('getElementsByTagName on a 10,100-depth tree succeeds without throwing RangeError (GHSA-2v35-w6hq-6mfw)', () => {
    +			const impl = new DOMImplementation();
    +			const doc = impl.createDocument(null, 'root');
    +			let current = doc.documentElement;
    +			for (let i = 0; i < 10100; i++) {
    +				const child = doc.createElement('n');
    +				current.appendChild(child);
    +				current = child;
    +			}
    +			expect(() => doc.documentElement.getElementsByTagName('n')).not.toThrow();
    +		});
     	});
     	test('getElementsByTagNameNS', () => {
     		const doc = new DOMParser().parseFromString(
    
  • test/dom/node.test.js+35 0 modified
    @@ -362,6 +362,16 @@ describe('Node.prototype', () => {
     			expect(clone.childNodes.length).toBe(2);
     			expect(clone.firstChild.nodeValue).toBe('a');
     		});
    +		test('cloneNode(true) on a 10,100-depth tree succeeds without throwing RangeError (GHSA-2v35-w6hq-6mfw)', () => {
    +			const root = doc.createElement('root');
    +			let current = root;
    +			for (let i = 0; i < 10100; i++) {
    +				const child = doc.createElement('n');
    +				current.appendChild(child);
    +				current = child;
    +			}
    +			expect(() => root.cloneNode(true)).not.toThrow();
    +		});
     	});
     	describe('isSameNode', () => {
     		const impl = new DOMImplementation();
    @@ -444,6 +454,19 @@ describe('Node.prototype', () => {
     			});
     		});
     
    +		describe('on deep tree (stack overflow guard)', () => {
    +			test('textContent on a 10,100-depth element tree succeeds without throwing RangeError (GHSA-2v35-w6hq-6mfw)', () => {
    +				const impl2 = new DOMImplementation();
    +				const doc2 = impl2.createDocument(null, 'root');
    +				let current = doc2.documentElement;
    +				for (let i = 0; i < 10100; i++) {
    +					const child = doc2.createElement('n');
    +					current.appendChild(child);
    +					current = child;
    +				}
    +				expect(() => void doc2.documentElement.textContent).not.toThrow();
    +			});
    +		});
     		describe('on other node types (returns nodeValue)', () => {
     			test('Text node returns its data', () => {
     				const node = doc.createTextNode('hello');
    @@ -563,5 +586,17 @@ describe('Node.prototype', () => {
     			expect(imported.value).toBe('val');
     			expect(imported.ownerDocument).toBe(dstDoc);
     		});
    +		test('importNode(node, true) on a 10,100-depth tree succeeds without throwing RangeError (GHSA-2v35-w6hq-6mfw)', () => {
    +			const impl2 = new DOMImplementation();
    +			const doc2 = impl2.createDocument(null, 'root');
    +			let current = doc2.documentElement;
    +			for (let i = 0; i < 10100; i++) {
    +				const child = doc2.createElement('n');
    +				current.appendChild(child);
    +				current = child;
    +			}
    +			const destDoc = impl2.createDocument(null, 'dest');
    +			expect(() => destDoc.importNode(doc2.documentElement, true)).not.toThrow();
    +		});
     	});
     });
    
  • test/dom/serializer.test.js+13 0 modified
    @@ -514,6 +514,19 @@ describe('XMLSerializer.serializeToString', () => {
     			);
     		});
     	});
    +
    +	describe('deep tree (stack overflow guard)', () => {
    +		test('serializeToString on a 5,100-depth tree succeeds without throwing RangeError (GHSA-2v35-w6hq-6mfw)', () => {
    +			const deepDoc = new DOMImplementation().createDocument(null, 'root');
    +			let current = deepDoc.documentElement;
    +			for (let i = 0; i < 5100; i++) {
    +				const child = deepDoc.createElement('n');
    +				current.appendChild(child);
    +				current = child;
    +			}
    +			expect(() => new XMLSerializer().serializeToString(deepDoc.documentElement)).not.toThrow();
    +		});
    +	});
     });
     
     describe('Node.toString', () => {
    
  • test/dom/walk-dom.test.js+20 0 modified
    @@ -192,6 +192,26 @@ describe('walkDOM', () => {
     		});
     	});
     
    +	describe('deep tree (stack overflow guard)', () => {
    +		test('walkDOM on a tree of 10,000 depth succeeds without throwing RangeError', () => {
    +			const impl = new DOMImplementation();
    +			const doc = impl.createDocument(null, 'root');
    +			let current = doc.documentElement;
    +			for (let i = 0; i < 10000; i++) {
    +				const child = doc.createElement('n');
    +				current.appendChild(child);
    +				current = child;
    +			}
    +			expect(() => {
    +				walkDOM(doc.documentElement, null, {
    +					enter() {
    +						return 'ctx';
    +					},
    +				});
    +			}).not.toThrow();
    +		});
    +	});
    +
     	describe('enter modifies firstChild before descent', () => {
     		test('walker visits the modified child list when enter adds a child before returning', () => {
     			const impl = new DOMImplementation();
    
17678a2a73ec

refactor: migrate serializeToString to walkDOM

https://github.com/xmldom/xmldomkarfauApr 12, 2026via ghsa
1 file changed · +256 215
  • lib/dom.js+256 215 modified
    @@ -3000,234 +3000,275 @@ function serializeToString(node, buf, visibleNamespaces, opts) {
     	var doc = node.nodeType === DOCUMENT_NODE ? node : node.ownerDocument;
     	var isHTML = doc.type === 'html';
     
    -	if (nodeFilter) {
    -		node = nodeFilter(node);
    -		if (node) {
    -			if (typeof node == 'string') {
    -				buf.push(node);
    -				return;
    -			}
    -		} else {
    -			return;
    -		}
    -		//buf.sort.apply(attrs, attributeSorter);
    -	}
    -
    -	switch (node.nodeType) {
    -		case ELEMENT_NODE:
    -			var attrs = node.attributes;
    -			var len = attrs.length;
    -			var child = node.firstChild;
    -			var nodeName = node.tagName;
    -
    -			var prefixedNodeName = nodeName;
    -			if (!isHTML && !node.prefix && node.namespaceURI) {
    -				var defaultNS;
    -				// lookup current default ns from `xmlns` attribute
    -				for (var ai = 0; ai < attrs.length; ai++) {
    -					if (attrs.item(ai).name === 'xmlns') {
    -						defaultNS = attrs.item(ai).value;
    -						break;
    -					}
    -				}
    -				if (!defaultNS) {
    -					// lookup current default ns in visibleNamespaces
    -					for (var nsi = visibleNamespaces.length - 1; nsi >= 0; nsi--) {
    -						var namespace = visibleNamespaces[nsi];
    -						if (namespace.prefix === '' && namespace.namespace === node.namespaceURI) {
    -							defaultNS = namespace.namespace;
    -							break;
    +	walkDOM(
    +		node,
    +		{ ns: visibleNamespaces },
    +		{
    +			enter: function (n, ctx) {
    +				var namespaces = ctx.ns;
    +
    +				if (nodeFilter) {
    +					n = nodeFilter(n);
    +					if (n) {
    +						if (typeof n == 'string') {
    +							buf.push(n);
    +							return null;
     						}
    +					} else {
    +						return null;
     					}
     				}
    -				if (defaultNS !== node.namespaceURI) {
    -					for (var nsi = visibleNamespaces.length - 1; nsi >= 0; nsi--) {
    -						var namespace = visibleNamespaces[nsi];
    -						if (namespace.namespace === node.namespaceURI) {
    -							if (namespace.prefix) {
    -								prefixedNodeName = namespace.prefix + ':' + nodeName;
    +
    +				switch (n.nodeType) {
    +					case ELEMENT_NODE:
    +						var attrs = n.attributes;
    +						var len = attrs.length;
    +						var nodeName = n.tagName;
    +
    +						var prefixedNodeName = nodeName;
    +						if (!isHTML && !n.prefix && n.namespaceURI) {
    +							var defaultNS;
    +							// lookup current default ns from `xmlns` attribute
    +							for (var ai = 0; ai < attrs.length; ai++) {
    +								if (attrs.item(ai).name === 'xmlns') {
    +									defaultNS = attrs.item(ai).value;
    +									break;
    +								}
    +							}
    +							if (!defaultNS) {
    +								// lookup current default ns in visibleNamespaces
    +								for (var nsi = namespaces.length - 1; nsi >= 0; nsi--) {
    +									var nsEntry = namespaces[nsi];
    +									if (nsEntry.prefix === '' && nsEntry.namespace === n.namespaceURI) {
    +										defaultNS = nsEntry.namespace;
    +										break;
    +									}
    +								}
    +							}
    +							if (defaultNS !== n.namespaceURI) {
    +								for (var nsi = namespaces.length - 1; nsi >= 0; nsi--) {
    +									var nsEntry = namespaces[nsi];
    +									if (nsEntry.namespace === n.namespaceURI) {
    +										if (nsEntry.prefix) {
    +											prefixedNodeName = nsEntry.prefix + ':' + nodeName;
    +										}
    +										break;
    +									}
    +								}
     							}
    -							break;
     						}
    -					}
    -				}
    -			}
     
    -			buf.push('<', prefixedNodeName);
    +						buf.push('<', prefixedNodeName);
    +
    +						// Build a fresh namespace snapshot for this element's children.
    +						// The slice prevents sibling elements from inheriting each other's declarations.
    +						var childNamespaces = namespaces.slice();
    +
    +						for (var i = 0; i < len; i++) {
    +							// add namespaces for attributes
    +							var attr = attrs.item(i);
    +							if (attr.prefix == 'xmlns') {
    +								childNamespaces.push({
    +									prefix: attr.localName,
    +									namespace: attr.value,
    +								});
    +							} else if (attr.nodeName == 'xmlns') {
    +								childNamespaces.push({ prefix: '', namespace: attr.value });
    +							}
    +						}
     
    -			for (var i = 0; i < len; i++) {
    -				// add namespaces for attributes
    -				var attr = attrs.item(i);
    -				if (attr.prefix == 'xmlns') {
    -					visibleNamespaces.push({
    -						prefix: attr.localName,
    -						namespace: attr.value,
    -					});
    -				} else if (attr.nodeName == 'xmlns') {
    -					visibleNamespaces.push({ prefix: '', namespace: attr.value });
    -				}
    -			}
    +						for (var i = 0; i < len; i++) {
    +							var attr = attrs.item(i);
    +							if (needNamespaceDefine(attr, isHTML, childNamespaces)) {
    +								var attrPrefix = attr.prefix || '';
    +								var uri = attr.namespaceURI;
    +								addSerializedAttribute(buf, attrPrefix ? 'xmlns:' + attrPrefix : 'xmlns', uri);
    +								childNamespaces.push({ prefix: attrPrefix, namespace: uri });
    +							}
    +							// Apply nodeFilter and serialize the attribute.
    +							var filteredAttr = nodeFilter ? nodeFilter(attr) : attr;
    +							if (filteredAttr) {
    +								if (typeof filteredAttr === 'string') {
    +									buf.push(filteredAttr);
    +								} else {
    +									addSerializedAttribute(buf, filteredAttr.name, filteredAttr.value);
    +								}
    +							}
    +						}
     
    -			for (var i = 0; i < len; i++) {
    -				var attr = attrs.item(i);
    -				if (needNamespaceDefine(attr, isHTML, visibleNamespaces)) {
    -					var prefix = attr.prefix || '';
    -					var uri = attr.namespaceURI;
    -					addSerializedAttribute(buf, prefix ? 'xmlns:' + prefix : 'xmlns', uri);
    -					visibleNamespaces.push({ prefix: prefix, namespace: uri });
    -				}
    -				serializeToString(attr, buf, visibleNamespaces, opts);
    -			}
    +						// add namespace for current node
    +						if (nodeName === prefixedNodeName && needNamespaceDefine(n, isHTML, childNamespaces)) {
    +							var nodePrefix = n.prefix || '';
    +							var uri = n.namespaceURI;
    +							addSerializedAttribute(buf, nodePrefix ? 'xmlns:' + nodePrefix : 'xmlns', uri);
    +							childNamespaces.push({ prefix: nodePrefix, namespace: uri });
    +						}
     
    -			// add namespace for current node
    -			if (nodeName === prefixedNodeName && needNamespaceDefine(node, isHTML, visibleNamespaces)) {
    -				var prefix = node.prefix || '';
    -				var uri = node.namespaceURI;
    -				addSerializedAttribute(buf, prefix ? 'xmlns:' + prefix : 'xmlns', uri);
    -				visibleNamespaces.push({ prefix: prefix, namespace: uri });
    -			}
    -			// in XML elements can be closed when they have no children
    -			var canCloseTag = !child;
    -			if (canCloseTag && (isHTML || node.namespaceURI === NAMESPACE.HTML)) {
    -				// in HTML (doc or ns) only void elements can be closed right away
    -				canCloseTag = isHTMLVoidElement(nodeName);
    -			}
    -			if (canCloseTag) {
    -				buf.push('/>');
    -			} else {
    -				buf.push('>');
    -				//if is cdata child node
    -				if (isHTML && isHTMLRawTextElement(nodeName)) {
    -					while (child) {
    -						if (child.data) {
    -							buf.push(child.data);
    +						// in XML elements can be closed when they have no children
    +						var canCloseTag = !n.firstChild;
    +						if (canCloseTag && (isHTML || n.namespaceURI === NAMESPACE.HTML)) {
    +							// in HTML (doc or ns) only void elements can be closed right away
    +							canCloseTag = isHTMLVoidElement(nodeName);
    +						}
    +						if (canCloseTag) {
    +							buf.push('/>');
    +							// Self-closing: no children and no closing tag needed from exit.
    +							return null;
    +						}
    +
    +						buf.push('>');
    +
    +						// HTML raw text elements: serialize children as raw data without further descent.
    +						if (isHTML && isHTMLRawTextElement(nodeName)) {
    +							var child = n.firstChild;
    +							while (child) {
    +								if (child.data) {
    +									buf.push(child.data);
    +								} else {
    +									serializeToString(child, buf, childNamespaces.slice(), opts);
    +								}
    +								child = child.nextSibling;
    +							}
    +							buf.push('</', prefixedNodeName, '>');
    +							// Children handled manually above; prevent walkDOM from also traversing them.
    +							return null;
    +						}
    +
    +						// Return child context so walkDOM descends; exit will emit the closing tag.
    +						return { ns: childNamespaces, tag: prefixedNodeName };
    +					case DOCUMENT_NODE:
    +					case DOCUMENT_FRAGMENT_NODE:
    +						if (requireWellFormed && n.nodeType === DOCUMENT_NODE && n.documentElement == null) {
    +							throw new DOMException('The Document has no documentElement', DOMExceptionName.InvalidStateError);
    +						}
    +						// Pass namespaces through; each child element will slice independently.
    +						return { ns: namespaces };
    +					case ATTRIBUTE_NODE:
    +						addSerializedAttribute(buf, n.name, n.value);
    +						return null;
    +					case TEXT_NODE:
    +						/*
    +						 * The ampersand character (&) and the left angle bracket (<) must not appear in their literal form,
    +						 * except when used as markup delimiters, or within a comment, a processing instruction,
    +						 * or a CDATA section.
    +						 * If they are needed elsewhere, they must be escaped using either numeric character
    +						 * references or the strings `&amp;` and `&lt;` respectively.
    +						 * The right angle bracket (>) may be represented using the string " &gt; ",
    +						 * and must, for compatibility, be escaped using either `&gt;`,
    +						 * or a character reference when it appears in the string `]]>` in content,
    +						 * when that string is not marking the end of a CDATA section.
    +						 *
    +						 * In the content of elements, character data is any string of characters which does not
    +						 * contain the start-delimiter of any markup and does not include the CDATA-section-close
    +						 * delimiter, `]]>`.
    +						 *
    +						 * @see https://www.w3.org/TR/xml/#NT-CharData
    +						 * @see https://w3c.github.io/DOM-Parsing/#xml-serializing-a-text-node
    +						 */
    +						if (requireWellFormed && g.InvalidChar.test(n.data)) {
    +							throw new DOMException(
    +								'The Text node data contains characters outside the XML Char production',
    +								DOMExceptionName.InvalidStateError
    +							);
    +						}
    +						buf.push(n.data.replace(/[<&>]/g, _xmlEncoder));
    +						return null;
    +					case CDATA_SECTION_NODE:
    +						if (requireWellFormed && n.data.indexOf(']]>') !== -1) {
    +							throw new DOMException('The CDATASection data contains "]]>"', DOMExceptionName.InvalidStateError);
    +						}
    +						if (splitCDATASections) {
    +							buf.push(g.CDATA_START, n.data.replace(/]]>/g, ']]]]><![CDATA[>'), g.CDATA_END);
     						} else {
    -							serializeToString(child, buf, visibleNamespaces.slice(), opts);
    +							buf.push(g.CDATA_START, n.data, g.CDATA_END);
     						}
    -						child = child.nextSibling;
    -					}
    -				} else {
    -					while (child) {
    -						serializeToString(child, buf, visibleNamespaces.slice(), opts);
    -						child = child.nextSibling;
    -					}
    -				}
    -				buf.push('</', prefixedNodeName, '>');
    -			}
    -			// remove added visible namespaces
    -			//visibleNamespaces.length = startVisibleNamespaces;
    -			return;
    -		case DOCUMENT_NODE:
    -		case DOCUMENT_FRAGMENT_NODE:
    -			if (requireWellFormed && node.nodeType === DOCUMENT_NODE && node.documentElement == null) {
    -				throw new DOMException('The Document has no documentElement', DOMExceptionName.InvalidStateError);
    -			}
    -			var child = node.firstChild;
    -			while (child) {
    -				serializeToString(child, buf, visibleNamespaces.slice(), opts);
    -				child = child.nextSibling;
    -			}
    -			return;
    -		case ATTRIBUTE_NODE:
    -			return addSerializedAttribute(buf, node.name, node.value);
    -		case TEXT_NODE:
    -			/*
    -			 * The ampersand character (&) and the left angle bracket (<) must not appear in their literal form,
    -			 * except when used as markup delimiters, or within a comment, a processing instruction,
    -			 * or a CDATA section.
    -			 * If they are needed elsewhere, they must be escaped using either numeric character
    -			 * references or the strings `&amp;` and `&lt;` respectively.
    -			 * The right angle bracket (>) may be represented using the string " &gt; ",
    -			 * and must, for compatibility, be escaped using either `&gt;`,
    -			 * or a character reference when it appears in the string `]]>` in content,
    -			 * when that string is not marking the end of a CDATA section.
    -			 *
    -			 * In the content of elements, character data is any string of characters which does not
    -			 * contain the start-delimiter of any markup and does not include the CDATA-section-close
    -			 * delimiter, `]]>`.
    -			 *
    -			 * @see https://www.w3.org/TR/xml/#NT-CharData
    -			 * @see https://w3c.github.io/DOM-Parsing/#xml-serializing-a-text-node
    -			 */
    -			if (requireWellFormed && g.InvalidChar.test(node.data)) {
    -				throw new DOMException(
    -					'The Text node data contains characters outside the XML Char production',
    -					DOMExceptionName.InvalidStateError
    -				);
    -			}
    -			return buf.push(node.data.replace(/[<&>]/g, _xmlEncoder));
    -		case CDATA_SECTION_NODE:
    -			if (requireWellFormed && node.data.indexOf(']]>') !== -1) {
    -				throw new DOMException('The CDATASection data contains "]]>"', DOMExceptionName.InvalidStateError);
    -			}
    -			if (splitCDATASections) {
    -				return buf.push(g.CDATA_START, node.data.replace(/]]>/g, ']]]]><![CDATA[>'), g.CDATA_END);
    -			}
    -			return buf.push(g.CDATA_START, node.data, g.CDATA_END);
    -		case COMMENT_NODE:
    -			if (requireWellFormed) {
    -				if (g.InvalidChar.test(node.data)) {
    -					throw new DOMException(
    -						'The comment node data contains characters outside the XML Char production',
    -						DOMExceptionName.InvalidStateError
    -					);
    -				}
    -				if (node.data.indexOf('--') !== -1 || node.data[node.data.length - 1] === '-') {
    -					throw new DOMException('The comment node data contains "--" or ends with "-"', DOMExceptionName.InvalidStateError);
    -				}
    -			}
    -			return buf.push(g.COMMENT_START, node.data, g.COMMENT_END);
    -		case DOCUMENT_TYPE_NODE:
    -			var pubid = node.publicId;
    -			var sysid = node.systemId;
    -			if (requireWellFormed) {
    -				if (pubid && !g.PubidLiteral_match.test(pubid)) {
    -					throw new DOMException('The DocumentType publicId is not a valid PubidLiteral', DOMExceptionName.InvalidStateError);
    -				}
    -				if (sysid && sysid !== '.' && !g.SystemLiteral_match.test(sysid)) {
    -					throw new DOMException('The DocumentType systemId is not a valid SystemLiteral', DOMExceptionName.InvalidStateError);
    -				}
    -				if (node.internalSubset && node.internalSubset.indexOf(']>') !== -1) {
    -					throw new DOMException('The DocumentType internalSubset contains "]>"', DOMExceptionName.InvalidStateError);
    -				}
    -			}
    -			buf.push(g.DOCTYPE_DECL_START, ' ', node.name);
    -			if (pubid) {
    -				buf.push(' ', g.PUBLIC, ' ', pubid);
    -				if (sysid && sysid !== '.') {
    -					buf.push(' ', sysid);
    -				}
    -			} else if (sysid && sysid !== '.') {
    -				buf.push(' ', g.SYSTEM, ' ', sysid);
    -			}
    -			if (node.internalSubset) {
    -				buf.push(' [', node.internalSubset, ']');
    -			}
    -			buf.push('>');
    -			return;
    -		case PROCESSING_INSTRUCTION_NODE:
    -			if (requireWellFormed) {
    -				if (node.target.indexOf(':') !== -1 || node.target.toLowerCase() === 'xml') {
    -					throw new DOMException('The ProcessingInstruction target is not well-formed', DOMExceptionName.InvalidStateError);
    -				}
    -				if (g.InvalidChar.test(node.data)) {
    -					throw new DOMException(
    -						'The ProcessingInstruction data contains characters outside the XML Char production',
    -						DOMExceptionName.InvalidStateError
    -					);
    +						return null;
    +					case COMMENT_NODE:
    +						if (requireWellFormed) {
    +							if (g.InvalidChar.test(n.data)) {
    +								throw new DOMException(
    +									'The comment node data contains characters outside the XML Char production',
    +									DOMExceptionName.InvalidStateError
    +								);
    +							}
    +							if (n.data.indexOf('--') !== -1 || n.data[n.data.length - 1] === '-') {
    +								throw new DOMException(
    +									'The comment node data contains "--" or ends with "-"',
    +									DOMExceptionName.InvalidStateError
    +								);
    +							}
    +						}
    +						buf.push(g.COMMENT_START, n.data, g.COMMENT_END);
    +						return null;
    +					case DOCUMENT_TYPE_NODE:
    +						var pubid = n.publicId;
    +						var sysid = n.systemId;
    +						if (requireWellFormed) {
    +							if (pubid && !g.PubidLiteral_match.test(pubid)) {
    +								throw new DOMException(
    +									'The DocumentType publicId is not a valid PubidLiteral',
    +									DOMExceptionName.InvalidStateError
    +								);
    +							}
    +							if (sysid && sysid !== '.' && !g.SystemLiteral_match.test(sysid)) {
    +								throw new DOMException(
    +									'The DocumentType systemId is not a valid SystemLiteral',
    +									DOMExceptionName.InvalidStateError
    +								);
    +							}
    +							if (n.internalSubset && n.internalSubset.indexOf(']>') !== -1) {
    +								throw new DOMException('The DocumentType internalSubset contains "]>"', DOMExceptionName.InvalidStateError);
    +							}
    +						}
    +						buf.push(g.DOCTYPE_DECL_START, ' ', n.name);
    +						if (pubid) {
    +							buf.push(' ', g.PUBLIC, ' ', pubid);
    +							if (sysid && sysid !== '.') {
    +								buf.push(' ', sysid);
    +							}
    +						} else if (sysid && sysid !== '.') {
    +							buf.push(' ', g.SYSTEM, ' ', sysid);
    +						}
    +						if (n.internalSubset) {
    +							buf.push(' [', n.internalSubset, ']');
    +						}
    +						buf.push('>');
    +						return null;
    +					case PROCESSING_INSTRUCTION_NODE:
    +						if (requireWellFormed) {
    +							if (n.target.indexOf(':') !== -1 || n.target.toLowerCase() === 'xml') {
    +								throw new DOMException('The ProcessingInstruction target is not well-formed', DOMExceptionName.InvalidStateError);
    +							}
    +							if (g.InvalidChar.test(n.data)) {
    +								throw new DOMException(
    +									'The ProcessingInstruction data contains characters outside the XML Char production',
    +									DOMExceptionName.InvalidStateError
    +								);
    +							}
    +							if (n.data.indexOf('?>') !== -1) {
    +								throw new DOMException('The ProcessingInstruction data contains "?>"', DOMExceptionName.InvalidStateError);
    +							}
    +						}
    +						buf.push('<?', n.target, ' ', n.data, '?>');
    +						return null;
    +					case ENTITY_REFERENCE_NODE:
    +						buf.push('&', n.nodeName, ';');
    +						return null;
    +					//case ENTITY_NODE:
    +					//case NOTATION_NODE:
    +					default:
    +						buf.push('??', n.nodeName);
    +						return null;
     				}
    -				if (node.data.indexOf('?>') !== -1) {
    -					throw new DOMException('The ProcessingInstruction data contains "?>"', DOMExceptionName.InvalidStateError);
    +			},
    +			exit: function (n, childCtx) {
    +				// Emit the closing tag for elements that were opened (not self-closed, not raw text).
    +				if (childCtx && childCtx.tag) {
    +					buf.push('</', childCtx.tag, '>');
     				}
    -			}
    -			return buf.push('<?', node.target, ' ', node.data, '?>');
    -		case ENTITY_REFERENCE_NODE:
    -			return buf.push('&', node.nodeName, ';');
    -		//case ENTITY_NODE:
    -		//case NOTATION_NODE:
    -		default:
    -			buf.push('??', node.nodeName);
    -	}
    +			},
    +		}
    +	);
     }
     /**
      * Imports a node from a different document into `doc`, creating a new copy.
    
291257493cb0

refactor: migrate importNode to walkDOM

https://github.com/xmldom/xmldomkarfauApr 12, 2026via ghsa
2 files changed · +54 47
  • index.d.ts+4 3 modified
    @@ -1371,11 +1371,12 @@ declare module '@xmldom/xmldom' {
     			localName: string
     		): LiveNodeList<Element>;
     		/**
    -		 * Returns a copy of node. If deep is true, the copy also includes the node's descendants.
    -		 *
    -		 * If node is a document or a shadow root, throws a "NotSupportedError" DOMException.
    +		 * Imports a node from another document into this document, creating a new copy owned by this
    +		 * document. If `deep` is true, the copy also includes the node's descendants.
     		 *
     		 * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/importNode)
    +		 *
    +		 * @see {@link https://dom.spec.whatwg.org/#dom-document-importnode}
     		 */
     		importNode<T extends Node>(node: T, deep?: boolean): T;
     	}
    
  • lib/dom.js+50 44 modified
    @@ -2208,7 +2208,20 @@ Document.prototype = {
     			this.documentElement = newChild;
     		}
     	},
    -	// Introduced in DOM Level 2:
    +	/**
    +	 * Imports a node from another document into this document, creating a new copy owned by this
    +	 * document. The source node and its subtree are not modified.
    +	 *
    +	 * @param {Node} importedNode
    +	 * The node to import.
    +	 * @param {boolean} deep
    +	 * If true, the contents of the node are recursively imported.
    +	 * If false, only the node itself (and its attributes, if it is an element) are imported.
    +	 * @returns {Node}
    +	 * Returns the newly created import of the node.
    +	 * @see {@link importNode}
    +	 * @see {@link https://dom.spec.whatwg.org/#dom-document-importnode}
    +	 */
     	importNode: function (importedNode, deep) {
     		return importNode(this, importedNode, deep);
     	},
    @@ -3216,50 +3229,43 @@ function serializeToString(node, buf, visibleNamespaces, opts) {
     			buf.push('??', node.nodeName);
     	}
     }
    +/**
    + * Imports a node from a different document into `doc`, creating a new copy.
    + * Delegates to {@link walkDOM} for traversal. Each node in the subtree is shallow-cloned,
    + * stamped with `doc` as its `ownerDocument`, and detached (`parentNode` set to `null`).
    + * Children are imported recursively when `deep` is `true`; for {@link Attr} nodes `deep` is
    + * always forced to `true`
    + * because an attribute's value lives in a child text node.
    + *
    + * @param {Document} doc
    + * The document that will own the imported node.
    + * @param {Node} node
    + * The node to import.
    + * @param {boolean} deep
    + * If `true`, descendants are imported recursively.
    + * @returns {Node}
    + * The newly imported node, now owned by `doc`.
    + */
     function importNode(doc, node, deep) {
    -	var node2;
    -	switch (node.nodeType) {
    -		case ELEMENT_NODE:
    -			node2 = node.cloneNode(false);
    -			node2.ownerDocument = doc;
    -		//var attrs = node2.attributes;
    -		//var len = attrs.length;
    -		//for(var i=0;i<len;i++){
    -		//node2.setAttributeNodeNS(importNode(doc,attrs.item(i),deep));
    -		//}
    -		case DOCUMENT_FRAGMENT_NODE:
    -			break;
    -		case ATTRIBUTE_NODE:
    -			deep = true;
    -			break;
    -		//case ENTITY_REFERENCE_NODE:
    -		//case PROCESSING_INSTRUCTION_NODE:
    -		////case TEXT_NODE:
    -		//case CDATA_SECTION_NODE:
    -		//case COMMENT_NODE:
    -		//	deep = false;
    -		//	break;
    -		//case DOCUMENT_NODE:
    -		//case DOCUMENT_TYPE_NODE:
    -		//cannot be imported.
    -		//case ENTITY_NODE:
    -		//case NOTATION_NODE:
    -		//can not hit in level3
    -		//default:throw e;
    -	}
    -	if (!node2) {
    -		node2 = node.cloneNode(false); //false
    -	}
    -	node2.ownerDocument = doc;
    -	node2.parentNode = null;
    -	if (deep) {
    -		var child = node.firstChild;
    -		while (child) {
    -			node2.appendChild(importNode(doc, child, deep));
    -			child = child.nextSibling;
    -		}
    -	}
    -	return node2;
    +	var destRoot;
    +	walkDOM(node, null, {
    +		enter: function (srcNode, destParent) {
    +			// Shallow-clone the node and stamp it into the target document.
    +			var destNode = srcNode.cloneNode(false);
    +			destNode.ownerDocument = doc;
    +			destNode.parentNode = null;
    +			// capture as the root of the imported subtree or attach to parent.
    +			if (destParent === null) {
    +				destRoot = destNode;
    +			} else {
    +				destParent.appendChild(destNode);
    +			}
    +			// ATTRIBUTE_NODE must always be imported deeply: its value lives in a child text node.
    +			var shouldDeep = srcNode.nodeType === ATTRIBUTE_NODE || deep;
    +			return shouldDeep ? destNode : null;
    +		},
    +	});
    +	return destRoot;
     }
     
     /**
    
b0620383abc1

refactor: migrate cloneNode to walkDOM

https://github.com/xmldom/xmldomkarfauApr 12, 2026via ghsa
1 file changed · +46 33
  • lib/dom.js+46 33 modified
    @@ -3279,42 +3279,55 @@ function importNode(doc, node, deep) {
      * potentially invoked in this function) do not meet their specific constraints.
      */
     function cloneNode(doc, node, deep) {
    -	var node2 = new node.constructor(PDC);
    -	for (var n in node) {
    -		if (hasOwn(node, n)) {
    -			var v = node[n];
    -			if (typeof v != 'object') {
    -				if (v != node2[n]) {
    -					node2[n] = v;
    +	var destRoot;
    +	walkDOM(node, null, {
    +		enter: function (srcNode, destParent) {
    +			// 1. Create a blank node of the same type and copy all scalar own properties.
    +			var destNode = new srcNode.constructor(PDC);
    +			for (var n in srcNode) {
    +				if (hasOwn(srcNode, n)) {
    +					var v = srcNode[n];
    +					if (typeof v != 'object') {
    +						if (v != destNode[n]) {
    +							destNode[n] = v;
    +						}
    +					}
     				}
     			}
    -		}
    -	}
    -	if (node.childNodes) {
    -		node2.childNodes = new NodeList();
    -	}
    -	node2.ownerDocument = doc;
    -	switch (node2.nodeType) {
    -		case ELEMENT_NODE:
    -			var attrs = node.attributes;
    -			var attrs2 = (node2.attributes = new NamedNodeMap());
    -			var len = attrs.length;
    -			attrs2._ownerElement = node2;
    -			for (var i = 0; i < len; i++) {
    -				node2.setAttributeNode(cloneNode(doc, attrs.item(i), true));
    +			if (srcNode.childNodes) {
    +				destNode.childNodes = new NodeList();
     			}
    -			break;
    -		case ATTRIBUTE_NODE:
    -			deep = true;
    -	}
    -	if (deep) {
    -		var child = node.firstChild;
    -		while (child) {
    -			node2.appendChild(cloneNode(doc, child, deep));
    -			child = child.nextSibling;
    -		}
    -	}
    -	return node2;
    +			destNode.ownerDocument = doc;
    +			// 2. Handle node-type-specific setup.
    +			//    Attributes are not DOM children, so they are cloned inline here
    +			//    rather than by walkDOM descent.
    +			//    ATTRIBUTE_NODE forces deep=true so its own children are walked.
    +			var shouldDeep = deep;
    +			switch (destNode.nodeType) {
    +				case ELEMENT_NODE:
    +					var attrs = srcNode.attributes;
    +					var attrs2 = (destNode.attributes = new NamedNodeMap());
    +					var len = attrs.length;
    +					attrs2._ownerElement = destNode;
    +					for (var i = 0; i < len; i++) {
    +						destNode.setAttributeNode(cloneNode(doc, attrs.item(i), true));
    +					}
    +					break;
    +				case ATTRIBUTE_NODE:
    +					shouldDeep = true;
    +			}
    +			// 3. Attach to parent, or capture as the root of the cloned subtree.
    +			if (destParent !== null) {
    +				destParent.appendChild(destNode);
    +			} else {
    +				destRoot = destNode;
    +			}
    +			// 4. Return destNode as the context for children (causes walkDOM to descend),
    +			//    or null to skip children (shallow clone).
    +			return shouldDeep ? destNode : null;
    +		},
    +	});
    +	return destRoot;
     }
     
     function __set__(object, key, value) {
    
8834218c85ac

refactor: migrate textContent getter to walkDOM

https://github.com/xmldom/xmldomkarfauApr 12, 2026via ghsa
2 files changed · +42 20
  • index.d.ts+13 1 modified
    @@ -448,7 +448,19 @@ declare module '@xmldom/xmldom' {
     		 * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/previousSibling)
     		 */
     		readonly previousSibling: Node | null;
    -		/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/textContent) */
    +		/**
    +		 * The text content of this node and its descendants.
    +		 *
    +		 * For {@link Element} and {@link DocumentFragment} nodes, returns the concatenation of the
    +		 * `nodeValue` of every descendant text node, excluding processing instruction and comment
    +		 * nodes. For all other node types, returns `nodeValue`.
    +		 *
    +		 * Setting `textContent` on an element or document fragment replaces all child nodes with a
    +		 * single text node; on other nodes it sets `data`, `value`, and `nodeValue` directly.
    +		 * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/textContent)
    +		 *
    +		 * @see {@link https://dom.spec.whatwg.org/#dom-node-textcontent}
    +		 */
     		textContent: string | null;
     
     		/**
    
  • lib/dom.js+29 19 modified
    @@ -3346,9 +3346,37 @@ try {
     			},
     		});
     
    +		/**
    +		 * The text content of this node and its descendants.
    +		 *
    +		 * For {@link Element} and {@link DocumentFragment} nodes, returns the concatenation of the
    +		 * `nodeValue` of every descendant text node, excluding processing instruction and comment
    +		 * nodes. For all other node types, returns `nodeValue`.
    +		 *
    +		 * Setting `textContent` on an element or document fragment replaces all child nodes with a
    +		 * single text node; on other nodes it sets `data`, `value`, and `nodeValue` directly.
    +		 *
    +		 * @type {string | null}
    +		 * @see {@link https://dom.spec.whatwg.org/#dom-node-textcontent}
    +		 */
     		Object.defineProperty(Node.prototype, 'textContent', {
     			get: function () {
    -				return getTextContent(this);
    +				if (this.nodeType === ELEMENT_NODE || this.nodeType === DOCUMENT_FRAGMENT_NODE) {
    +					var buf = [];
    +					walkDOM(this, null, {
    +						enter: function (n) {
    +							if (n.nodeType === ELEMENT_NODE || n.nodeType === DOCUMENT_FRAGMENT_NODE) {
    +								return true; // enter children
    +							}
    +							if (n.nodeType === PROCESSING_INSTRUCTION_NODE || n.nodeType === COMMENT_NODE) {
    +								return null; // excluded from text content
    +							}
    +							buf.push(n.nodeValue);
    +						},
    +					});
    +					return buf.join('');
    +				}
    +				return this.nodeValue;
     			},
     
     			set: function (data) {
    @@ -3371,24 +3399,6 @@ try {
     			},
     		});
     
    -		function getTextContent(node) {
    -			switch (node.nodeType) {
    -				case ELEMENT_NODE:
    -				case DOCUMENT_FRAGMENT_NODE:
    -					var buf = [];
    -					node = node.firstChild;
    -					while (node) {
    -						if (node.nodeType !== 7 && node.nodeType !== 8) {
    -							buf.push(getTextContent(node));
    -						}
    -						node = node.nextSibling;
    -					}
    -					return buf.join('');
    -				default:
    -					return node.nodeValue;
    -			}
    -		}
    -
     		Object.defineProperty(Element.prototype, 'children', {
     			get: function () {
     				return new LiveNodeList(this, childrenRefresh);
    
8b7cfd149131

refactor: migrate _visitNode to walkDOM

https://github.com/xmldom/xmldomkarfauApr 12, 2026via ghsa
1 file changed · +14 14
  • lib/dom.js+14 14 modified
    @@ -1548,22 +1548,22 @@ copy(DocumentPosition, Node);
     copy(DocumentPosition, Node.prototype);
     
     /**
    - * @param callback
    - * Return true for continue,false for break.
    - * @returns
    - * boolean true: break visit;
    + * Visits every node in the subtree rooted at `node` in depth-first pre-order.
    + *
    + * Delegates to {@link walkDOM} for traversal. The `callback` is called on each node;
    + * if it returns a truthy value, traversal stops immediately.
    + *
    + * @param {Node} node
    + * Root of the subtree to visit.
    + * @param {function(Node): *} callback
    + * Called for each node. A truthy return value stops traversal early.
      */
     function _visitNode(node, callback) {
    -	if (callback(node)) {
    -		return true;
    -	}
    -	if ((node = node.firstChild)) {
    -		do {
    -			if (_visitNode(node, callback)) {
    -				return true;
    -			}
    -		} while ((node = node.nextSibling));
    -	}
    +	walkDOM(node, null, {
    +		enter: function (n) {
    +			return callback(n) ? walkDOM.STOP : true;
    +		},
    +	});
     }
     
     /**
    
2d6d6916ed8a

refactor: introduce recursive walkDOM

https://github.com/xmldom/xmldomkarfauApr 12, 2026via ghsa
2 files changed · +274 0
  • lib/dom.js+57 0 modified
    @@ -1566,6 +1566,62 @@ function _visitNode(node, callback) {
     	}
     }
     
    +/**
    + * Depth-first pre/post-order DOM tree walker.
    + *
    + * Visits every node in the subtree rooted at `node`. For each node:
    + *
    + * 1. Calls `callbacks.enter(node, context)` before descending into the node's children. The
    + * return value becomes the `context` passed to each child's `enter` call and to the matching
    + * `exit` call.
    + * 2. If `enter` returns `null` or `undefined`, the node's children are skipped;
    + * sibling traversal continues normally.
    + * 3. If `enter` returns `walkDOM.STOP`, the entire traversal is aborted immediately — no
    + * further `enter` or `exit` calls are made.
    + * 4. `firstChild` is read **after** `enter` returns, so `enter` may safely modify the node's
    + * child list before the walker descends.
    + * 5. Calls `callbacks.exit(node, context)` (if provided) after all of a node's children have
    + * been visited, passing the same `context` that `enter`
    + * returned for that node.
    + *
    + * Note: this implementation is recursive. It will be converted to an iterative form in a later
    + * step to eliminate stack-overflow risk on very deep trees.
    + *
    + * @param {Node} node
    + * Root of the subtree to walk.
    + * @param {*} context
    + * Initial context value passed to the root node's `enter`.
    + * @param {{ enter: function(Node, *): *, exit?: function(Node, *): void }} callbacks
    + * @returns {void | walkDOM.STOP}
    + */
    +function walkDOM(node, context, callbacks) {
    +	var childContext = callbacks.enter(node, context);
    +	if (childContext === walkDOM.STOP) {
    +		return walkDOM.STOP;
    +	}
    +	if (childContext !== null && childContext !== undefined) {
    +		var child = node.firstChild;
    +		while (child) {
    +			var next = child.nextSibling;
    +			if (walkDOM(child, childContext, callbacks) === walkDOM.STOP) {
    +				return walkDOM.STOP;
    +			}
    +			child = next;
    +		}
    +	}
    +	if (callbacks.exit) {
    +		callbacks.exit(node, childContext);
    +	}
    +}
    +
    +/**
    + * Sentinel value returned from a `walkDOM` `enter` callback to abort the entire traversal
    + * immediately.
    + *
    + * @type {symbol}
    + */
    +walkDOM.STOP = Symbol('walkDOM.STOP');
    +
     /**
      * @typedef DocumentOptions
      * @property {string} [contentType=MIME_TYPE.XML_APPLICATION]
    @@ -3377,4 +3433,5 @@ exports.NodeList = NodeList;
     exports.Notation = Notation;
     exports.Text = Text;
     exports.ProcessingInstruction = ProcessingInstruction;
    +exports.walkDOM = walkDOM;
     exports.XMLSerializer = XMLSerializer;
    
  • test/dom/walk-dom.test.js+217 0 added
    @@ -0,0 +1,217 @@
    +'use strict';
    +
    +const { describe, test, expect, beforeEach } = require('@jest/globals');
    +const { DOMImplementation, walkDOM } = require('../../lib/dom');
    +
    +/**
    + * Build a small DOM tree for testing:
    + *
    + *   root (Element)
    + *     childA (Element)
    + *       grandchildA1 (Element)
    + *       grandchildA2 (Element)
    + *     childB (Element)
    + *       grandchildB1 (Element)
    + */
    +function buildTree() {
    +	const impl = new DOMImplementation();
    +	const doc = impl.createDocument(null, 'root');
    +	const root = doc.documentElement;
    +
    +	const childA = doc.createElement('childA');
    +	root.appendChild(childA);
    +	const grandchildA1 = doc.createElement('grandchildA1');
    +	childA.appendChild(grandchildA1);
    +	const grandchildA2 = doc.createElement('grandchildA2');
    +	childA.appendChild(grandchildA2);
    +	const childB = doc.createElement('childB');
    +	root.appendChild(childB);
    +	const grandchildB1 = doc.createElement('grandchildB1');
    +	childB.appendChild(grandchildB1);
    +
    +	return { doc, root, childA, grandchildA1, grandchildA2, childB, grandchildB1 };
    +}
    +
    +describe('walkDOM', () => {
    +	describe('pre-order entry', () => {
    +		test('calls enter on parent before children', () => {
    +			const { root } = buildTree();
    +			const visited = [];
    +			walkDOM(root, null, {
    +				enter(node) {
    +					visited.push(node.nodeName);
    +					// Descend into root so childA/childB are visited, but skip grandchildren
    +					if (node === root) return 'ctx';
    +					return null;
    +				},
    +			});
    +			// root entered first; childA and childB entered; grandchildren skipped
    +			expect(visited).toEqual(['root', 'childA', 'childB']);
    +		});
    +
    +		test('visits all nodes in depth-first pre-order when enter returns a truthy context', () => {
    +			const { root } = buildTree();
    +			const visited = [];
    +			walkDOM(root, 'ctx', {
    +				enter(node) {
    +					visited.push(node.nodeName);
    +					return 'ctx';
    +				},
    +			});
    +			expect(visited).toEqual(['root', 'childA', 'grandchildA1', 'grandchildA2', 'childB', 'grandchildB1']);
    +		});
    +	});
    +
    +	describe('post-order exit', () => {
    +		test('calls exit on a node after all its children have been exited', () => {
    +			const { root } = buildTree();
    +			const log = [];
    +			walkDOM(root, 'ctx', {
    +				enter(node) {
    +					log.push('enter:' + node.nodeName);
    +					return 'ctx';
    +				},
    +				exit(node) {
    +					log.push('exit:' + node.nodeName);
    +				},
    +			});
    +			// grandchildA1 exited before childA, childA exited before root
    +			const grandchildA1Exit = log.indexOf('exit:grandchildA1');
    +			const childAExit = log.indexOf('exit:childA');
    +			const rootExit = log.indexOf('exit:root');
    +			expect(grandchildA1Exit).toBeLessThan(childAExit);
    +			expect(childAExit).toBeLessThan(rootExit);
    +		});
    +
    +		test('passes the context returned by enter to exit', () => {
    +			const impl = new DOMImplementation();
    +			const doc = impl.createDocument(null, 'root');
    +			const root = doc.documentElement;
    +			root.appendChild(doc.createElement('child'));
    +
    +			const exitContexts = [];
    +			walkDOM(root, 0, {
    +				enter(node, ctx) {
    +					return ctx + 1;
    +				},
    +				exit(node, ctx) {
    +					exitContexts.push({ name: node.nodeName, ctx });
    +				},
    +			});
    +			// root enter gets ctx=0, returns 1; child enter gets ctx=1, returns 2
    +			// child exit gets ctx=2; root exit gets ctx=1
    +			expect(exitContexts).toEqual([
    +				{ name: 'child', ctx: 2 },
    +				{ name: 'root', ctx: 1 },
    +			]);
    +		});
    +	});
    +
    +	describe('context propagation', () => {
    +		test('each child receives the return value of its parent enter', () => {
    +			const { root } = buildTree();
    +			const receivedCtx = {};
    +			walkDOM(root, 0, {
    +				enter(node, ctx) {
    +					receivedCtx[node.nodeName] = ctx;
    +					return ctx + 1;
    +				},
    +			});
    +			expect(receivedCtx['root']).toBe(0);
    +			expect(receivedCtx['childA']).toBe(1);
    +			expect(receivedCtx['grandchildA1']).toBe(2);
    +			expect(receivedCtx['grandchildA2']).toBe(2);
    +			expect(receivedCtx['childB']).toBe(1);
    +			expect(receivedCtx['grandchildB1']).toBe(2);
    +		});
    +	});
    +
    +	describe('STOP sentinel', () => {
    +		test('returning walkDOM.STOP from enter aborts the entire traversal immediately', () => {
    +			const { root } = buildTree();
    +			const visited = [];
    +			walkDOM(root, null, {
    +				enter(node) {
    +					visited.push(node.nodeName);
    +					if (node.nodeName === 'childA') {
    +						return walkDOM.STOP;
    +					}
    +					return 'ctx';
    +				},
    +				exit(node) {
    +					visited.push('exit:' + node.nodeName);
    +				},
    +			});
    +			// root and childA entered; traversal stops at childA — no children of childA,
    +			// no siblings (childB), no exit calls
    +			expect(visited).toEqual(['root', 'childA']);
    +		});
    +	});
    +
    +	describe('skip children', () => {
    +		test('returning null from enter skips children but continues with siblings', () => {
    +			const { root } = buildTree();
    +			const visited = [];
    +			walkDOM(root, null, {
    +				enter(node) {
    +					visited.push(node.nodeName);
    +					if (node.nodeName === 'childA') {
    +						return null; // skip grandchildA1, grandchildA2
    +					}
    +					return 'ctx';
    +				},
    +			});
    +			expect(visited).toContain('root');
    +			expect(visited).toContain('childA');
    +			expect(visited).not.toContain('grandchildA1');
    +			expect(visited).not.toContain('grandchildA2');
    +			// childB and its child are still walked (null only skips childA's children)
    +			expect(visited).toContain('childB');
    +			expect(visited).toContain('grandchildB1');
    +		});
    +
    +		test('returning undefined from enter skips children but continues with siblings', () => {
    +			const { root } = buildTree();
    +			const visited = [];
    +			walkDOM(root, null, {
    +				enter(node) {
    +					visited.push(node.nodeName);
    +					if (node.nodeName === 'childA') {
    +						return undefined; // skip grandchildA1, grandchildA2
    +					}
    +					return 'ctx';
    +				},
    +			});
    +			expect(visited).toContain('root');
    +			expect(visited).toContain('childA');
    +			expect(visited).not.toContain('grandchildA1');
    +			expect(visited).not.toContain('grandchildA2');
    +			// childB and its child are still walked (undefined only skips childA's children)
    +			expect(visited).toContain('childB');
    +			expect(visited).toContain('grandchildB1');
    +		});
    +	});
    +
    +	describe('enter modifies firstChild before descent', () => {
    +		test('walker visits the modified child list when enter adds a child before returning', () => {
    +			const impl = new DOMImplementation();
    +			const doc = impl.createDocument(null, 'root');
    +			const root = doc.documentElement;
    +			const original = doc.createElement('original');
    +			root.appendChild(original);
    +
    +			const visited = [];
    +			walkDOM(root, null, {
    +				enter(node) {
    +					visited.push(node.nodeName);
    +					if (node.nodeName === 'root') {
    +						// Add a new child before returning — walker should see it
    +						root.appendChild(doc.createElement('added'));
    +					}
    +					return 'ctx';
    +				},
    +			});
    +			expect(visited).toContain('added');
    +		});
    +	});
    +});
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

14

News mentions

1