Skip to content

GHSA-f6ww-3ggp-fr8h CVE-2026-41674

xmldom has XML injection through unvalidated DocumentType serialization

High npm
Severity
0.0 / 10
High · CVSS 4.0
Exploit chance (EPSS)
0.56%
Chance of exploitation in the next 30 days. Higher than 46.97% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-91 XML Injection (aka Blind XPath Injection)
Fix
0.8.13
Upgrade @xmldom/xmldom to this version or later

Description

Summary

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.


Details

DOMImplementation.createDocumentType(qualifiedName, publicId, systemId, internalSubset) validates only qualifiedName against the XML QName production. The remaining three arguments are stored as-is with no validation.

The XMLSerializer emits DocumentType nodes as:

<!DOCTYPE name[ PUBLIC pubid][ SYSTEM sysid][ [internalSubset]]>

All fields are pushed into the output buffer verbatim — no escaping, no quoting added.

internalSubset injection: The serializer wraps internalSubset with [ and ]. A value containing ]> closes the internal subset and the DOCTYPE declaration at the injection point. Any content after ]> in internalSubset appears outside the DOCTYPE in the serialized output as raw XML markup. Reported by @TharVid (GHSA-f6ww-3ggp-fr8h). Affected: @xmldom/xmldom ≥ 0.9.0 via createDocumentType API; 0.8.x only via direct property write.

publicId injection: The serializer emits publicId verbatim after PUBLIC with no quoting added. A value containing an injected system identifier (e.g., "pubid" SYSTEM "evil") breaks the intended quoting context, injecting a fake SYSTEM entry into the serialized DOCTYPE declaration. Identified during internal security research. Affected: both branches, all versions back to 0.1.0.

systemId injection: The serializer emits systemId verbatim. A value containing > terminates the DOCTYPE declaration early; content after > appears as raw XML markup outside the DOCTYPE context. Identified during internal security research. Affected: both branches, all versions back to 0.1.0.

The parse path is safe: the SAX parser enforces the PubidLiteral and SystemLiteral grammar productions, which exclude the relevant characters, and the internal subset parser only accepts a subset it can structurally validate. The vulnerability is reachable only through programmatic createDocumentType calls with attacker-controlled arguments.


Affected code

lib/dom.js — createDocumentType (lines 898–910):

createDocumentType: function (qualifiedName, publicId, systemId, internalSubset) {
    validateQualifiedName(qualifiedName);          // only qualifiedName is validated
    var node = new DocumentType(PDC);
    node.name = qualifiedName;
    node.nodeName = qualifiedName;
    node.publicId = publicId || '';               // stored verbatim
    node.systemId = systemId || '';               // stored verbatim
    node.internalSubset = internalSubset || '';   // stored verbatim
    node.childNodes = new NodeList();
    return node;
},

lib/dom.js — serializer DOCTYPE case (lines 2948–2964):

case DOCUMENT_TYPE_NODE:
    var pubid = node.publicId;
    var sysid = node.systemId;
    buf.push(g.DOCTYPE_DECL_START, ' ', node.name);
    if (pubid) {
        buf.push(' ', g.PUBLIC, ' ', pubid);
        if (sysid && sysid !== '.') {
            buf.push(' ', sysid);
        }
    } else if (sysid && sysid !== '.') {
        buf.push(' ', g.SYSTEM, ' ', sysid);
    }
    if (node.internalSubset) {
        buf.push(' [', node.internalSubset, ']');  // internalSubset emitted verbatim
    }
    buf.push('>');
    return;

PoC

internalSubset injection

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

const impl = new DOMImplementation();
const doctype = impl.createDocumentType(
    'root',
    '',
    '',
    ']><injected/><![CDATA['
);
const doc = impl.createDocument(null, 'root', doctype);
const xml = new XMLSerializer().serializeToString(doc);
console.log(xml);
// <!DOCTYPE root []><injected/><![CDATA[]><root/>
//                   ^^^^^^^^^^  injected element outside DOCTYPE

publicId quoting context break

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

const impl = new DOMImplementation();
const doctype = impl.createDocumentType(
    'root',
    '"injected PUBLIC_ID" SYSTEM "evil"',
    '',
    ''
);
const doc = impl.createDocument(null, 'root', doctype);
console.log(new XMLSerializer().serializeToString(doc));
// <!DOCTYPE root PUBLIC "injected PUBLIC_ID" SYSTEM "evil"><root/>
// quoting context broken — SYSTEM entry injected

systemId injection

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

const impl = new DOMImplementation();
const doctype = impl.createDocumentType(
    'root',
    '',
    '"sysid"><injected attr="pwn"/>',
    ''
);
const doc = impl.createDocument(null, 'root', doctype);
console.log(new XMLSerializer().serializeToString(doc));
// <!DOCTYPE root SYSTEM "sysid"><injected attr="pwn"/>><root/>
// > in sysid closes DOCTYPE early; <injected/> appears as sibling element

Impact

An application that programmatically constructs DocumentType nodes from user-controlled data and then serializes the document can emit a DOCTYPE declaration where the internal subset is closed early or where injected SYSTEM entities or other declarations appear in the serialized output.

Downstream XML parsers that re-parse the serialized output and expand entities from the injected DOCTYPE declarations may be susceptible to XXE-class attacks if they enable entity expansion.


Fix Applied

⚠ Opt-in required. Protection is not automatic. Existing serialization calls remain vulnerable unless { requireWellFormed: true } is explicitly passed. Applications that pass untrusted data to createDocumentType() or write untrusted values directly to a DocumentType node's publicId, systemId, or internalSubset properties should audit all serializeToString() call sites and add the option.

XMLSerializer.serializeToString() now accepts an options object as a second argument. When { requireWellFormed: true } is passed, the serializer validates the DocumentType node's publicId, systemId, and internalSubset fields before emitting the DOCTYPE declaration and throws InvalidStateError if any field contains an injection sequence:

  • publicId: throws if non-empty and does not match the XML PubidLiteral production (XML 1.0 [12])
  • systemId: throws if non-empty and does not match the XML SystemLiteral production (XML 1.0 [11])
  • internalSubset: throws if it contains ]> (which closes the internal subset and DOCTYPE declaration early)

All three checks apply regardless of how the invalid value entered the node — whether via createDocumentType arguments or a subsequent direct property write.

PoC — fixed path

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

// internalSubset injection
const dt1 = impl.createDocumentType('root', '', '', ']><injected/><![CDATA[');
const doc1 = impl.createDocument(null, 'root', dt1);

// Default (unchanged): verbatim — injection present
console.log(new XMLSerializer().serializeToString(doc1));
// <!DOCTYPE root []><injected/><![CDATA[]><root/>

// Opt-in guard: throws InvalidStateError
try {
  new XMLSerializer().serializeToString(doc1, { requireWellFormed: true });
} catch (e) {
  console.log(e.name, e.message);
  // InvalidStateError: DocumentType internalSubset contains "]>"
}

The guard also covers post-creation property writes:

const dt2 = impl.createDocumentType('root', '', '');
dt2.systemId = '"sysid"><injected attr="pwn"/>';
const doc2 = impl.createDocument(null, 'root', dt2);
new XMLSerializer().serializeToString(doc2, { requireWellFormed: true });
// InvalidStateError: DocumentType systemId is not a valid SystemLiteral

Why the default stays verbatim

The W3C DOM Parsing and Serialization spec §3.2.1.3 defines a require well-formed flag whose default value is false. With the flag unset, the spec permits verbatim serialization of DOCTYPE fields. Unconditionally throwing would be a behavioral breaking change with no spec justification. The opt-in requireWellFormed: true flag allows applications that require injection safety to enable strict mode without breaking existing deployments.

Residual limitation

createDocumentType(qualifiedName, publicId, systemId[, internalSubset]) does not validate publicId, systemId, or internalSubset at creation time. This creation-time validation is a breaking change and is deferred to a future breaking release.

When the default serialization path is used (without requireWellFormed: true), all three fields are still emitted verbatim. Applications that do not pass requireWellFormed: true remain exposed.

Affected versions

npm

PackageVulnerableFixed in
@xmldom/xmldom
< 0.8.13
0.8.13
@xmldom/xmldom
>= 0.9.0, < 0.9.10
0.9.10
xmldom
<= 0.6.0
No fix yet

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

@xmldom/xmldom npm → 0.8.13

npm
npm install @xmldom/xmldom@0.8.13
yarn
yarn add @xmldom/xmldom@0.8.13
pnpm
pnpm add @xmldom/xmldom@0.8.13

Severity breakdown

CVSS 4.0 0.0

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

Attack vector
Network
Attack complexity
Low
Attack requirements
None
Privileges required
None
User interaction
None
Confidentiality (vulnerable system)
None
Integrity (vulnerable system)
High
Availability (vulnerable system)
None
Confidentiality (subsequent systems)
None
Integrity (subsequent systems)
None
Availability (subsequent systems)
None

CVSS 3.1 7.5

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
High
Availability
None

Weakness

CWE-91: XML Injection (aka Blind XPath Injection)

The product does not properly neutralize special elements that are used in XML, allowing attackers to modify the syntax, content, or commands of the XML before it is processed by an end system.

How to prevent it
  • Implementation: Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does. When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue." Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylists can be useful for detecting potential attacks or determining which inputs are so malformed that they should be rejected outright.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-x4fp-j954-r2f4
xmldom: End-tag Whitespace-Trim Regex ReDoS — quadratic backtracking in the 0.8.x end-tag parser
High Also affects @xmldom/xmldom
GHSA-vr34-hp96-76pp
xmldom: requireWellFormed DocType publicId/systemId validation is bypassable via an embedded line terminator
High Also affects @xmldom/xmldom
GHSA-jxjr-3g7g-3944
xmldom: requireWellFormed element/attribute name validation is bypassable via an embedded line terminator
High Also affects @xmldom/xmldom
GHSA-c7q8-3ch8-vqpv
xmldom: Processing Instruction Target Injection Bypasses requireWellFormed
High Also affects xmldom
GHSA-965w-775f-mr7g
xmldom: Quadratic-memory consumption
High Also affects xmldom
GHSA-93r5-fhx6-vmg9
xmldom: Quadratic-time parsing via the malformed-input recovery path — `parseElementStartPart` re-scan and `normalize()` adjacent-text merge
High Also affects xmldom
GHSA-8344-3jmq-59r6
xmldom: Quadratic-time attribute deduplication
High Also affects xmldom
GHSA-6mj3-qw4j-hgrw
xmldom: HTML raw-text closing-tag case mismatch causes output amplification
High Also affects @xmldom/xmldom
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor