CVE-2026-83614: xmldom Quadratic-time Parsing Vulnerability

HIGH
Advisory Identifier: GHSA-93r5-fhx6-vmg9
CWE-400 CWE-407
Published
Advisory Summary

CVE-2026-83614 is a HIGH quadratic-time parsing vulnerability (CWE-400: Uncontrolled Resource Consumption) affecting xmldom libraries in the npm ecosystem. The issue involves quadratic-time parsing via the malformed-input recovery path — `parseelementstartpart` re-scan and `normalize()` adjacent-text merge. Remediation is available by upgrading affected packages to version 0.8.15, 0.9.12.

CVE-2026-83614 at a Glance

CVE Identifier CVE-2026-83614
GHSA Identifier GHSA-93r5-fhx6-vmg9
Severity HIGH
CVSS v3.1 Base Score N/A
CWE Classification CWE-400: Uncontrolled Resource Consumption
Affected Product xmldom
Package Ecosystem npm
Published Date
Last Updated

How to Fix CVE-2026-83614

To remediate this vulnerability, upgrade all affected xmldom dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

@xmldom/xmldom fixed: 0.8.15
npm install @xmldom/xmldom@0.8.15
@xmldom/xmldom fixed: 0.9.12
npm install @xmldom/xmldom@0.9.12
xmldom fixed: Latest secure release
npm install xmldom@Latest secure release
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls @xmldom/xmldom

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-83614, inspect your project dependencies using npm:

npm ls @xmldom/xmldom

Analyze the terminal output to locate any version of xmldom in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.8.15), your application is affected.

Note: The npm ls @xmldom/xmldom command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-400: Uncontrolled Resource Consumption, where the product does not properly control the allocation and maintenance of a limited resource. In xmldom, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary `xmldom`'s malformed-input **error-recovery path** has two quadratic-time (O(n²)) behaviors that a single crafted input triggers together, so a tiny, highly compressible document (tens of KB) stalls the Node.js event loop for multiple seconds. It is reachable from `DOMParser.parseFromString` under **default options** — i.e. from unauthenticated, network-delivered XML — making this an unauthenticated denial of service. One of the two behaviors, the `normalize()` adjacent-text merge, is **additionally reachable programmatically** — via a plain `normalize()` call on a DOM built with adjacent text nodes, independent of the parser — so its fix must live in `normalize()`, not only in a parser bound. ## Details ### Finding A — `parseElementStartPart` quadratic re-scan A `<` character is not a delimiter in any tag-parsing state, so `parseElementStartPart` scans forward character-by-character over any embedded `<` until it reaches the next `>` (or end of input), then validates the accumulated slice as a tag name and throws `invalid tagName:` on failure. The main loop catches this, reports an `error`, sets `end = -1`, and recovers by advancing a single character (`appendText(Math.max(tagStart, start) + 1)`). With a long run of `<` and a distant `>`, each of the O(n) recovery retries performs an O(n) scan plus an O(n) anchored regex validation over the growing candidate ⇒ **O(n²)**. Code (0.9.x, `bb7a085dc5ba1eea3212388509b97bb4b4af32b9`): - `parseElementStartPart` character scan — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L263-L461 - tag-name validation (`setTagName` → throws `invalid tagName`) — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L886-L891 - main-loop `catch` → `error` + `end = -1` — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L234-L242 - single-character recovery fallback — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L247 Code (0.8.x, `e5c14802592685bb872c042c54c3f73758875c85`): - `parseElementStartPart` — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L227 - `catch` → `error` + `end = -1` — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L202-L208 - recovery fallback — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L213 - `setTagName` validation — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L616-L621 ### Finding B — `normalize()` adjacent-text O(K²) merge `endDocument()` calls `document.normalize()`. For a parent with K adjacent text nodes (produced by the one-character recovery of Finding A), `normalize()` performs K−1 merges. Each merge does a `removeChild` — which re-indexes **all** child nodes of the parent (O(K)) — and an `appendData` — which rebuilds the accumulator string `this.data + text` (O(K)). Total: **O(K²)**. Well-formed XML cannot produce adjacent text-node siblings *through the parser* (each text run is one node; comments, CDATA, PIs, and elements sit between runs), so the **parse-path** trigger for Finding B is the malformed-input recovery that emits single-character text nodes. The same O(K²) merge is, however, independently reachable via the public `normalize()` API on a programmatically built tree (see "Finding B is additionally reachable programmatically" below). Code (0.9.x, `bb7a085dc5ba1eea3212388509b97bb4b4af32b9`): - `endDocument` → `normalize()` — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom-parser.js#L418-L420 - `normalize()` adjacent-text merge — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom.js#L1336-L1356 - `removeChild` re-index-all branch — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom.js#L1788-L1798 - `appendData` string rebuild — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom.js#L2786-L2790 Code (0.8.x, `e5c14802592685bb872c042c54c3f73758875c85`): - `endDocument` → `normalize()` — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/dom-parser.js#L213-L214 - `normalize()` merge — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/dom.js#L529-L549 - `removeChild` re-index-all branch — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/dom.js#L756-L773 - `appendData` string rebuild — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/dom.js#L1533 ### Finding B is additionally reachable programmatically (no parser involved) `Node.prototype.normalize()` is public API on every `Document`/`Element`. A tree built entirely through the ordinary DOM API — `new DOMImplementation().createDocument(...)`, then K× `createTextNode` + `appendChild` on one parent — reaches the **same** O(K²) merge when the application calls `normalize()`, with **no** parsing and **no** error-recovery. The parser is only *one* of the two callers of the vulnerable merge: - the parser's automatic `endDocument()` → `document.normalize()` (the parse-path trigger above), and - any explicit application call to the public `normalize()` on a tree with adjacent text nodes. `XMLSerializer` does **not** call `normalize()`, so serializing an un-merged tree is O(total text), not O(K²); the O(K²) surface is exactly those two `normalize()` callers. Consequently a parser-side bound alone cannot remediate Finding B — the fix must live in `normalize()`. ## Affected Versions Both findings are present across the full published `@xmldom/xmldom` history — both currently-maintained versions (`0.8.x` and `0.9.x`) are affected — and across the retired unscoped `xmldom` line. Finding B's `normalize()` merge is additionally reachable **programmatically**: a direct `normalize()` call on a DOM built with adjacent text nodes hits the same O(K²) merge, independent of the parser — so, unlike Finding A, it does not require the malformed-input recovery path. ## Proof of Concept Default `DOMParser`, no options. The input is trivially compressible (`a<` / `a<>` repeated) and never throws — it is parsed via the recovery path. ```js const { DOMParser } = require('@xmldom/xmldom'); // Silence the expected `error`-level recovery reports (default handler logs // them to console.error without throwing; only fatalError throws). console.error = function () {}; function timeParse(label, xml, mime) { const t0 = process.hrtime.bigint(); new DOMParser().parseFromString(xml, mime); // completes; no exception const ms = Number(process.hrtime.bigint() - t0) / 1e6; console.log(label + ' bytes=' + Buffer.byteLength(xml) + ' time=' + ms.toFixed(1) + ' ms'); } for (const N of [4000, 8000, 16000, 32000]) { // Finding A: long re-scans, O(n^2) during parse. timeParse('A N=' + N, '<r>' + 'a<'.repeat(N) + '</r>', 'text/xml'); // Finding B: short re-scans (cheap parse) but K adjacent text nodes -> O(K^2) in normalize(). timeParse('B N=' + N, '<r>' + 'a<>'.repeat(N) + '</r>', 'text/html'); // Combined: ONE input hits both A and B under the default parser. timeParse('C N=' + N, '<r>' + 'a<'.repeat(N) + '</r>', 'text/xml'); } ``` Measured on Node v18.20.8 (absolute ms vary by host; the load-bearing fact is that doubling the input ~quadruples the time — canonical O(n²)): Finding A, isolated (`"<r>" + "a<"×N + "</r>"`, normalize disabled to isolate the re-scan): | N | input bytes | `@xmldom/xmldom` 0.9.10 | 0.8.13 | |--:|--:|--:|--:| | 2000 | 4007 | 43 ms | 37 ms | | 4000 | 8007 | 129 ms | 106 ms | | 8000 | 16007 | 434 ms | 424 ms | | 16000 | 32007 | 1629 ms | 1611 ms | Finding B, isolated (`"<r>" + "a<>"×N + "</r>"`, time attributable to `normalize()`): | K (N) | input bytes | 0.9.10 | 0.8.13 | |--:|--:|--:|--:| | 4000 | 12007 | 120 ms | 165 ms | | 8000 | 24007 | 589 ms | 771 ms | | 16000 | 48007 | 3142 ms | 4448 ms | | 32000 | 96007 | 12127 ms | 12951 ms | Combined (default parser, both findings; `"<r>" + "a<"×N + "</r>"`): | N | input bytes | 0.9.10 | 0.8.13 | |--:|--:|--:|--:| | 4000 | 8007 | 341 ms | 397 ms | | 8000 | 16007 | 1894 ms | 1641 ms | | 16000 | 32007 | 4398 ms | 7661 ms | ~32 KB of input → several seconds of single-threaded event-loop stall. ### Finding B via the public `normalize()` API (no parser) ```js const { DOMImplementation } = require('@xmldom/xmldom'); function timeNormalize(K) { const doc = new DOMImplementation().createDocument(null, 'r', null); const el = doc.documentElement; for (let i = 0; i < K; i++) el.appendChild(doc.createTextNode('x')); // K adjacent text nodes const t0 = process.hrtime.bigint(); doc.normalize(); // O(K^2) merge — no parsing involved const ms = Number(process.hrtime.bigint() - t0) / 1e6; console.log('K=' + K + ' time=' + ms.toFixed(1) + ' ms'); } for (const K of [2000, 4000, 8000, 16000, 32000]) timeNormalize(K); ``` Measured on Node v18.20.8 (doubling K ~quadruples the time — O(K²)): | K | 0.9.10 | 0.8.13 | |--:|--:|--:| | 2000 | 5.7 ms | 5.6 ms | | 32000 | 1263 ms | 1704 ms | This path is reachable by any application that builds a DOM from attacker-influenced data and calls `normalize()`, entirely independent of `DOMParser`. ## Impact Availability only: a single parse of a small crafted document blocks the Node.js event loop for the duration of the quadratic work (multiple seconds at tens of KB; larger inputs scale as O(n²)). No memory blow-up beyond transient strings, no data exposure, no integrity impact. Because XML is routinely accepted from untrusted sources and parsed with default options, one request can stall a server. The payloads are highly compressible, so any endpoint accepting compressed XML faces additional amplification. Finding B is additionally reachable via an explicit `normalize()` call on a programmatically built DOM (see Proof of Concept), so applications that construct a document from attacker-influenced data and normalize it are exposed even without parsing. ## Severity note The complexity is **quadratic**, not exponential, so a multi-second stall requires tens-to-hundreds of KB of input. `VA:H` reflects that xmldom applies **no** input-size limit and the path runs on default-options parsing, so a single unbounded parse can fully stall the event loop. ## Fix Applied Two independent, non-breaking fixes shipped together — each alone leaves the other's quadratic cost dominating the default parse. Finding A — terminate the malformed tag-name scan at an embedded `<`, so error recovery is linear instead of O(n²). DOM output is unchanged; only the reported error-message text differs (error strings are not a semver contract). Finding B — merge adjacent text nodes in `normalize()` in O(K) instead of O(K²), which also closes the same slowdown reachable programmatically through a direct `normalize()` call. Both ship on both maintained versions.

CVE-2026-83614 Affected xmldom Versions

3 component(s)
Fixed Versions:
0.8.150.9.12
Product / Package Ecosystem Affected Versions Fixed Version
@xmldom/xmldom
npm
≥ 0.7.0
< 0.8.15
0.8.15
@xmldom/xmldom
npm
≥ 0.9.0
< 0.9.12
0.9.12
xmldom
npm
≥ 0.3.0
≤ 0.6.0

References

Frequently Asked Questions

What is CVE-2026-83614?
CVE-2026-83614 is a security vulnerability in xmldom involving Quadratic-time parsing via the malformed-input recovery path — `parseElementStartPart` re-scan and `normalize()` adjacent-text merge under CWE-400: Uncontrolled Resource Consumption.
Which xmldom versions are affected?
Affected versions include releases prior to 0.8.15, 0.9.12 across affected packages: @xmldom/xmldom, xmldom.
What version fixes CVE-2026-83614?
Upgrading to 0.8.15, 0.9.12 (or the latest patched release for your respective branch) resolves the vulnerability.
Is @xmldom/xmldom affected?
Yes, @xmldom/xmldom is affected in releases prior to 0.8.15, 0.9.12. Upgrade your dependency to 0.8.15, 0.9.12 or later.
Is xmldom affected?
Yes, xmldom is affected in releases prior to Latest secure release. Upgrade your dependency to Latest secure release or later.
What CWE is associated with CVE-2026-83614?
CVE-2026-83614 is classified under CWE-400: Uncontrolled Resource Consumption (The product does not properly control the allocation and maintenance of a limited resource.).
How can I check npm dependencies for CVE-2026-83614?
Run the dependency tree command: 'npm ls @xmldom/xmldom'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-93r5-fhx6-vmg9",
  "modified" : "2026-09-08T21:00:41Z",
  "published" : "2026-09-08T21:00:41Z",
  "aliases" : [ "CVE-2026-83614" ],
  "summary" : "xmldom: Quadratic-time parsing via the malformed-input recovery path — `parseElementStartPart` re-scan and `normalize()` adjacent-text merge",
  "details" : "## Summary\n\n`xmldom`'s malformed-input **error-recovery path** has two quadratic-time (O(n²)) behaviors that a\nsingle crafted input triggers together, so a tiny, highly compressible document (tens of KB) stalls\nthe Node.js event loop for multiple seconds. It is reachable from `DOMParser.parseFromString` under\n**default options** — i.e. from unauthenticated, network-delivered XML — making this an unauthenticated\ndenial of service. One of the two behaviors, the `normalize()` adjacent-text merge, is **additionally\nreachable programmatically** — via a plain `normalize()` call on a DOM built with adjacent text nodes,\nindependent of the parser — so its fix must live in `normalize()`, not only in a parser bound.\n\n## Details\n\n### Finding A — `parseElementStartPart` quadratic re-scan\n\nA `<` character is not a delimiter in any tag-parsing state, so `parseElementStartPart` scans\nforward character-by-character over any embedded `<` until it reaches the next `>` (or end of\ninput), then validates the accumulated slice as a tag name and throws `invalid tagName:` on failure.\nThe main loop catches this, reports an `error`, sets `end = -1`, and recovers by advancing a single\ncharacter (`appendText(Math.max(tagStart, start) + 1)`). With a long run of `<` and a distant `>`,\neach of the O(n) recovery retries performs an O(n) scan plus an O(n) anchored regex validation over\nthe growing candidate ⇒ **O(n²)**.\n\nCode (0.9.x, `bb7a085dc5ba1eea3212388509b97bb4b4af32b9`):\n\n- `parseElementStartPart` character scan — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L263-L461\n- tag-name validation (`setTagName` → throws `invalid tagName`) — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L886-L891\n- main-loop `catch` → `error` + `end = -1` — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L234-L242\n- single-character recovery fallback — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L247\n\nCode (0.8.x, `e5c14802592685bb872c042c54c3f73758875c85`):\n\n- `parseElementStartPart` — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L227\n- `catch` → `error` + `end = -1` — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L202-L208\n- recovery fallback — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L213\n- `setTagName` validation — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L616-L621\n\n### Finding B — `normalize()` adjacent-text O(K²) merge\n\n`endDocument()` calls `document.normalize()`. For a parent with K adjacent text nodes (produced by\nthe one-character recovery of Finding A), `normalize()` performs K−1 merges. Each merge does a\n`removeChild` — which re-indexes **all** child nodes of the parent (O(K)) — and an `appendData` —\nwhich rebuilds the accumulator string `this.data + text` (O(K)). Total: **O(K²)**.\n\nWell-formed XML cannot produce adjacent text-node siblings *through the parser* (each text run is one\nnode; comments, CDATA, PIs, and elements sit between runs), so the **parse-path** trigger for Finding B\nis the malformed-input recovery that emits single-character text nodes. The same O(K²) merge is,\nhowever, independently reachable via the public `normalize()` API on a programmatically built tree\n(see \"Finding B is additionally reachable programmatically\" below).\n\nCode (0.9.x, `bb7a085dc5ba1eea3212388509b97bb4b4af32b9`):\n\n- `endDocument` → `normalize()` — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom-parser.js#L418-L420\n- `normalize()` adjacent-text merge — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom.js#L1336-L1356\n- `removeChild` re-index-all branch — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom.js#L1788-L1798\n- `appendData` string rebuild — https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom.js#L2786-L2790\n\nCode (0.8.x, `e5c14802592685bb872c042c54c3f73758875c85`):\n\n- `endDocument` → `normalize()` — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/dom-parser.js#L213-L214\n- `normalize()` merge — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/dom.js#L529-L549\n- `removeChild` re-index-all branch — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/dom.js#L756-L773\n- `appendData` string rebuild — https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/dom.js#L1533\n\n### Finding B is additionally reachable programmatically (no parser involved)\n\n`Node.prototype.normalize()` is public API on every `Document`/`Element`. A tree built entirely through\nthe ordinary DOM API — `new DOMImplementation().createDocument(...)`, then K× `createTextNode` +\n`appendChild` on one parent — reaches the **same** O(K²) merge when the application calls `normalize()`,\nwith **no** parsing and **no** error-recovery. The parser is only *one* of the two callers of the\nvulnerable merge:\n\n- the parser's automatic `endDocument()` → `document.normalize()` (the parse-path trigger above), and\n- any explicit application call to the public `normalize()` on a tree with adjacent text nodes.\n\n`XMLSerializer` does **not** call `normalize()`, so serializing an un-merged tree is O(total text), not\nO(K²); the O(K²) surface is exactly those two `normalize()` callers. Consequently a parser-side bound\nalone cannot remediate Finding B — the fix must live in `normalize()`.\n\n## Affected Versions\n\nBoth findings are present across the full published `@xmldom/xmldom` history — both\ncurrently-maintained versions (`0.8.x` and `0.9.x`) are affected — and across the retired unscoped\n`xmldom` line. Finding B's `normalize()` merge is additionally reachable **programmatically**: a\ndirect `normalize()` call on a DOM built with adjacent text nodes hits the same O(K²) merge,\nindependent of the parser — so, unlike Finding A, it does not require the malformed-input recovery\npath.\n\n## Proof of Concept\n\nDefault `DOMParser`, no options. The input is trivially compressible (`a<` / `a<>` repeated) and\nnever throws — it is parsed via the recovery path.\n\n```js\nconst { DOMParser } = require('@xmldom/xmldom');\n\n// Silence the expected `error`-level recovery reports (default handler logs\n// them to console.error without throwing; only fatalError throws).\nconsole.error = function () {};\n\nfunction timeParse(label, xml, mime) {\n  const t0 = process.hrtime.bigint();\n  new DOMParser().parseFromString(xml, mime); // completes; no exception\n  const ms = Number(process.hrtime.bigint() - t0) / 1e6;\n  console.log(label + '  bytes=' + Buffer.byteLength(xml) + '  time=' + ms.toFixed(1) + ' ms');\n}\n\nfor (const N of [4000, 8000, 16000, 32000]) {\n  // Finding A: long re-scans, O(n^2) during parse.\n  timeParse('A N=' + N, '<r>' + 'a<'.repeat(N) + '</r>', 'text/xml');\n  // Finding B: short re-scans (cheap parse) but K adjacent text nodes -> O(K^2) in normalize().\n  timeParse('B N=' + N, '<r>' + 'a<>'.repeat(N) + '</r>', 'text/html');\n  // Combined: ONE input hits both A and B under the default parser.\n  timeParse('C N=' + N, '<r>' + 'a<'.repeat(N) + '</r>', 'text/xml');\n}\n```\n\nMeasured on Node v18.20.8 (absolute ms vary by host; the load-bearing fact is that doubling the\ninput ~quadruples the time — canonical O(n²)):\n\nFinding A, isolated (`\"<r>\" + \"a<\"×N + \"</r>\"`, normalize disabled to isolate the re-scan):\n\n| N | input bytes | `@xmldom/xmldom` 0.9.10 | 0.8.13 |\n|--:|--:|--:|--:|\n| 2000 | 4007 | 43 ms | 37 ms |\n| 4000 | 8007 | 129 ms | 106 ms |\n| 8000 | 16007 | 434 ms | 424 ms |\n| 16000 | 32007 | 1629 ms | 1611 ms |\n\nFinding B, isolated (`\"<r>\" + \"a<>\"×N + \"</r>\"`, time attributable to `normalize()`):\n\n| K (N) | input bytes | 0.9.10 | 0.8.13 |\n|--:|--:|--:|--:|\n| 4000 | 12007 | 120 ms | 165 ms |\n| 8000 | 24007 | 589 ms | 771 ms |\n| 16000 | 48007 | 3142 ms | 4448 ms |\n| 32000 | 96007 | 12127 ms | 12951 ms |\n\nCombined (default parser, both findings; `\"<r>\" + \"a<\"×N + \"</r>\"`):\n\n| N | input bytes | 0.9.10 | 0.8.13 |\n|--:|--:|--:|--:|\n| 4000 | 8007 | 341 ms | 397 ms |\n| 8000 | 16007 | 1894 ms | 1641 ms |\n| 16000 | 32007 | 4398 ms | 7661 ms |\n\n~32 KB of input → several seconds of single-threaded event-loop stall.\n\n### Finding B via the public `normalize()` API (no parser)\n\n```js\nconst { DOMImplementation } = require('@xmldom/xmldom');\n\nfunction timeNormalize(K) {\n  const doc = new DOMImplementation().createDocument(null, 'r', null);\n  const el = doc.documentElement;\n  for (let i = 0; i < K; i++) el.appendChild(doc.createTextNode('x')); // K adjacent text nodes\n  const t0 = process.hrtime.bigint();\n  doc.normalize();                                    // O(K^2) merge — no parsing involved\n  const ms = Number(process.hrtime.bigint() - t0) / 1e6;\n  console.log('K=' + K + '  time=' + ms.toFixed(1) + ' ms');\n}\nfor (const K of [2000, 4000, 8000, 16000, 32000]) timeNormalize(K);\n```\n\nMeasured on Node v18.20.8 (doubling K ~quadruples the time — O(K²)):\n\n| K | 0.9.10 | 0.8.13 |\n|--:|--:|--:|\n| 2000 | 5.7 ms | 5.6 ms |\n| 32000 | 1263 ms | 1704 ms |\n\nThis path is reachable by any application that builds a DOM from attacker-influenced data and calls\n`normalize()`, entirely independent of `DOMParser`.\n\n## Impact\n\nAvailability only: a single parse of a small crafted document blocks the Node.js event loop for the\nduration of the quadratic work (multiple seconds at tens of KB; larger inputs scale as O(n²)). No\nmemory blow-up beyond transient strings, no data exposure, no integrity impact. Because XML is\nroutinely accepted from untrusted sources and parsed with default options, one request can stall a\nserver. The payloads are highly compressible, so any endpoint accepting compressed XML faces\nadditional amplification. Finding B is additionally reachable via an explicit `normalize()` call on a\nprogrammatically built DOM (see Proof of Concept), so applications that construct a document from attacker-influenced\ndata and normalize it are exposed even without parsing.\n\n## Severity note\n\nThe complexity is **quadratic**, not exponential, so a multi-second stall requires\ntens-to-hundreds of KB of input. `VA:H` reflects that xmldom applies **no** input-size limit and the\npath runs on default-options parsing, so a single unbounded parse can fully stall the event loop.\n\n## Fix Applied\n\nTwo independent, non-breaking fixes shipped together — each alone leaves the other's quadratic cost dominating the default parse.\nFinding A — terminate the malformed tag-name scan at an embedded `<`, so error recovery is linear instead of O(n²). DOM output is unchanged; only the reported error-message text differs (error strings are not a semver contract).\nFinding B — merge adjacent text nodes in `normalize()` in O(K) instead of O(K²), which also closes the same slowdown reachable programmatically through a direct `normalize()` call. Both ship on both maintained versions.",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "@xmldom/xmldom"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0.7.0"
      }, {
        "fixed" : "0.8.15"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.8.14"
    }
  }, {
    "package" : {
      "ecosystem" : "npm",
      "name" : "@xmldom/xmldom"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0.9.0"
      }, {
        "fixed" : "0.9.12"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.9.11"
    }
  }, {
    "package" : {
      "ecosystem" : "npm",
      "name" : "xmldom"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0.3.0"
      }, {
        "last_affected" : "0.6.0"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/xmldom/xmldom/security/advisories/GHSA-93r5-fhx6-vmg9"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-83614"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/xmldom/xmldom/pull/1071"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/xmldom/xmldom/pull/1072"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/xmldom/xmldom/commit/0748720b620555f8c222782dcab575cf0cf403b4"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/xmldom/xmldom/commit/f40ccb861eee0acbf5ee4feb9a34932e87b329c9"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/xmldom/xmldom"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/xmldom/xmldom/releases/tag/0.8.15"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/xmldom/xmldom/releases/tag/0.9.12"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-400", "CWE-407" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-08T21:00:41Z",
    "nvd_published_at" : "2026-09-01T15:17:39Z"
  }
}