Where
-Infinity
0
Severity
8.7
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/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

Summary

On the @xmldom/xmldom 0.8.x line, parsing an XML end tag whose name is followed by a long run of whitespace and then a non-whitespace character triggers quadratic-time regular-expression backtracking (ReDoS), so a single small crafted end tag stalls the Node.js event loop. It is reachable from DOMParser.parseFromString under default options, unauthenticated, before any validity check — an availability-only denial of service. The 0.9.x line is not affected.

Details

lib/sax.js (release-0.8.x, commit e5c1480) trims trailing whitespace from a captured end-tag name with an unanchored global regex:

- lib/sax.js line 120: https://github.com/xmldom/xmldom/blob/e5c14802592685bb872c042c54c3f73758875c85/lib/sax.js#L120

js /[ \t\n\r]+$/g

Applied to a string shaped whitespace-run + one non-whitespace char (e.g. the content of an end tag </ … x>), the engine must, for every starting position, extend [ws]+ to the end and then fail the $ anchor when the trailing non-whitespace char is present — classic O(n²) backtracking in the length of the whitespace run. The trimmed substring is delimited only by indexOf('>'), so the attacker controls its length directly.

Proof of Concept

js const { DOMParser } = require('@xmldom/xmldom'); // 0.8.x const n = 64 1024; const payload = '<r></' + ' '.repeat(n) + 'x>'; console.time('parse'); new DOMParser().parseFromString(payload, 'text/xml'); console.timeEnd('parse');

Measured (Node 18) — time quadruples per doubling of the whitespace run (canonical O(n²)):

| Whitespace run | Isolated regex | End-to-end parseFromString (0.8.13) | |---|---|---| | 4 KB | 5.6 ms | 5.7 ms | | 8 KB | 22.7 ms | 22.5 ms | | 16 KB | 88.6 ms | 92 ms | | 32 KB | 354 ms | 361 ms | | 64 KB | 1434 ms | 1452 ms | | 128 KB | 5761 ms | — |

Impact

Availability only: a single parse of a small crafted document blocks the Node.js event loop for the duration of the quadratic scan (≈1.4 s at 64 KB; multi-second with larger inputs). No memory blow-up, 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.

Affected Versions

Affected on the 0.7.x and 0.8.x lines (the trailing-whitespace trim was added in 0.7.0, present through 0.8.14); the fix targets the 0.8.x LTS patch. The 0.9.x line rewrote end-tag parsing to an anchored linear matcher and never had this regex, so it is not affected. No published unscoped xmldom is affected — the vulnerable code exists only in a 0.7.0 git tag that was never released to npm (npm view xmldom → latest = 0.6.0).

Fix Applied

Anchors the end-tag trailing-whitespace trim so it runs in linear time instead of backtracking quadratically on a long whitespace run. Byte-identical output. Non-breaking; 0.8.x-only.

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.

1 / 3
Source: GitHub
First published (updated )
Severity
8.7
XSS
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

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, QNameexact = reg('^', QName, '$'), therefore inherits m. When it is applied as QNameexact.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.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
XSS
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

Summary

Document.createProcessingInstruction() in @xmldom/xmldom performs no validation on the target parameter. The requireWellFormed: true serializer option validates only for : in the target and a case-insensitive xml prefix, but does not check for > characters. A > in the target breaks the processing instruction boundary (<?...?>), allowing injection of arbitrary content into the serialized XML output.

Details

Document.createProcessingInstruction(target, data) at lib/dom.js around line 2413 accepts any string as the target parameter and stores it on the PI node without validation.

During serialization, the requireWellFormed code path (around line 3286) performs two checks on PI targets:

1. Rejects targets containing : (namespace prefix check) 2. Rejects targets matching xml case-insensitively (reserved prefix)

However, it does NOT validate that the target conforms to the XML Name production, and critically does NOT check for > characters. Since processing instructions are serialized as <?target data?>, a > in the target prematurely closes the PI, causing the remaining content to be interpreted as document content by any downstream XML parser.

Root Cause

1. createProcessingInstruction() performs no validation on target 2. The serializer's requireWellFormed check is incomplete -- it only checks for : and xml, missing characters that break PI syntax (>, ?, whitespace) 3. The serializer emits the target verbatim: <?${target} ${data}?>

Proof of Concept

javascript const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom');

const impl = new DOMImplementation(); const serializer = new XMLSerializer(); const doc = impl.createDocument(null, 'root', null);

// PI target containing > breaks the PI boundary const pi = doc.createProcessingInstruction('a>', 'data'); doc.documentElement.appendChild(pi);

const output = serializer.serializeToString(doc, { requireWellFormed: true }); console.log(output); // Output: <root><?a> data?></root> // // The > in the target closes the PI prematurely. // A downstream XML parser sees: // - Processing instruction: <?a?> (target "a", no data) // - Text content: " data?>" // // requireWellFormed: true did NOT prevent the injection.

Injecting elements via PI target

javascript const pi2 = doc.createProcessingInstruction( 'a?><script xmlns="http://www.w3.org/1999/xhtml">alert(1)</script><?b', '' ); doc.documentElement.appendChild(pi2);

const output2 = serializer.serializeToString(doc, { requireWellFormed: true }); console.log(output2); // Output includes: // <?a?><script xmlns="http://www.w3.org/1999/xhtml">alert(1)</script><?b ?> // // The injected <script> element is valid XHTML that a browser would execute.

Impact

Applications that create processing instructions with user-controlled target strings and serialize the result are vulnerable to XML injection. This enables:

- XML structure injection: Breaking the PI boundary to inject arbitrary elements, text, or additional processing instructions into the output - XSS via XHTML: If the serialized output is served as XHTML or processed by a browser-based XML parser, injected script elements will execute - XXE chain: Injected DOCTYPE declarations or entity references could trigger XXE in downstream XML parsers that consume the output - requireWellFormed bypass: The existing well-formedness checks are incomplete and provide a false sense of security

Fix Applied

Under requireWellFormed, the serializer validates a processing-instruction target as an XML NCName (a Name with no colon) and rejects a case-insensitive xml, throwing InvalidStateError when the target is ill-formed — so a >, ?, or whitespace in the target is now refused.\ On 0.9.12 this replaces an earlier check that already rejected a colon or xml, so the no-colon rule is preserved.\ 0.8.15 had no processing-instruction target check at all, so the whole target validation is new there.\ Non-breaking and opt-in. See the XML Name production. ⚠ 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

javascript const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom');

const impl = new DOMImplementation(); const serializer = new XMLSerializer(); const doc = impl.createDocument(null, 'root', null);

// PI target containing > breaks the PI boundary const pi = doc.createProcessingInstruction('a>', 'data'); doc.documentElement.appendChild(pi);

// Default path: emits the ill-formed target verbatim. console.log(serializer.serializeToString(doc)); // Output: <root><?a> data?></root>

// Opt-in path: the target check now rejects the break-out character. try { serializer.serializeToString(doc, { requireWellFormed: true }); } catch (e) { console.log(e.name); // InvalidStateError }

Why the default stays verbatim

W3C DOM Parsing's require-well-formed flag defaults to false, and the browser XMLSerializer emits the target verbatim in that default mode. Unconditionally throwing on an ill-formed PI target would diverge from that platform behavior and would be an unjustified breaking change, so the stricter validation is gated behind { requireWellFormed: true }. (See the W3C XML Name production and XML Processing Instructions.)

Residual limitation

The default serialization path still emits the ill-formed target verbatim -- only the opt-in requireWellFormed path is protected. Creation-time validation of the target in createProcessingInstruction() is breaking and is deferred to the next breaking release, tracked at xmldom/xmldom#1073.

1 / 3
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/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

Summary

When an element declares a namespace prefix, xmldom copies the entire in-scope namespace map into a fresh object and keeps that copy on the element while it is open on the parse stack. A crafted document that nests N elements, each declaring one unique prefix, therefore drives the parser to hold on the order of N(N+1)/2 = O(N²) namespace-map entries at its peak, so a small, highly compressible input exhausts the heap. Parsing runs under default options on untrusted, network-delivered XML, so a sub-megabyte payload can OOM-crash the process before any application-level validation runs — an unauthenticated denial of service.

Details

appendElement performs the copy: copy clones the current namespace map into a fresh object for each prefix-declaring element, and the copy is retained on that element's parse-stack entry:

js if (localNSMap == null) { localNSMap = Object.create(null); copy(currentNSMap, (currentNSMap = Object.create(null))); // full copy of all ancestor prefixes } currentNSMap[nsPrefix] = localNSMap[nsPrefix] = value; ... el.currentNSMap = currentNSMap; // retained while the element is open on the parse stack

https://github.com/xmldom/xmldom/blob/08a22d78e4bc50f12ce9f5090b8d96ee6031ac7b/lib/sax.js#L467-L540

The copies stack: the element at depth i copies a map of size ~i, and every ancestor stays live on the parse stack until it closes, so at the deepest point Σi namespace entries are held at once. That peak is transient — the completed DOM retains only O(N), one small namespace map per node — but it is reached during parsing, which is what OOM-crashes the process.

Proof of Concept

A minimal document — N nested elements, each declaring one unique namespace prefix (no SAML wrapper needed):

js const { DOMParser } = require('@xmldom/xmldom');

function build(n) { let open = '', close = ''; for (let i = 0; i < n; i++) { open += <a xmlns:p${i}="urn:${i}">; close = '</a>' + close; } return <r>${open}${close}</r>; // <r><a xmlns:p0="urn:0">...<a xmlns:p{n-1}="urn:{n-1}">...</a>...</r> }

for (const n of [2000, 4000, 8000, 16000]) { const src = build(n); new DOMParser().parseFromString(src, 'text/xml'); // peak memory ~ O(n^2) console.log(n, (src.length / 1024).toFixed(0) + ' KB in', (process.resourceUsage().maxRSS / 1024).toFixed(0) + ' MB peak RSS'); }

Measured on Node.js v24 (peak RSS ~quadruples per doubling of depth; absolute numbers vary by host):

| depth | input | peak RSS | | -----: | ------: | ------------------------------- | | 2,000 | 56 KB | 266 MB | | 4,000 | 115 KB | 622 MB | | 8,000 | 232 KB | 1.9 GB | | 16,000 | ~470 KB | OOM crash (default ~4 GB heap) |

About 470 KB of trivially-generated, highly-compressible input crashes a default Node.js process; larger depths scale as O(N²) into the tens of GB, crashing larger hosts (as first measured by the reporter with a SAML-shaped payload).

Impact

Unauthenticated denial of service against any service that parses attacker-influenced XML with xmldom under default options. A single sub-megabyte request drives multi-gigabyte peak memory and can OOM-crash the process before any application-level validation (e.g. schema checks or a SAML signature verification) runs. The payload is a plain namespace-nesting document and highly compressible, so it is effective over compressed transports (e.g. an HTTP-Redirect / DEFLATE binding, not only POST bindings).

Severity note

The CVSS 4.0 vector scores availability only (VC:N/VI:N/VA:H): the flaw neither discloses nor alters data, it exhausts the heap. VA:H is justified because a single unauthenticated, network-delivered request (AV:N/PR:N/UI:N) of trivial complexity (AC:L/AT:N) drives the parser to multi-gigabyte peak memory and OOM-crashes the process before any application-level logic runs — a full loss of availability for the affected service.

Fix Applied

Inherit each element's in-scope namespace map through the prototype chain instead of copying it for every prefix-declaring element, so a deeply namespaced document holds O(N) namespace entries instead of O(N²) at peak. Behavior-preserving: serialized output is byte-identical, only the memory cost drops. Non-breaking and independent of requireWellFormed; ships on both maintained versions.

1 / 3
Source: GitHub
First published (updated )
Severity
8.7
XSS
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

Summary

An embedded line terminator bypasses xmldom's always-on, WHATWG-mandated creation-time name validation. createElementNS, createAttributeNS, createDocumentType, and createAttribute should reject a malformed qualified name with InvalidCharacterError, but a name whose first line is well-formed slips through and enters the DOM. On serialization it is emitted verbatim, so the characters after the line terminator inject markup into the output. The injection reaches the default serialization path, and enabling requireWellFormed does not prevent it.

Details

createElementNS, createAttributeNS, and createDocumentType route through validateQualifiedName, and createAttribute performs the analogous check; each validates the name with g.QNameexact.test(name). QNameexact = reg('^', QName, '$') inherits the m flag from xmldom's shared regexp builder, so the matcher accepts any name whose first line is a valid QName and leaves the remaining lines unconstrained (see Root Cause).

Root Cause

1. A shared regexp builder compiles anchored productions with the m flag. 2. ^…$ under m are line anchors, not string anchors. 3. validateQualifiedName / createAttribute validate with .test() against such a production, so a line terminator followed by breakout markup passes and the malformed name is stored.

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'); const impl = new DOMImplementation();

const doc = impl.createDocument('urn:x', 'root', null); const el = doc.createElementNS('urn:x', 'a\n><script>x</script'); // ACCEPTED (no throw) doc.documentElement.appendChild(el);

// DEFAULT serialization — requireWellFormed NOT set: console.log(new XMLSerializer().serializeToString(doc)); // Observed: <root xmlns="urn:x"><a // ><script>x</script/></root> <-- injected element on the default path // Control: createElementNS('urn:x', 'bad>name') throws InvalidCharacterError, confirming the check is // active and specifically bypassed by the line terminator.

Impact

- Bypass of the always-on WHATWG creation-time name validation (InvalidCharacterError): a malformed name the standard requires be rejected at creation is instead admitted to the DOM. - Markup / structure injection. An application relying on the create APIs to reject malformed names (the standard behavior) as a trust boundary is exposed; where the serialized output reaches an HTML context, downstream XSS. - No serializer option mitigates it. The admitted name is emitted verbatim under both the default path and requireWellFormed: true — the strict serializer shares the same m-flagged blind spot (the subject of the sibling serializer advisories) — so the bypassed creation-time check was the only layer that could have stopped it. Demonstrated for all four create sites in poccreationstrictserializationbypass.cjs.

Fix Applied

createElementNS, createAttributeNS, createDocumentType, and createAttribute now reject a name containing a line terminator with InvalidCharacterError — the same result they already give for other malformed names — because name validation now applies to the whole string. The requireWellFormed serializer's name checks are corrected by the same change. The fix is non-breaking: such a name was already invalid, and no previously-accepted well-formed name is affected.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
XSS
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

Summary

The @xmldom/xmldom serializer emits DocumentType.name verbatim into the <!DOCTYPE …> declaration with no well-formedness guard. GHSA-f6ww-3ggp-fr8h (CVE-2026-41674) hardened the serializer's requireWellFormed path for a DocumentType's sibling fields — publicId, systemId, and internalSubset — but it did not add any check for name. A > (or whitespace) in the name terminates the doctype declaration early, letting the remaining characters become sibling markup in the serialized output.

Because requireWellFormed: true — the recommended mitigation for the prior xmldom injection CVEs — performs no validation on the DocType name, this is a bypass of that control, in the same family as the open element-name (GHSA-w2rr-34g9-rvrj) and attribute-name (GHSA-4w3w-2rp5-g8jm) name-injection advisories.

Details

The serializer's DOCUMENTTYPENODE case runs the requireWellFormed block only against publicId, systemId, and internalSubset, then pushes n.name directly into the buffer between the <!DOCTYPE prefix and the closing >:

- 0.9.x (v0.9.10, bb7a085): serializer DocType case, lib/dom.js#L3256-L3283 — the requireWellFormed block (#L3259-L3269) validates publicId/systemId/internalSubset but not name, which is emitted verbatim at #L3270. - 0.8.x (v0.8.13, e5c1480): serializer DocType case, lib/dom.js#L1914-L1946 — same structure; name is emitted verbatim at #L1928. - unscoped xmldom (v0.6.0, c80a161): lib/dom.js#L1105 emits node.name verbatim; this line predates requireWellFormed, so there is no well-formedness path at all.

Enabling write paths

DocumentType.name is a plain, writable own-property, so the enabling vector differs by line:

- 0.9.x — createDocumentType() validates the name via validateQualifiedName (lib/dom.js#L925-L936, validation at #L926), so the deliverable vector is a direct property write (dt.name = 'html><script>…') to the unguarded own-property. - 0.8.x — createDocumentType() does not validate the name (lib/dom.js#L456-L464), so the malicious name is reachable directly through createDocumentType() as well as via direct property write. - unscoped xmldom (<= 0.6.0) — createDocumentType() does not validate the name (lib/dom.js#L286), same as 0.8.x.

This is the same structural root cause the sibling name-injection advisories share: the serializer's requireWellFormed path validates content delimiters but no name field, and every name-like field is a plain writable property, so mutation / direct property-write bypasses any creation-time check.

Root Cause

1. The serializer's requireWellFormed DocType block checks publicId, systemId, and internalSubset (the fields hardened by GHSA-f6ww-3ggp-fr8h) but has no check for name. 2. DocumentType.name is a plain writable own-property; on 0.8.x and the unscoped package createDocumentType() does not validate it either. 3. The serializer emits name directly between the doctype delimiters: <!DOCTYPE ${name}…>.

Proof of Concept

Run against @xmldom/xmldom v0.9.10 (commit bb7a085):

javascript const { DOMImplementation, XMLSerializer, DOMParser } = require('@xmldom/xmldom');

const impl = new DOMImplementation(); const serializer = new XMLSerializer();

// 0.9.x createDocumentType validates the name, so overwrite it via direct property write const dt = impl.createDocumentType('html', '', ''); dt.name = 'html><script xmlns="http://www.w3.org/1999/xhtml">alert(1)</script'; const doc = impl.createDocument(null, 'r', dt);

const output = serializer.serializeToString(doc, { requireWellFormed: true }); console.log(output); // Output: <!DOCTYPE html><script xmlns="http://www.w3.org/1999/xhtml">alert(1)</script><r/> // // requireWellFormed: true did NOT prevent the injection (no exception thrown). // The injected <script> is well-formed XHTML that a browser would execute.

Confirmed runtime behavior:

- 0.9.x — createDocumentType() rejects the malicious name at creation (InvalidCharacterError); a direct write to dt.name bypasses that, and serializeToString(…, { requireWellFormed: true }) emits the breakout with no exception. - Re-parse confirmation — re-parsing the output shows the injected <script> is a real second top-level element originating entirely from the DocType name: the parser rejects it with HierarchyRequestError: Only one element can be added and only after doctype. A comment-injection variant (dt.name = 'html><!--INJECTED--', output <!DOCTYPE html><!--INJECTED--><r/>) re-parses cleanly and the injected comment node is enumerable, confirming the injected node is structurally live. - 0.8.x (v0.8.13, e5c1480) — createDocumentType('html><script>…', '', '') accepts the malicious name directly (no creation-time validation), and serializeToString(doc, null, null, { requireWellFormed: true }) produces <!DOCTYPE html><script>alert(1)</script><r/> with no exception.

A browser reproduction does not apply: browsers keep DocumentType.name readonly, so the direct-write vector cannot be reproduced in a browser DOM. The injection is specific to xmldom exposing name as writable and serializing it without a guard.

Impact

Applications that build a DocumentType node with an attacker-influenced name — via direct property write on any affected line, or via createDocumentType() on 0.8.x and the unscoped package — and serialize the document are vulnerable to XML/markup injection:

- XML structure injection — breaking out of the <!DOCTYPE …> declaration to inject arbitrary sibling elements, comments, or additional markup into the output. - XSS via XHTML — if the serialized output is served as XHTML or processed by a browser-based XML parser, an injected <script> element (in the XHTML namespace) executes. - requireWellFormed bypass — applications that adopted requireWellFormed: true as a mitigation for the prior injection CVEs (including the sibling DocType fields fixed by GHSA-f6ww-3ggp-fr8h) remain vulnerable through the DocType name.

Fix Applied

Under requireWellFormed, the serializer validates the DocType name as a well-formed XML Name and throws InvalidStateError when it is not — matching the sibling publicId/systemId/internalSubset checks. Non-breaking and opt-in; ships on both maintained versions. No creation-time change is made: 0.9.x already validates the name at createDocumentType, and the 0.8.x/unscoped creation gap cannot be closed without a breaking change, so it is left unfixed. See the XML Name production. ⚠ 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

javascript const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom');

const impl = new DOMImplementation(); const serializer = new XMLSerializer();

const dt = impl.createDocumentType('html', '', ''); dt.name = 'html><script xmlns="http://www.w3.org/1999/xhtml">alert(1)</script'; const doc = impl.createDocument(null, 'r', dt);

// Default path: the ill-formed name is still emitted verbatim (injection present). console.log(serializer.serializeToString(doc)); // <!DOCTYPE html><script xmlns="http://www.w3.org/1999/xhtml">alert(1)</script><r/>

// Opt-in path: serialization throws instead of emitting the breakout. serializer.serializeToString(doc, { requireWellFormed: true }); // throws InvalidStateError

Why the default stays verbatim

W3C DOM Parsing's require-well-formed flag defaults to false, and the browser XMLSerializer emits the name verbatim in that default mode. Unconditionally throwing would be an unjustified breaking change against that specified default, so the guard is opt-in behind { requireWellFormed: true }.

Residual limitation

The default serialization path still emits the ill-formed DocType name verbatim; protection applies only when requireWellFormed: true is passed. No creation-time validation is added for the DocType name: 0.9.x already validates at createDocumentType, and the 0.8.x/unscoped creation gap is left unfixed — it cannot be closed without a breaking change.

1 / 3
Source: GitHub
First published (updated )
Severity
8.7
XSS
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

Summary

Element.setAttribute() in @xmldom/xmldom bypasses attribute name validation by calling the private createAttribute(name) method, which performs no validation. The public createAttribute() method correctly validates names against an anchored QName pattern, but setAttribute() never uses it. The serializer escapes attribute values but trusts attribute names, allowing an attacker to inject additional attributes (including event handlers) into serialized output. The requireWellFormed: true option did not catch this.

Details

Element.setAttribute(name, value) creates attribute nodes by calling the private createAttribute(name) method, which performs no validation on the name parameter. In contrast, the public Document.createAttribute(name) method validates the name against the QName production before creating the attribute node.

The result is a two-tier validation system where the most commonly used API (setAttribute) takes the unvalidated path:

- doc.createAttribute("bad name") — throws INVALIDCHARACTERERR (correct). - el.setAttribute("bad name", "value") — succeeds silently (vulnerable).

The serializer emits attribute names verbatim into the output. Because attribute values ARE escaped (quotes, ampersands, etc.), the injection must occur through the name. An attacker can terminate the current attribute and inject new ones by including quote and space characters in the attribute name.

Root Cause

1. setAttribute() calls createAttribute() (private, no validation) instead of createAttribute() (public, validates against QName). 2. The serializer trusts attribute names and emits them unescaped. 3. The serializer's requireWellFormed code path did not validate attribute names during serialization.

Proof of Concept

js const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom');

const impl = new DOMImplementation(); const serializer = new XMLSerializer(); const doc = impl.createDocument(null, 'root', null);

// The attribute name contains a closing quote, a space, and a new attribute doc.documentElement.setAttribute('class="safe" onclick', 'alert(1)');

const output = serializer.serializeToString(doc, { requireWellFormed: true }); console.log(output); // <root class="safe" onclick="alert(1)"/> // // The single setAttribute() call produced TWO attributes: // 1. class="safe" // 2. onclick="alert(1)" // // requireWellFormed: true did NOT prevent the injection.

Demonstrating the validation gap

js // Public createAttribute correctly rejects invalid names: try { doc.createAttribute('class="safe" onclick'); } catch (e) { console.log('createAttribute rejects:', e.message); }

// But setAttribute (which uses createAttribute) accepts the same input: doc.documentElement.setAttribute('class="safe" onclick', 'alert(1)'); // No error thrown

Impact

Applications that use setAttribute() with any user-controlled portion of the attribute name are vulnerable to attribute injection attacks. This includes:

- Cross-Site Scripting (XSS): Injecting event handler attributes into HTML output consumed by browsers. - Security attribute override: Overriding security-relevant attributes such as integrity, nonce, sandbox, or Content-Security-Policy meta attributes. - Validation bypass: The public createAttribute() API validates while setAttribute() does not, creating an inconsistent security boundary that developers cannot rely on. - requireWellFormed bypass: Applications that adopted requireWellFormed: true as a mitigation for prior CVEs remained vulnerable.

@xmldom/xmldom can also be used inside browsers, where it mirrors the DOM API. Unlike the browser's setAttribute(), which rejects an invalid attribute name with InvalidCharacterError, xmldom accepts it — developers may assume the same safety and skip validation.

Fix Applied

⚠ 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.

When { requireWellFormed: true } is passed, the serializer now validates each serialized attribute's qualified name against the XML QName production and throws InvalidStateError before emitting it. This covers ordinary attribute names and synthesized xmlns:PREFIX namespace declarations (the namespace-prefix sub-vector).

Fixed under requireWellFormed: true in @xmldom/xmldom 0.9.11 and 0.8.14. Default serialization is unchanged.

PoC — fixed path

js const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom');

const doc = new DOMImplementation().createDocument(null, 'root', null); doc.documentElement.setAttribute('class="safe" onclick', 'alert(1)');

// Default (unchanged): verbatim — injection present console.log(new XMLSerializer().serializeToString(doc)); // <root class="safe" onclick="alert(1)"/>

// Opt-in guard: throws InvalidStateError before serializing try { new XMLSerializer().serializeToString(doc, { requireWellFormed: true }); } catch (e) { console.log(e.name, e.message); // InvalidStateError: The attribute name "class="safe" onclick" is not a valid XML QName }

Why the default stays verbatim

The W3C DOM Parsing and Serialization spec defines a require well-formed flag whose default value is false. With the flag unset, the serializer emits attribute names verbatim, matching the XMLSerializer behavior of Chrome, Firefox, and Safari. Unconditionally throwing would be a behavioral breaking change with no spec justification; the opt-in requireWellFormed: true flag lets applications that require injection safety enable strict mode without breaking existing code.

Residual limitation

setAttribute(name, value) does not validate name at creation time (unlike the public createAttribute(), which already does). Making setAttribute() reject invalid names unconditionally is a breaking change and is deferred to the next breaking release. When the default serialization path is used (without requireWellFormed: true), attribute names set via setAttribute() are still emitted verbatim; applications that do not pass requireWellFormed: true remain exposed.

Creation-time validation is tracked in a public issue on the next breaking-release milestone (filed at publication — issue link to be added), targeting the next breaking release.

1 / 3
Source: GitHub
First published (updated )
Severity
7

xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. In @xmldom/xmldom prior to versions 0.9.10 and 0.8.13 and xmldom version 0.6.0 and prior, the package allows attacker-controlled processing instruction data to be serialized into XML without validating or neutralizing the PI-closing sequence ?>. As a result, an attacker can terminate the processing instruction early and inject arbitrary XML nodes into the serialized output. This issue has been patched in versions @xmldom/xmldom versions 0.9.10 and 0.8.13.

First published (updated )
Severity
7

xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. In @xmldom/xmldom prior to versions 0.9.10 and 0.8.13 and xmldom version 0.6.0 and prior, the package allows attacker-controlled comment content to be serialized into XML without validating or neutralizing comment-breaking sequences. As a result, an attacker can terminate the comment early and inject arbitrary XML nodes into the serialized output. This issue has been patched in versions @xmldom/xmldom versions 0.9.10 and 0.8.13.

First published (updated )
Severity
7

xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. In @xmldom/xmldom prior to versions 0.9.10 and 0.8.13 and xmldom version 0.6.0 and prior, seven recursive traversals in lib/dom.js operate without a depth limit. A sufficiently deeply nested DOM tree causes a RangeError: Maximum call stack size exceeded, crashing the application. This issue has been patched in versions @xmldom/xmldom versions 0.9.10 and 0.8.13.

First published (updated )
Severity
7

xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. In @xmldom/xmldom prior to versions 0.9.10 and 0.8.13 and xmldom version 0.6.0 and prior, the package serializes DocumentType node fields (internalSubset, publicId, systemId) verbatim without any escaping or validation. When these fields are set programmatically to attacker-controlled strings, XMLSerializer.serializeToString can produce output where the DOCTYPE declaration is terminated early and arbitrary markup appears outside it. This issue has been patched in versions @xmldom/xmldom versions 0.9.10 and 0.8.13.

First published (updated )
Severity
7

xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. In xmldom versions 0.6.0 and prior and @xmldom/xmldom prior to versions 0.8.12 and 0.9.9, xmldom/xmldom allows attacker-controlled strings containing the CDATA terminator ]]> to be inserted into a CDATASection node. During serialization, XMLSerializer emitted the CDATA content verbatim without rejecting or safely splitting the terminator. As a result, data intended to remain text-only became active XML markup in the serialized output, enabling XML structure injection and downstream business-logic manipulation. This issue has been patched in xmldom version 0.6.0 and @xmldom/xmldom versions 0.8.12 and 0.9.9.

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203