{"id":"CVE-2026-41673","aliases":["GHSA-2v35-w6hq-6mfw"],"url":"https://o3.security/vulnerability/CVE-2026-41673","summary":"xmldom: Denial of service via uncontrolled recursion in XML serialization","details":"## Summary\n\nSeven recursive traversals in `lib/dom.js` operate without a depth limit. A sufficiently deeply\nnested DOM tree causes a `RangeError: Maximum call stack size exceeded`, crashing the application.\n\n**Reported operations:**\n- `Node.prototype.normalize()` — reported by @praveen-kv (email 2026-04-05) and @KarimTantawey (GHSA-fwmp-8wwc-qhv6, via `DOMParser.parseFromString()`)\n- `XMLSerializer.serializeToString()` — reported by @Jvr2022 (GHSA-2v35-w6hq-6mfw) and @KarimTantawey (GHSA-j2hf-fqwf-rrjf)\n\n**Additionally, discovered in research:**\n- `Element.getElementsByTagName()` / `getElementsByTagNameNS()` / `getElementsByClassName()` / `getElementById()`\n- `Node.cloneNode(true)`\n- `Document.importNode(node, true)`\n- `node.textContent` (getter)\n- `Node.isEqualNode(other)`\n\nAll seven share the same root cause: pure-JavaScript recursive tree traversal with no depth guard.\nA single deeply nested document (parsed successfully) triggers any or all of these operations.\n\n---\n\n## Details\n\n### Root cause\n\n`lib/dom.js` implements DOM tree traversals as depth-first recursive functions. Each level of\nelement nesting adds one JavaScript call frame. The JS engine's call stack is finite; once\nexhausted, a `RangeError: Maximum call stack size exceeded` is thrown. This error may not be\ncaught reliably at stack-exhaustion depths because the catch handler itself requires stack\nframes to execute — especially in async scenarios, where an uncaught `RangeError` inside a\ncallback or promise chain can crash the entire Node.js process.\n\nParsing a deeply nested document **succeeds** — the SAX parser in `lib/sax.js` is iterative.\nThe crash occurs during subsequent operations on the parsed DOM.\n\n### `Node.prototype.normalize()` — reported by @praveen-kv\n\n[`lib/dom.js:1296–1308`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L1296-L1308) (main):\n\n```js\nnormalize: function () {\n    var child = this.firstChild;\n    while (child) {\n        var next = child.nextSibling;\n        if (next && next.nodeType == TEXT_NODE && child.nodeType == TEXT_NODE) {\n            this.removeChild(next);\n            child.appendData(next.data);\n        } else {\n            child.normalize();   // recursive call — no depth guard\n            child = next;\n        }\n    }\n},\n```\n\nCrash threshold (Node.js 18, default stack): ~10,000 levels.\n\n### `XMLSerializer.serializeToString()` — reported by @Jvr2022\n\n[`lib/dom.js:2790–2974`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L2790-L2974) (main):\nThe internal `serializeToString` worker recurses into child nodes at four call sites, each\npassing a `visibleNamespaces.slice()` copy. The per-frame allocation causes earlier stack\nexhaustion than `normalize()`.\n\nCrash threshold (Node.js 18, default stack): ~5,000 levels.\n\n### Additional recursive entry points\n\nAll five crash at ~10,000 levels on Node.js 18.\n\n| Function                    | Definition                                                                                                           | Public API entry point(s)                                                                            | Crash depth (Node.js 18) |\n|-----------------------------|----------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------------------------|\n| `_visitNode`                | [`lib/dom.js:1529`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L1529) | `getElementsByTagName()`, `getElementsByTagNameNS()`, `getElementsByClassName()`, `getElementById()` | ~10,000 levels           |\n| `cloneNode` (module fn)     | [`lib/dom.js:3037`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L3037) | `Node.prototype.cloneNode(true)`                                                                     | ~10,000 levels           |\n| `importNode` (module fn)    | [`lib/dom.js:2975`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L2975) | `Document.prototype.importNode(node, true)`                                                          | ~10,000 levels           |\n| `getTextContent` (inner fn) | [`lib/dom.js:3130`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L3130) | `node.textContent` (getter)                                                                          | ~10,000 levels           |\n| `isEqualNode`               | [`lib/dom.js:1120`](https://github.com/xmldom/xmldom/blob/9ef2fd297ca527a05ecb11979850317a927cd20c/lib/dom.js#L1120) | `Node.prototype.isEqualNode(other)`                                                                  | ~10,000 levels           |\n\nBoth active branches (`main` and `release-0.8.x`) are identically affected. The unscoped `xmldom`\npackage (≤ 0.6.0) carries the same recursive patterns from its initial commit.\n\n### Browser behavior\n\nTested with Chromium 147 (Playwright headless). Chromium's native C++ implementations of all\nseven DOM methods are **iterative** — they traverse the DOM without consuming JS call stack frames.\nAll seven succeed at depths up to 20,000 without any crash.\n\nWhen `@xmldom/xmldom` is bundled and run in a browser context the same recursive JS code executes\nunder the browser's V8 stack limit (~12,000–13,000 frames). The crash thresholds are similar to\nthose observed on Node.js 18 (~5,000 for `serializeToString`, ~10,000 for the remaining six).\n\nThe vulnerability is specific to xmldom's pure-JavaScript recursive implementation, not an\ninherent property of the DOM operations.\n\n---\n\n## PoC\n\n### `normalize()` (from @praveen-kv report, 2026-04-05)\n\n```js\nconst { DOMParser } = require('@xmldom/xmldom');\n\nfunction generateNestedXML(depth) {\n    return '<root>' + '<a>'.repeat(depth) + 'text' + '</a>'.repeat(depth) + '</root>';\n}\n\nconst doc = new DOMParser().parseFromString(generateNestedXML(10000), 'text/xml');\ndoc.documentElement.normalize();\n// RangeError: Maximum call stack size exceeded\n```\n\n### `XMLSerializer.serializeToString()` (from GHSA-2v35-w6hq-6mfw)\n\n```js\nconst { DOMParser, XMLSerializer } = require('@xmldom/xmldom');\n\nconst depth = 5000;\nconst xml = '<a>'.repeat(depth) + '</a>'.repeat(depth);\nconst doc = new DOMParser().parseFromString(xml, 'text/xml');\nnew XMLSerializer().serializeToString(doc);\n// RangeError: Maximum call stack size exceeded\n```\n\nThe other methods have been verified using similar pocs.\n\n---\n\n## Impact\n\nAny service that accepts attacker-controlled XML and subsequently calls any of the seven affected\nDOM operations can be forced into a reliable denial of service with a single crafted payload.\n\nThe immediate result is an uncaught `RangeError` and failed request processing. In deployments\nwhere uncaught exceptions terminate the worker or process, the impact can extend beyond a single\nrequest and disrupt service availability more broadly.\n\nNo authentication, special options, or invalid XML is required. A valid, deeply nested XML\ndocument is enough.\n\n---\n\n## Disclosure\n\nThe `normalize()` vector was publicly disclosed at 2026-04-06T11:25:07Z via\n[xmldom/xmldom#987](https://github.com/xmldom/xmldom/pull/987) (closed without merge).\n`serializeToString()` and the five additional recursive entry points were not mentioned in that PR.\n\n---\n\n## Fix Applied\n\nAll seven affected traversals have been converted from recursive to iterative implementations, eliminating call-stack consumption on deep trees.\n\n### `walkDOM` utility\n\nA 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.\n\nThe following six operations are re-implemented on top of `walkDOM`:\n\n| Operation | Public entry point(s) |\n|---|---|\n| `_visitNode` helper | `getElementsByTagName()`, `getElementsByTagNameNS()`, `getElementsByClassName()`, `getElementById()` |\n| `getTextContent` inner function | `node.textContent` getter |\n| `cloneNode` module function | `Node.prototype.cloneNode(true)` |\n| `importNode` module function | `Document.prototype.importNode(node, true)` |\n| `serializeToString` worker | `XMLSerializer.prototype.serializeToString()`, `Node.prototype.toString()`, `NodeList.prototype.toString()` |\n| `normalize` | `Node.prototype.normalize()` |\n\n`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.\n\n### Custom iterative loop for `isEqualNode`\n\nOne function cannot use `walkDOM`:\n\n**`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.\n\n### After the fix\n\nAll 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.","published":"2026-05-07T03:40:28.378Z","modified":"2026-08-30T03:30:27.740943443Z","cvss":null,"epss":{"score":0.00643,"percentile":0.492,"asOf":"2026-09-17"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"@xmldom/xmldom","fixedVersion":"0.8.13"},{"ecosystem":"npm","name":"@xmldom/xmldom","fixedVersion":"0.9.10"},{"ecosystem":"npm","name":"xmldom","fixedVersion":null}],"fix":{"url":"https://github.com/xmldom/xmldom/commit/17678a2a73ecbd1a2da90f3d47dc23da9cef81aa","label":"xmldom/xmldom@17678a2"},"references":[{"type":"WEB","url":"https://github.com/xmldom/xmldom/releases/tag/0.8.13"},{"type":"WEB","url":"https://github.com/xmldom/xmldom/releases/tag/0.9.10"},{"type":"WEB","url":"https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-41673.json"},{"type":"ADVISORY","url":"https://access.redhat.com/errata/RHSA-2026:26234"},{"type":"ADVISORY","url":"https://access.redhat.com/errata/RHSA-2026:60520"},{"type":"ADVISORY","url":"https://access.redhat.com/security/cve/CVE-2026-41673"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/41xxx/CVE-2026-41673.json"},{"type":"ADVISORY","url":"https://github.com/xmldom/xmldom/security/advisories/GHSA-2v35-w6hq-6mfw"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-41673"},{"type":"REPORT","url":"https://bugzilla.redhat.com/show_bug.cgi?id=2467630"},{"type":"FIX","url":"https://github.com/xmldom/xmldom/commit/17678a2a73ecbd1a2da90f3d47dc23da9cef81aa"},{"type":"FIX","url":"https://github.com/xmldom/xmldom/commit/291257493cb0eb6980eda83b162a9c4e6d7d2597"},{"type":"FIX","url":"https://github.com/xmldom/xmldom/commit/2d6d6916ed8a4c223db1f6d7560ab4544c465b0f"},{"type":"FIX","url":"https://github.com/xmldom/xmldom/commit/430357c7b6333108856e917bf2367afe5ceb6f8a"},{"type":"FIX","url":"https://github.com/xmldom/xmldom/commit/4845ef109221df0890825de2822fbe77afba3afe"},{"type":"FIX","url":"https://github.com/xmldom/xmldom/commit/8834218c85ac2a4d757b9587c9028e67c2f7b6c3"},{"type":"FIX","url":"https://github.com/xmldom/xmldom/commit/8b7cfd1491314abdc347261921d7334ff15f7112"},{"type":"FIX","url":"https://github.com/xmldom/xmldom/commit/b0620383abc1df067f3ce1014c43ae1bc1161eeb"},{"type":"FIX","url":"https://github.com/xmldom/xmldom/commit/e6edcab6bef5bcdba0b220bb35442aa72f452b84"},{"type":"PACKAGE","url":"https://github.com/xmldom/xmldom"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-30T03:30:27.740943443Z"}}