Résumé

xmldom: DocType `name` Injection Bypasses requireWellFormed

Détails de l’avis

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 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 >:

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):

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

Références