GHSA-93R5-FHX6-VMG9

Vulnerability from github – Published: 2026-09-08 21:00 – Updated: 2026-09-08 21:00
VLAI
Summary
xmldom: Quadratic-time parsing via the malformed-input recovery path — `parseElementStartPart` re-scan and `normalize()` adjacent-text merge
Details

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.

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)

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.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.8.14"
      },
      "package": {
        "ecosystem": "npm",
        "name": "@xmldom/xmldom"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.7.0"
            },
            {
              "fixed": "0.8.15"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.9.11"
      },
      "package": {
        "ecosystem": "npm",
        "name": "@xmldom/xmldom"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.9.0"
            },
            {
              "fixed": "0.9.12"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "xmldom"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.3.0"
            },
            {
              "last_affected": "0.6.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-83614"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-400",
      "CWE-407"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-08T21:00:41Z",
    "nvd_published_at": "2026-09-01T15:17:39Z",
    "severity": "HIGH"
  },
  "details": "## Summary\n\n`xmldom`\u0027s malformed-input **error-recovery path** has two quadratic-time (O(n\u00b2)) 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** \u2014 i.e. from unauthenticated, network-delivered XML \u2014 making this an unauthenticated\ndenial of service. One of the two behaviors, the `normalize()` adjacent-text merge, is **additionally\nreachable programmatically** \u2014 via a plain `normalize()` call on a DOM built with adjacent text nodes,\nindependent of the parser \u2014 so its fix must live in `normalize()`, not only in a parser bound.\n\n## Details\n\n### Finding A \u2014 `parseElementStartPart` quadratic re-scan\n\nA `\u003c` character is not a delimiter in any tag-parsing state, so `parseElementStartPart` scans\nforward character-by-character over any embedded `\u003c` until it reaches the next `\u003e` (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 `\u003c` and a distant `\u003e`,\neach of the O(n) recovery retries performs an O(n) scan plus an O(n) anchored regex validation over\nthe growing candidate \u21d2 **O(n\u00b2)**.\n\nCode (0.9.x, `bb7a085dc5ba1eea3212388509b97bb4b4af32b9`):\n\n- `parseElementStartPart` character scan \u2014 https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L263-L461\n- tag-name validation (`setTagName` \u2192 throws `invalid tagName`) \u2014 https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L886-L891\n- main-loop `catch` \u2192 `error` + `end = -1` \u2014 https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L234-L242\n- single-character recovery fallback \u2014 https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/sax.js#L247\n\nCode (0.8.x, `e5c14802592685bb872c042c54c3f73758875c85`):\n\n- `parseElementStartPart` \u2014 https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L227\n- `catch` \u2192 `error` + `end = -1` \u2014 https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L202-L208\n- recovery fallback \u2014 https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L213\n- `setTagName` validation \u2014 https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L616-L621\n\n### Finding B \u2014 `normalize()` adjacent-text O(K\u00b2) 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\u22121 merges. Each merge does a\n`removeChild` \u2014 which re-indexes **all** child nodes of the parent (O(K)) \u2014 and an `appendData` \u2014\nwhich rebuilds the accumulator string `this.data + text` (O(K)). Total: **O(K\u00b2)**.\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\u00b2) 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` \u2192 `normalize()` \u2014 https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom-parser.js#L418-L420\n- `normalize()` adjacent-text merge \u2014 https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom.js#L1336-L1356\n- `removeChild` re-index-all branch \u2014 https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom.js#L1788-L1798\n- `appendData` string rebuild \u2014 https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom.js#L2786-L2790\n\nCode (0.8.x, `e5c14802592685bb872c042c54c3f73758875c85`):\n\n- `endDocument` \u2192 `normalize()` \u2014 https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/dom-parser.js#L213-L214\n- `normalize()` merge \u2014 https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/dom.js#L529-L549\n- `removeChild` re-index-all branch \u2014 https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/dom.js#L756-L773\n- `appendData` string rebuild \u2014 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 \u2014 `new DOMImplementation().createDocument(...)`, then K\u00d7 `createTextNode` +\n`appendChild` on one parent \u2014 reaches the **same** O(K\u00b2) 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\u0027s automatic `endDocument()` \u2192 `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\u00b2); the O(K\u00b2) surface is exactly those two `normalize()` callers. Consequently a parser-side bound\nalone cannot remediate Finding B \u2014 the fix must live in `normalize()`.\n\n## Affected Versions\n\nBoth findings are present across the full published `@xmldom/xmldom` history \u2014 both\ncurrently-maintained versions (`0.8.x` and `0.9.x`) are affected \u2014 and across the retired unscoped\n`xmldom` line. Finding B\u0027s `normalize()` merge is additionally reachable **programmatically**: a\ndirect `normalize()` call on a DOM built with adjacent text nodes hits the same O(K\u00b2) merge,\nindependent of the parser \u2014 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\u003c` / `a\u003c\u003e` repeated) and\nnever throws \u2014 it is parsed via the recovery path.\n\n```js\nconst { DOMParser } = require(\u0027@xmldom/xmldom\u0027);\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 + \u0027  bytes=\u0027 + Buffer.byteLength(xml) + \u0027  time=\u0027 + ms.toFixed(1) + \u0027 ms\u0027);\n}\n\nfor (const N of [4000, 8000, 16000, 32000]) {\n  // Finding A: long re-scans, O(n^2) during parse.\n  timeParse(\u0027A N=\u0027 + N, \u0027\u003cr\u003e\u0027 + \u0027a\u003c\u0027.repeat(N) + \u0027\u003c/r\u003e\u0027, \u0027text/xml\u0027);\n  // Finding B: short re-scans (cheap parse) but K adjacent text nodes -\u003e O(K^2) in normalize().\n  timeParse(\u0027B N=\u0027 + N, \u0027\u003cr\u003e\u0027 + \u0027a\u003c\u003e\u0027.repeat(N) + \u0027\u003c/r\u003e\u0027, \u0027text/html\u0027);\n  // Combined: ONE input hits both A and B under the default parser.\n  timeParse(\u0027C N=\u0027 + N, \u0027\u003cr\u003e\u0027 + \u0027a\u003c\u0027.repeat(N) + \u0027\u003c/r\u003e\u0027, \u0027text/xml\u0027);\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 \u2014 canonical O(n\u00b2)):\n\nFinding A, isolated (`\"\u003cr\u003e\" + \"a\u003c\"\u00d7N + \"\u003c/r\u003e\"`, 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 (`\"\u003cr\u003e\" + \"a\u003c\u003e\"\u00d7N + \"\u003c/r\u003e\"`, 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; `\"\u003cr\u003e\" + \"a\u003c\"\u00d7N + \"\u003c/r\u003e\"`):\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 \u2192 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(\u0027@xmldom/xmldom\u0027);\n\nfunction timeNormalize(K) {\n  const doc = new DOMImplementation().createDocument(null, \u0027r\u0027, null);\n  const el = doc.documentElement;\n  for (let i = 0; i \u003c K; i++) el.appendChild(doc.createTextNode(\u0027x\u0027)); // K adjacent text nodes\n  const t0 = process.hrtime.bigint();\n  doc.normalize();                                    // O(K^2) merge \u2014 no parsing involved\n  const ms = Number(process.hrtime.bigint() - t0) / 1e6;\n  console.log(\u0027K=\u0027 + K + \u0027  time=\u0027 + ms.toFixed(1) + \u0027 ms\u0027);\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 \u2014 O(K\u00b2)):\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\u00b2)). 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 \u2014 each alone leaves the other\u0027s quadratic cost dominating the default parse.\nFinding A \u2014 terminate the malformed tag-name scan at an embedded `\u003c`, so error recovery is linear instead of O(n\u00b2). DOM output is unchanged; only the reported error-message text differs (error strings are not a semver contract).\nFinding B \u2014 merge adjacent text nodes in `normalize()` in O(K) instead of O(K\u00b2), which also closes the same slowdown reachable programmatically through a direct `normalize()` call. Both ship on both maintained versions.",
  "id": "GHSA-93r5-fhx6-vmg9",
  "modified": "2026-09-08T21:00:41Z",
  "published": "2026-09-08T21:00:41Z",
  "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"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "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",
      "type": "CVSS_V4"
    }
  ],
  "summary": "xmldom: Quadratic-time parsing via the malformed-input recovery path \u2014 `parseElementStartPart` re-scan and `normalize()` adjacent-text merge"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…