xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. From 0.9.11 until 0.9.12, the…
GitHub_M·CWE-91·Published 2026-09-01
xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. From 0.9.11 until 0.9.12, the requireWellFormed: true element and attribute name checks use the anchored QName_exact expression produced by reg() in lib/grammar.js, which inherits the multiline flag. A name with a valid first line followed by U+000A, U+000D, U+2028, or U+2029 and breakout markup therefore passes validation and is emitted verbatim in element start and end tags or attribute names. This bypasses the strict-serialization checks introduced for the earlier element-name and attribute-name injection advisories, while the default serialization path remains outside the strict guarantee. This issue is fixed in @xmldom/xmldom version 0.9.12.
xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. From 0.9.11 until 0.9.12, the requireWellFormed: true element and attribute name checks use the anchored QName_exact expression produced by reg() in lib/grammar.js, which inherits the multiline flag. A name with a valid first line followed by U+000A, U+000D, U+2028, or U+2029 and breakout markup therefore passes validation and is emitted verbatim in element start and end tags or attribute names. This bypasses the strict-serialization checks introduced for the earlier element-name and attribute-name injection advisories, while the default serialization path remains outside the strict guarantee. This issue is fixed in @xmldom/xmldom version 0.9.12.
## Summary An embedded line terminator bypasses the `requireWellFormed` serializer check for element and attribute names. The check was added to fix GHSA-w2rr-34g9-rvrj and GHSA-4w3w-2rp5-g8jm; a name whose first line is well-formed slips past it and is serialized verbatim, so the characters after the line terminator break out of the start/end tag or attribute. Callers who enabled `requireWellFormed` specifically to neutralize those name-injection issues remain exposed. ## Details xmldom builds every grammar production through a shared regexp builder that compiles with the `m` flag. The anchored full-string matcher used for element and attribute names, `QName_exact = reg('^', QName, '$')`, therefore inherits `m`. When it is applied as `QName_exact.test(name)` against an already-assembled node name, the `m` flag makes `$` match at an interior line terminator, so the matcher accepts any value in which **at least one line** is a valid `QName`; the other lines are never constrained. A payload whose first line is a valid `QName`, followed by a line terminator and breakout markup, is what yields a working injection. The serializer emits the accepted name verbatim into element start/end tags and attribute names, so the bytes after the line terminator break out of the intended syntactic position. The check is reached whenever a caller serializes, with `requireWellFormed: true`, a node whose name was set through programmatic DOM construction (`createElement`, `createElementNS`, `createAttribute`, `createAttributeNS`) with attacker-influenced input. ### Root Cause 1. A shared regexp builder compiles anchored productions with the `m` flag. 2. `^…$` under `m` are line anchors, not string anchors. 3. A full-string validator built on such a production (`.test()`) accepts any string with one conforming line, so a line terminator followed by breakout markup passes. The triggering line terminators are the ECMAScript `LineTerminator` set: U+000A, U+000D, U+2028, U+2029. ## Proof of Concept ```js const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom'); // Element name carrying an embedded line terminator + breakout markup: const doc = new DOMImplementation().createDocument(null, 'root', null); const el = doc.createElement('a\n><script>alert(1)</script'); doc.documentElement.appendChild(el); // Caller opted into well-formed serialization, expecting invalid names to be rejected: console.log(new XMLSerializer().serializeToString(doc, { requireWellFormed: true })); // Observed on the affected version: NO throw; the output contains the injected `><script>…` // breakout, because the name's first line ("a") satisfies the m-anchored QName check. // Expected: InvalidStateError (the name is not a valid XML QName). // Control — a single-line invalid name IS correctly rejected, proving the check is active and // that only the line terminator defeats it: const ctrl = new DOMImplementation().createDocument(null, 'root', null); ctrl.documentElement.appendChild(ctrl.createElement('a b')); new XMLSerializer().serializeToString(ctrl, { requireWellFormed: true }); // => throws InvalidStateError: The element name "a b" is not a valid XML QName ``` ## Impact - **Bypass of a previously shipped security mitigation.** Applications that adopted `requireWellFormed: true` specifically to neutralize GHSA-w2rr-34g9-rvrj / GHSA-4w3w-2rp5-g8jm remain exposed to element/attribute name injection. - **XML / markup structure injection**, and, where the serialized output is placed into an HTML context, downstream XSS. ## Fix Applied The anchored XML `Name`/`QName` validators used by the `requireWellFormed` serializer no longer treat interior line terminators as satisfying the anchors, so a name is validated against the whole string. A name containing a line terminator is rejected with `InvalidStateError`, closing the bypass for element and attribute names. The default serialization path is unchanged. > **⚠ Opt-in required.** Protection is not automatic. Existing serialization calls remain > vulnerable unless `{ requireWellFormed: true }` is explicitly passed. Applications that > serialize untrusted DOM content should audit all `serializeToString()` call sites and add it. ### Proof of Concept - fixed path ```js const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom'); const doc = new DOMImplementation().createDocument(null, 'root', null); const el = doc.createElement('a\n><script>alert(1)</script'); doc.documentElement.appendChild(el); // Default path (require-well-formed off) — unchanged, still emits the name verbatim, // so the `><script>…` bytes break out of the start tag: new XMLSerializer().serializeToString(doc); // Opted-in path — now rejected: new XMLSerializer().serializeToString(doc, { requireWellFormed: true }); // throws InvalidStateError: The element name "a\n><script>alert(1)</script" is not a valid XML QName ``` ### Why the default stays verbatim The W3C DOM Parsing require-well-formed flag defaults to false, and browser `XMLSerializer` emits names verbatim when it is unset. Throwing unconditionally would be an unjustified breaking change, so the check stays gated on the caller opting in with `{ requireWellFormed: true }`. ### Residual limitation The default serialization path (no `requireWellFormed`) still emits names verbatim by design (above). Names introduced through `createElement` / `setAttribute` are never validated at creation — those APIs store the name unchecked by design — so the opt-in serializer check remains the only guard on that path.
## Summary An embedded line terminator bypasses the `requireWellFormed` serializer check for element and attribute names. The check was added to fix GHSA-w2rr-34g9-rvrj and GHSA-4w3w-2rp5-g8jm; a name whose first line is well-formed slips past it and is serialized verbatim, so the characters after the line terminator break out of the start/end tag or attribute. Callers who enabled `requireWellFormed` specifically to neutralize those name-injection issues remain exposed. ## Details xmldom builds every grammar production through a shared regexp builder that compiles with the `m` flag. The anchored full-string matcher used for element and attribute names, `QName_exact = reg('^', QName, '$')`, therefore inherits `m`. When it is applied as `QName_exact.test(name)` against an already-assembled node name, the `m` flag makes `$` match at an interior line terminator, so the matcher accepts any value in which **at least one line** is a valid `QName`; the other lines are never constrained. A payload whose first line is a valid `QName`, followed by a line terminator and breakout markup, is what yields a working injection. The serializer emits the accepted name verbatim into element start/end tags and attribute names, so the bytes after the line terminator break out of the intended syntactic position. The check is reached whenever a caller serializes, with `requireWellFormed: true`, a node whose name was set through programmatic DOM construction (`createElement`, `createElementNS`, `createAttribute`, `createAttributeNS`) with attacker-influenced input. ### Root Cause 1. A shared regexp builder compiles anchored productions with the `m` flag. 2. `^…$` under `m` are line anchors, not string anchors. 3. A full-string validator built on such a production (`.test()`) accepts any string with one conforming line, so a line terminator followed by breakout markup passes. The triggering line terminators are the ECMAScript `LineTerminator` set: U+000A, U+000D, U+2028, U+2029. ## Proof of Concept ```js const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom'); // Element name carrying an embedded line terminator + breakout markup: const doc = new DOMImplementation().createDocument(null, 'root', null); const el = doc.createElement('a\n><script>alert(1)</script'); doc.documentElement.appendChild(el); // Caller opted into well-formed serialization, expecting invalid names to be rejected: console.log(new XMLSerializer().serializeToString(doc, { requireWellFormed: true })); // Observed on the affected version: NO throw; the output contains the injected `><script>…` // breakout, because the name's first line ("a") satisfies the m-anchored QName check. // Expected: InvalidStateError (the name is not a valid XML QName). // Control — a single-line invalid name IS correctly rejected, proving the check is active and // that only the line terminator defeats it: const ctrl = new DOMImplementation().createDocument(null, 'root', null); ctrl.documentElement.appendChild(ctrl.createElement('a b')); new XMLSerializer().serializeToString(ctrl, { requireWellFormed: true }); // => throws InvalidStateError: The element name "a b" is not a valid XML QName ``` ## Impact - **Bypass of a previously shipped security mitigation.** Applications that adopted `requireWellFormed: true` specifically to neutralize GHSA-w2rr-34g9-rvrj / GHSA-4w3w-2rp5-g8jm remain exposed to element/attribute name injection. - **XML / markup structure injection**, and, where the serialized output is placed into an HTML context, downstream XSS. ## Fix Applied The anchored XML `Name`/`QName` validators used by the `requireWellFormed` serializer no longer treat interior line terminators as satisfying the anchors, so a name is validated against the whole string. A name containing a line terminator is rejected with `InvalidStateError`, closing the bypass for element and attribute names. The default serialization path is unchanged. > **⚠ Opt-in required.** Protection is not automatic. Existing serialization calls remain > vulnerable unless `{ requireWellFormed: true }` is explicitly passed. Applications that > serialize untrusted DOM content should audit all `serializeToString()` call sites and add it. ### Proof of Concept - fixed path ```js const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom'); const doc = new DOMImplementation().createDocument(null, 'root', null); const el = doc.createElement('a\n><script>alert(1)</script'); doc.documentElement.appendChild(el); // Default path (require-well-formed off) — unchanged, still emits the name verbatim, // so the `><script>…` bytes break out of the start tag: new XMLSerializer().serializeToString(doc); // Opted-in path — now rejected: new XMLSerializer().serializeToString(doc, { requireWellFormed: true }); // throws InvalidStateError: The element name "a\n><script>alert(1)</script" is not a valid XML QName ``` ### Why the default stays verbatim The W3C DOM Parsing require-well-formed flag defaults to false, and browser `XMLSerializer` emits names verbatim when it is unset. Throwing unconditionally would be an unjustified breaking change, so the check stays gated on the caller opting in with `{ requireWellFormed: true }`. ### Residual limitation The default serialization path (no `requireWellFormed`) still emits names verbatim by design (above). Names introduced through `createElement` / `setAttribute` are never validated at creation — those APIs store the name unchecked by design — so the opt-in serializer check remains the only guard on that path.
| Version | Type | Source | Base | Exp | Impact | Vector |
|---|---|---|---|---|---|---|
| 4.0 | Primary | cve.org | 8.7 | — | — | CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N |
| 4.0 | Primary | cve.org | 8.7 | — | — |
| CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N |
| 4.0 | Secondary | GHSA | 8.7 | — | — | CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N |
| 4.0 | Secondary | NVD | 8.7 | — | — | CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X |
| 4.0 | Secondary | ENISA EUVD | 8.7 | — | — | CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N |