npm · @xmldom/xmldom
xmldom: DocType `name` Injection Bypasses requireWellFormed
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.
The serializer's DOCUMENT_TYPE_NODE 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 >:
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.e5c1480):
serializer DocType case, lib/dom.js#L1914-L1946
— same structure; name is emitted verbatim at #L1928.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.DocumentType.name is a plain, writable own-property, so the enabling vector
differs by line:
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.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.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.
requireWellFormed DocType block checks publicId,
systemId, and internalSubset (the fields hardened by GHSA-f6ww-3ggp-fr8h)
but has no check for name.DocumentType.name is a plain writable own-property; on 0.8.x and the
unscoped package createDocumentType() does not validate it either.name directly between the doctype delimiters:
<!DOCTYPE ${name}…>.Run against @xmldom/xmldom v0.9.10 (commit bb7a085):
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:
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.<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.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.
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:
<!DOCTYPE …> declaration
to inject arbitrary sibling elements, comments, or additional markup into the
output.<script> element (in the XHTML
namespace) executes.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.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
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.