Summary
@xmldom/xmldom — the pure-JavaScript DOMParser/XMLSerializer implementation that a surprising amount of the Node.js tooling ecosystem depends on — was resolved at 0.9.10 in this project's dependency tree. That version is affected by CVE-2026-83606: a regular expression in the processing-instruction parsing path backtracks catastrophically on crafted input. The lockfile now resolves 0.9.12, which carries the fix.
Introduction
@xmldom/xmldom exists to parse XML you did not write. That is its entire job: you hand DOMParser.prototype.parseFromString() a string of bytes that arrived from a file upload, a SOAP response, a SAML assertion, a plist, a mobile-app config, or an RSS feed, and it hands you back a DOM. Every one of those inputs is, from the parser's perspective, attacker-influenced.
CVE-2026-83606 lives in the part of the parser that handles processing instructions — the <?target data?> construct that most developers only ever see as the XML declaration itself:
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="t.xsl"?>
The tokenizer has to find where the PI target ends, where the data begins, and where the closing ?> is. In @xmldom/xmldom 0.9.10 and earlier, that scan is driven by a regular expression whose quantifiers can overlap: when the input contains a long run of PI-ish characters and the closing ?> never arrives (or arrives only after a lot of ambiguous filler), the regex engine explores an exploding number of ways to split the same substring before it finally gives up. The result is not a crash and not a wrong answer. It is a function call that simply does not return for a long time.
In Node.js, "does not return for a long time" is the interesting part. There is one thread running your JavaScript. A parseFromString() call that spends 30 seconds backtracking is 30 seconds in which your HTTP server accepts no connections, answers no health checks, and processes no queue messages.
This upgrade is a dependency-tree change only. The package was present transitively; no claim is made here that application code reaches the affected function.
Affected Versions
| Affected | unknown — this dependency tree resolved 0.9.10, which the advisory covers |
| Fixed in | unknown — the advisory cites 0.9.11; this lockfile resolves 0.9.12 |
| Ecosystem | npm |
| CVE / GHSA | CVE-2026-83606 / GHSA not assigned |
| CWE | unknown (behaviorally: inefficient regular expression complexity / ReDoS) |
If npm ls @xmldom/xmldom reports 0.9.10 or anything earlier in the 0.9.x line, you are on an affected version — including when it appears only as a transitive dependency of a build tool or mobile tooling package.
The Vulnerability Explained
What the parser is doing
@xmldom/xmldom is a regex-driven XML tokenizer rather than a hand-rolled character-at-a-time state machine. That design is what makes it small and dependency-free, and it is also what makes it susceptible to this class of bug: a single misshapen pattern turns a linear scan into an exponential one.
The processing-instruction branch has to recognize three things in one pass — the target name, an optional run of whitespace, and then arbitrary data up to the first ?>. Expressed as a pattern, that shape looks roughly like this (illustrative, not the literal source):
// Illustrative shape of the vulnerable PI scan:
// target, then whitespace, then "anything" up to an OPTIONAL terminator
/^<\?(\S+)\s*([\s\S]*?)(\?>)?/
The problem is the combination of a permissive body group and a terminator that the engine is allowed to treat as absent. When the terminator is optional, failure to find ?> does not end the search — it forces the engine to retry the body group at every possible length, and each of those retries re-tests the surrounding groups. With \S+ adjacent to \s* adjacent to a lazy [\s\S]*?, the same run of input can be attributed to more than one group, and the engine will enumerate those attributions.
The attack
The attacker does not need a valid XML document. They need a document that starts to look like a processing instruction and then refuses to resolve:
const { DOMParser } = require('@xmldom/xmldom');
// Long PI-ish run, no closing "?>" — the scan never converges
const payload = '<?' + 'a'.repeat(50000);
new DOMParser().parseFromString(payload, 'text/xml'); // blocks
Two properties make this a good denial-of-service primitive:
- The payload is tiny. Tens of kilobytes — well under any reasonable body-size limit, and far too small to look anomalous in access logs.
- The cost is superlinear. Doubling the length of the run more than doubles the CPU time, so the attacker tunes payload size to whatever wall-clock stall they want, and repeats.
Real-world impact
Consider a service that accepts an XML upload and parses it before validating anything:
app.post('/import', express.text({ type: 'application/xml' }), (req, res) => {
const doc = new DOMParser().parseFromString(req.body, 'text/xml');
res.json({ root: doc.documentElement.nodeName });
});
One request to /import with the payload above holds the event loop. Every other in-flight request — including unrelated endpoints, WebSocket pings, and the orchestrator's liveness probe — waits. If the probe times out, the container is restarted, and the attacker only needs to keep sending to keep it restarting. A handful of concurrent requests takes the process down without any of the traffic volume that rate limiters and WAFs are tuned to notice.
The same shape applies to non-HTTP entry points. If a build step, a CI task, or a CLI tool parses XML that came from a repository, a registry, or a downloaded artifact, the stall lands there instead — same primitive, different blast radius.
The Fix
The change is a dependency upgrade. The resolved version of @xmldom/xmldom moves from the vulnerable release to a patched one:
"node_modules/@xmldom/xmldom": {
- "version": "0.9.10",
+ "version": "0.9.12",
The upstream engines constraint is unchanged (node >= 14.6), and there is no API surface difference between these two patch releases — new DOMParser().parseFromString(...), XMLSerializer, and the DOM node interfaces all behave identically for well-formed input. This is a drop-in bump.
Two details worth calling out:
The PR title says 0.9.11; the lockfile says 0.9.12. 0.9.11 is the release that introduced the processing-instruction fix, so the advisory names it as the fixed version. Because the manifest range permits any compatible 0.9.x, npm install resolved to the newest available patch — 0.9.12 — which contains the same fix plus whatever landed afterward. Resolving forward is fine and is the preferred outcome; nothing needs to be pinned back to 0.9.11.
Some "peer": true markers disappeared. Alongside the version bump, the lockfile dropped "peer": true from @capacitor/core, acorn, and reflect-metadata. Those flags are npm's record of how a package was reached in the graph, not what is installed. When npm re-resolves the tree it recomputes them, and here it decided those three are now reached as regular dependencies rather than peers. No installed code changes as a result. Reviewers should know this is normal lockfile churn and not a hidden second change — but it does mean the diff should be read as "one upgrade plus bookkeeping," not "four package changes."
Why upgrading is the fix, and not input validation
It is tempting to reach for a defensive wrapper — cap the input length, reject documents that do not start with <?xml, run the parse in a worker thread. Those are reasonable defense-in-depth measures, but none of them fix this:
- Length caps do not help much when the threshold for painful CPU time is in the tens of kilobytes, which is smaller than a legitimate XML document.
- Prefix checks do not help because the payload legitimately starts with
<?; a processing instruction is valid XML syntax, which is exactly why the parser tries so hard to parse it. - Timeouts do not help, because a
setTimeoutcannot interrupt a synchronous regex backtrack. The timer callback is queued behind the very work you want to cancel.
Moving the parse to a worker_thread does contain the damage, and is worth doing for any service that parses untrusted XML. But the actual defect is the pattern inside the parser, and the only place to fix it is the parser.
Key Takeaways
@xmldom/xmldomat 0.9.10 or earlier is exploitable through any code path that reachesDOMParser.prototype.parseFromString()with attacker-influenced XML — an upload endpoint, a SOAP/SAML response, aplist, or a downloaded artifact parsed at build time.- A processing instruction is valid XML, so allowlisting "looks like XML" is not a mitigation for CVE-2026-83606. The payload is a
<?followed by a long unterminated run; it passes every syntactic smell test a front door could apply. setTimeoutcannot cancel a synchronous regex backtrack. If you need a hard bound on XML parse time, the parse must run in aworker_threador child process you can actually terminate.- Regex-driven tokenizers pay for their compactness in worst-case complexity. When a pattern places a greedy class next to a lazy class next to an optional terminator group, unterminated input forces the engine to enumerate splits instead of failing fast.
- Transitive depth is not distance from risk.
@xmldom/xmldomfrequently arrives through tooling rather than direct use; checknpm ls @xmldom/xmldomrather than assuming absence from your manifest means absence from your process.
How Orbis AppSec Detected This
- Source: XML text passed to
DOMParser.prototype.parseFromString()from@xmldom/xmldom— in practice an HTTP request body, an uploaded file, a remote service response, or a downloaded artifact. - Sink: the processing-instruction scan inside the package's tokenizer, which matches the
<?target data?>construct with a backtracking-prone regular expression carrying an optional?>terminator. - Missing control: no linear-time bound on the PI scan and no fail-fast on a missing terminator, so a long unterminated
<?…run drives superlinear backtracking on the single Node.js event-loop thread; there is no interruptible timeout around the synchronous parse. - CWE: unknown — no CWE was assigned in the advisory data available for CVE-2026-83606. Behaviorally this is inefficient regular expression complexity (ReDoS).
- Fix: the resolved
@xmldom/xmldomversion was raised from 0.9.10 to 0.9.12, which contains the corrected processing-instruction pattern.
Detection here was dependency-graph based: the vulnerable version was identified in the resolved tree. Reachability of the affected function from application code was not verified, so treat the upgrade as a low-risk patch bump rather than a confirmed exploit path.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
CVE-2026-83606 is a good illustration of why availability bugs in parsers deserve the same urgency as injection bugs. There is no data exfiltration and no code execution — just a <? followed by a long run of characters that never terminates, and a regular expression that spends minutes proving it cannot match. On a single-threaded runtime, that is enough to take a service offline with a payload that fits in a tweet.
The remediation is unglamorous and complete: move @xmldom/xmldom off 0.9.10. This project now resolves 0.9.12, an API-compatible patch release within the same 0.9.x line with the same node >= 14.6 requirement. If your own tree still reports an affected version, npm ls @xmldom/xmldom will tell you where it comes from, and an npm update or a targeted override will move it forward. And if your service parses XML that you did not author, this is a reasonable moment to ask whether that parse belongs on a worker thread you can actually kill.
Advisory detail: CVE-2026-83606.