Skip to content

chore(deps): update dependency @xmldom/xmldom to v0.9.12 [security] - #1152

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-xmldom-xmldom-vulnerability
Open

chore(deps): update dependency @xmldom/xmldom to v0.9.12 [security]#1152
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-xmldom-xmldom-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
@xmldom/xmldom 0.9.10 β†’ 0.9.12 age confidence

xmldom: XML fragment injection via invalid EntityReference.nodeName during requireWellFormed serialization

CVE-2026-83610 / GHSA-6gmq-8vp8-gcm6

More information

Details

Summary

An EntityReference node can be created with an invalid, attacker-controlled name through Document.createEntityReference(name). When this node is serialized directly with:

serializer.serializeToString(ref, { requireWellFormed: true })

the invalid nodeName is emitted into the serialized XML fragment without validation or escaping.

This can produce real XML markup in the serialized output. In the proof of concept below, the serialized fragment contains <injected/>, and reparsing the fragment creates a real injected element.


Details

The issue appears to be in the serialization path for ENTITY_REFERENCE_NODE.

For several other node types, requireWellFormed: true performs specific validation checks before serialization. For example, comments, processing instructions, document types, and some character data cases are checked before being emitted.

However, for ENTITY_REFERENCE_NODE, the serializer appears to emit the node name directly in entity reference form:

case ENTITY_REFERENCE_NODE:
  buf.push('&', n.nodeName, ';');
  return null;

As a result, if nodeName contains characters that break out of the intended &name; structure, the serializer can emit additional XML markup.

For example, an entity reference created with the name:

safe; <injected/> &x

is serialized as:

&safe; <injected/> &x;

When this fragment is later parsed in an XML context, <injected/> becomes a real element.

This is especially surprising when { requireWellFormed: true } is used, because applications may reasonably treat this mode as the stricter or safer XML serialization mode.


Proof of Concept

Tested with:

@xmldom/xmldom@0.9.10
Node.js v24.18.0
Windows 10 / PowerShell
'use strict';

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

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

function countInjected(fragment) {
  try {
    const parsed = new DOMParser().parseFromString(`<root>${fragment}</root>`, 'application/xml');
    return parsed.getElementsByTagName('injected').length;
  } catch (e) {
    return `PARSE_THROW ${e.name}: ${e.message}`;
  }
}

for (const name of [
  'safe',
  'safe; <injected/> &x',
  'x<injected',
  'x y'
]) {
  try {
    const ref = doc.createEntityReference(name);
    const xml = serializer.serializeToString(ref, { requireWellFormed: true });

    console.log(`[SERIALIZED] ${JSON.stringify(name)}: ${xml}`);
    console.log(`[INJECTED_COUNT] ${JSON.stringify(name)}: ${countInjected(xml)}`);
  } catch (e) {
    console.log(`[THROW] ${JSON.stringify(name)}: ${e.name}: ${e.message}`);
  }
}

Observed output:

[SERIALIZED] "safe": &safe;
[INJECTED_COUNT] "safe": 0

[SERIALIZED] "safe; <injected/> &x": &safe; <injected/> &x;
[INJECTED_COUNT] "safe; <injected/> &x": 1

[SERIALIZED] "x<injected": &x<injected;
[INJECTED_COUNT] "x<injected": 0

[SERIALIZED] "x y": &x y;
[INJECTED_COUNT] "x y": 0

Impact

An application that creates an EntityReference from attacker-controlled input and then serializes that node or XML fragment with requireWellFormed: true may produce XML containing attacker-controlled markup.

The impact is limited by two observations:

  1. The parser does not create EntityReference nodes from ordinary XML entity references.
  2. Appending an EntityReference node as an element child is rejected with a HierarchyRequestError.

The main affected scenario is applications that directly use createEntityReference(name) and then serialize the resulting node or fragment.

Fix Applied

Two complementary, non-breaking fixes.
(1) document.createEntityReference(name) rejects an invalid Name at creation, closing the reachable creation vector by default β€” the opt-in serializer check alone cannot, since a later nodeName mutation would bypass a creation-only guard.
(2) Under requireWellFormed, the serializer validates the EntityReference nodeName as a well-formed XML Name and throws InvalidStateError when it is not; a valid reference still serializes as &name;. Both ship on both maintained versions. The EntityReference / createEntityReference docs note that under requireWellFormed the nodeName is validated as an XML Name, and that xmldom does not expand entities. 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
'use strict';

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

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

// Creation-time anchor (applied by default): an invalid XML Name is rejected at creation.
try {
  doc.createEntityReference('safe; <injected/> &x');
} catch (e) {
  console.log(`${e.name}`); // rejected at creation
}

// Default path (requireWellFormed omitted): because creation now rejects an ill-formed name,
// an ill-formed nodeName is only reachable via a post-creation mutation β€” and is emitted verbatim.
const ref = doc.createEntityReference('safe');
ref.nodeName = 'safe; <injected/> &x';
console.log(serializer.serializeToString(ref));
// -> &safe; <injected/> &x;   (injection present on the default path)

// Opt-in path: throws on the invalid nodeName.
try {
  serializer.serializeToString(ref, { requireWellFormed: true });
} catch (e) {
  console.log(`${e.name}`); // InvalidStateError
}

// A valid name still serializes as &name; under requireWellFormed.
const ok = doc.createEntityReference('valid');
console.log(serializer.serializeToString(ok, { requireWellFormed: true }));
// -> &valid;
Why the default stays verbatim

The creation-time anchor is applied by default, because it is classified non-breaking. The serializer check, by contrast, stays gated behind { requireWellFormed: true }: W3C DOM Parsing's require-well-formed flag defaults to false, and the browser XMLSerializer emits the nodeName verbatim in that default mode, so unconditionally throwing for an ill-formed EntityReference.nodeName would be an unjustified breaking change β€” which is why the default serialization path stays verbatim.

Residual limitation

The creation vector is closed by default β€” the non-breaking creation-time anchor β€” with no further deferred work. The residual is at serialization: the default path still emits an ill-formed nodeName verbatim, because the serializer check is opt-in via { requireWellFormed: true }.

Severity

  • CVSS Score: 6.3 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


xmldom: requireWellFormed DocType publicId/systemId validation is bypassable via an embedded line terminator

CVE-2026-83618 / GHSA-vr34-hp96-76pp

More information

Details

Summary

An embedded line terminator bypasses the requireWellFormed serializer check for a DocumentType's
publicId and systemId. The check was added to fix GHSA-f6ww-3ggp-fr8h; an id whose first line is a
valid literal slips past it and is emitted verbatim into the <!DOCTYPE …> declaration, so the markup
after the line terminator breaks out into the surrounding document. Callers who enabled
requireWellFormed to neutralize DocumentType injection remain exposed.

Details

publicId and systemId are stored as raw values including their surrounding quotes, and the
PubidLiteral/SystemLiteral productions include those quotes. The serializer validates them with
g.PubidLiteral_match.test(publicId) and g.SystemLiteral_match.test(systemId), where both matchers
are reg('^', …, '$') and inherit the m flag from xmldom's shared regexp builder. Under m, $
matches at an interior line terminator, so a value such as "valid pubid"\n"><!ENTITY …> satisfies
the matcher on its first line ("valid pubid" is a complete PubidLiteral) and the whole value β€”
including the post-newline breakout β€” is emitted after PUBLIC/SYSTEM.

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 complete, valid literal on the first line passes even though a line terminator
    and breakout markup follow. PubidChar excluding </> does not prevent it β€” the breakout is
    appended after the literal, not embedded inside it.

The triggering line terminators are the ECMAScript LineTerminator set: U+000A, U+000D, U+2028, U+2029.

Affected Versions

Only @xmldom/xmldom 0.9.x is affected. The vulnerable matchers are built by lib/grammar.js's
m-flagged reg() builder, and the DocType publicId/systemId requireWellFormed check that
consumes them was introduced in 0.9.10 (the GHSA-f6ww-3ggp-fr8h fix); 0.9.10 and 0.9.11 carry it.
0.8.x performs the same requireWellFormed check with inline, non-m regular expressions and is not
affected. The unscoped xmldom package has no grammar.js and no requireWellFormed serializer, so
there is no check to bypass.

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

// publicId: complete literal on line 1, then newline + breakout
const dt = impl.createDocumentType('html', '"valid pubid"\n"><!ENTITY xxe SYSTEM "file:///etc/passwd">', '');
const doc = impl.createDocument(null, 'root', dt);
console.log(new XMLSerializer().serializeToString(doc, { requireWellFormed: true }));
// Observed (no throw):
//   <!DOCTYPE html PUBLIC "valid pubid"
//   "><!ENTITY xxe SYSTEM "file:///etc/passwd">><root/>
// Expected: InvalidStateError (publicId is not a valid PubidLiteral).
// Control: a single-line invalid publicId ("no-surrounding-quotes<>") DOES throw InvalidStateError,
// confirming the check is active and specifically bypassed by the line terminator.
Impact
  • Bypass of the GHSA-f6ww-3ggp-fr8h mitigation. Applications that adopted requireWellFormed: true to neutralize DocumentType injection remain exposed.
  • XML structure injection into the DOCTYPE, including injected markup / entity declarations after
    the public or system identifier.
Fix Applied

The anchored PubidLiteral/SystemLiteral validators used by the requireWellFormed
serializer no longer treat an interior line terminator as satisfying the $ anchor, so a publicId
or systemId containing any ECMAScript LineTerminator (U+000A, U+000D, U+2028, U+2029) is rejected
with InvalidStateError. Valid single-line identifiers serialize unchanged, and the default
serialization path is unaffected.

⚠ 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
const { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom');
const impl = new DOMImplementation();
const dt = impl.createDocumentType('html', '"valid pubid"\n"><!ENTITY xxe SYSTEM "file:///etc/passwd">', '');
const doc = impl.createDocument(null, 'root', dt);

// Default path (requireWellFormed off) β€” unchanged, still emits verbatim:
console.log(new XMLSerializer().serializeToString(doc));
//   <!DOCTYPE html PUBLIC "valid pubid"
//   "><!ENTITY xxe SYSTEM "file:///etc/passwd">><root/>

// Opt-in path β€” now throws instead of emitting the breakout:
new XMLSerializer().serializeToString(doc, { requireWellFormed: true });
//   InvalidStateError: DocumentType publicId is not a valid PubidLiteral
Why the default stays verbatim

The W3C DOM Parsing "require well-formed" flag defaults to false, and a browser XMLSerializer emits
the DOCTYPE verbatim. Unconditionally throwing on a malformed publicId/systemId would be an
unjustified breaking change to the default path, so the fix tightens only the opt-in
requireWellFormed validator, matching browser and spec defaults.

Residual limitation

The guarantee holds only for callers that pass { requireWellFormed: true }; the default
serialization path still emits publicId/systemId verbatim. publicId and systemId are not
validated at creation (createDocumentType) or on direct property assignment
(documentType.publicId = …) β€” the WHATWG DOM specification places no well-formedness constraint on
these fields at creation time, so the serializer is the spec-aligned enforcement point.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: 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

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


xmldom: HTML raw-text closing-tag case mismatch causes output amplification

CVE-2026-83612 / GHSA-6mj3-qw4j-hgrw

More information

Details

Summary

In HTML mode (text/html), a raw-text element (script, style, textarea, title) whose closing
tag differs in case from its opening tag (e.g. </ScRiPt> for <script>) is mishandled by the
parser, producing quadratic (O(nΒ²)) output growth β€” a small crafted document parses and serializes
into output orders of magnitude larger, exhausting CPU and memory. A modest input of tens of KB can
therefore cause a denial of service in any service that parses untrusted HTML with xmldom. Only HTML
mode is affected.

Details

The parser calls parseHtmlSpecialContent for each raw-text element in HTML mode, matched via
isHTMLRawTextElement / isHTMLEscapableRawTextElement (so all four types β€” script, style,
textarea, title β€” are in scope). It searches for the element's closing tag with
source.indexOf('</' + tagName + '>', elStartEnd), a byte-for-byte case-sensitive match. A
mixed-case closing tag never matches, so the search returns -1, and the following
source.substring(elStartEnd + 1, -1) extracts text backwards from the start of the document
instead of the element's content. The function then returns -1 to the parse loop, which cannot
advance normally and falls back to character-by-character reprocessing. Every raw-text element
re-captures all source text preceding it, so output grows as O(nΒ²) in the number of such elements.

Root Cause
  1. Case-sensitive close-tag search (lib/sax.js:549): source.indexOf('</' + tagName + '>', elStartEnd) does not fold case, contrary to the WHATWG HTML RAWTEXT end-tag-name rule.
  2. Unguarded -1 (lib/sax.js:550): source.substring(elStartEnd + 1, elEndStart) runs even
    when elEndStart === -1, extracting text backwards from position 0.
  3. Unstable progression (lib/sax.js:556): the function returns elEndStart (-1), driving
    repeated character-by-character fallback in the parse loop.
Affected Versions

Only the 0.9.x line is affected β€” the amplification was introduced in 0.9.0-beta.1 when
parseHtmlSpecialContent was refactored, and remains through 0.9.11. The 0.8.x line is not
affected: its older parseHtmlSpecialContent does not amplify, despite sharing the same
case-sensitive indexOf.

Proof of Concept
const { DOMParser, XMLSerializer } = require('@xmldom/xmldom');

const n = 1000;
const payload = '<html><body>' + '<script>x</ScRiPt>'.repeat(n) + '</body></html>';
const doc = new DOMParser().parseFromString(payload, 'text/html');
const out = new XMLSerializer().serializeToString(doc);
console.log(payload.length, out.length, (out.length / payload.length).toFixed(1) + 'x');
// 18026 9037063 501.3x  β€” an 18 KB input yields ~9 MB of output

Output size grows quadratically with the number of case-mismatched raw-text elements:

Repeats | Input len | Output len | Ratio
1       | 44        | 109        | 2.5x
100     | 1826      | 93763      | 51.3x
500     | 9026      | 2268563    | 251.3x
1000    | 18026     | 9037063    | 501.3x
2000    | 36026     | 36074063   | 1001.3x

Proof of Concept from @​KarimTantawey (tested with script); the same amplification occurs for style,
textarea, and title.

Impact

Small attacker payloads can force disproportionate CPU and memory usage in services that
parse and serialize untrusted HTML via xmldom. The quadratic growth means a modest-sized input
(tens of kilobytes) can produce output in the tens or hundreds of megabytes, potentially
exhausting memory or causing timeouts.

The attack only requires HTML mode (text/html MIME type) and mixed-case closing tags for
any of the four raw-text element types. No special configuration or error handler setup is needed.

Severity note

The CVSS 4.0 vector scores availability only (VA:H, with VC:N/VI:N): the flaw neither discloses
nor corrupts data, but a small untrusted HTML input (tens of KB) can force output and memory in the
tens to hundreds of MB, enough to exhaust a service's heap or stall its event loop. It is reachable
with no authentication, configuration, or error-handler setup β€” only that the application parses
untrusted text/html and serializes the result.

Fix Applied

The raw-text closing tag is now matched case-insensitively in HTML raw-text mode (per the WHATWG HTML
RAWTEXT end-tag rule),
and a missing closing tag is handled explicitly, removing the quadratic output amplification. Output
for well-formed input is unchanged. Non-breaking; 0.9.x-only.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: 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

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


xmldom: Quadratic-time attribute deduplication

CVE-2026-83613 / GHSA-8344-3jmq-59r6

More information

Details

Summary

xmldom builds the attribute collection of every parsed element by inserting attributes one at a
time into a DOM NamedNodeMap. Each insertion first performs a linear scan of all
already-inserted attributes
to enforce the DOM uniqueness rule (no two attributes with the same
qualified name / namespace+local-name). Parsing an element that carries M distinct attributes
therefore costs 1 + 2 + … + M = O(MΒ²) comparisons.

Because the trigger is simply "one element with many attributes", the attack payload is a
fully well-formed XML document. No malformed markup, no error recovery, and no non-default
parser options are involved β€” parsing completes silently with zero warning/error/fatalError
events. An attacker who can submit a modest, highly compressible document (a single element with
tens of thousands of attributes, ~340 KB uncompressed) can consume seconds of single-threaded CPU
per request, enabling an unauthenticated denial of service.

This is distinct from the known quadratic-memory namespace-map issue: it burns CPU and it
does not require any namespace declarations or nesting.

Details

The DOM content handler adds each attribute of a starting element by calling
el.setAttributeNode(attr) in a loop:

// DOMHandler.startElement
for (var i = 0; i < len; i++) {
	var namespaceURI = attrs.getURI(i);
	var value = attrs.getValue(i);
	var qName = attrs.getQName(i);
	var attr = doc.createAttributeNS(namespaceURI, qName);
	attr.value = attr.nodeValue = value;
	el.setAttributeNode(attr);          // O(existing attrs) each β€” see below
}

https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom-parser.js#L370-L387

setAttributeNode delegates to NamedNodeMap.setNamedItem, which calls getNamedItemNS to look
for an existing attribute with the same namespace URI and local name before appending:

setNamedItem: function (attr) {
	var el = attr.ownerElement;
	if (el && el !== this._ownerElement) {
		throw new DOMException(DOMException.INUSE_ATTRIBUTE_ERR);
	}
	var oldAttr = this.getNamedItemNS(attr.namespaceURI, attr.localName);  // linear scan
	if (oldAttr === attr) {
		return attr;
	}
	_addNamedNode(this._ownerElement, this, attr, oldAttr);
	return oldAttr;
},

https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom.js#L612-L623

getNamedItemNS walks the whole list on every call:

getNamedItemNS: function (namespaceURI, localName) {
	if (!namespaceURI) {
		namespaceURI = null;
	}
	var i = 0;
	while (i < this.length) {
		var node = this[i];
		if (node.localName === localName && node.namespaceURI === namespaceURI) {
			return node;
		}
		i++;
	}
	return null;
},

https://github.com/xmldom/xmldom/blob/bb7a085dc5ba1eea3212388509b97bb4b4af32b9/lib/dom.js#L702-L715

For the i-th attribute the scan visits i-1 entries, so inserting M distinct attributes performs
Θ(M²) comparisons. There is no hash index or set keyed by name; the map is a plain
array-backed structure.

The same structure exists on 0.8.x. There setNamedItem dedups via
getNamedItem(attr.nodeName) instead of getNamedItemNS, but that method is likewise a full linear
scan, so the complexity is identical:

The linear-scan NamedNodeMap predates the @xmldom/xmldom fork and is present unchanged in the
unscoped xmldom package back to its earliest published release. In xmldom@0.1.0, parsing already
inserts each attribute one at a time (DOMHandler.startElement loops calling
setAttributeNS β†’ setAttributeNode β†’ NamedNodeMap.setNamedItem), and setNamedItem dedups by
calling getNamedItemNS, which is a full linear while (i--) scan of the already-inserted
attributes β€” the identical O(MΒ²) structure. The whole unscoped line (0.1.0 … 0.6.0) is
therefore affected; the earliest published tag (0.1.0) was verified to contain the per-insert
linear dedup scan.

Proof of Concept

A single well-formed element with M distinct attributes. No malformed markup and no options:

'use strict';
var DOMParser = require('@xmldom/xmldom').DOMParser;

function buildDoc(m) {
	var parts = new Array(m);
	for (var i = 0; i < m; i++) parts[i] = 'a' + i + '="x"';
	return '<r ' + parts.join(' ') + '/>';   // <r a0="x" a1="x" ... a{M-1}="x"/>
}

for (var _i = 0, sizes = [2000, 4000, 8000, 16000, 32000]; _i < sizes.length; _i++) {
	var m = sizes[_i];
	var xml = buildDoc(m);
	var t0 = process.hrtime.bigint();
	var doc = new DOMParser().parseFromString(xml, 'text/xml');  // silent: no error events
	var ms = Number(process.hrtime.bigint() - t0) / 1e6;
	console.log(m + ' attrs, ' + xml.length + ' bytes -> ' + ms.toFixed(1) + ' ms; parsed=' +
		doc.documentElement.attributes.length);
}

Measured with Node.js v18.20.8 (wall-clock; absolute numbers vary by host, the scaling is the
load-bearing fact):

@xmldom/xmldom 0.9.10:

M (attributes) input bytes time (ms) ratio vs prev
2000 18,894 13.4 β€”
4000 38,894 38.7 Γ—2.9
8000 78,894 100.8 Γ—2.6
16000 164,894 406.2 Γ—4.0
32000 340,894 2149.5 Γ—5.3

@xmldom/xmldom 0.8.13:

M (attributes) input bytes time (ms)
2000 18,894 10.6
4000 38,894 19.9
8000 78,894 75.9
16000 164,894 657.7
32000 340,894 1643.2

xmldom (unscoped) 0.6.0: 4000 β†’ 28.2 ms, 8000 β†’ 131.8 ms, 16000 β†’ 545.2 ms (β‰ˆ Γ—4 per doubling).

Time grows β‰ˆ Γ—4 per doubling of M β€” quadratic. About 340 KB of well-formed input costs ~1.6–2.1 s
of single-threaded CPU
, and it keeps scaling: doubling the attribute count quadruples the cost.
The document is trivially generated and compresses to a few kilobytes on the wire.

Impact

Unauthenticated, remotely triggerable denial of service against any service that parses
attacker-influenced XML/HTML with xmldom. A single request holds one event-loop thread for seconds;
a handful of concurrent requests can saturate CPU and stall the process. Because the payload is a
plain well-formed document (one element, many attributes), it passes any "must be well-formed" gate
and reaches the parser before any application-level validation (e.g. schema checks or signature
verification) can run. The payload is highly compressible, so it is effective over compressed
transports.

Fix Applied

Replaced the per-insert linear duplicate scan on the parse-time dedup path with a name-keyed
index, so de-duplicating an element's attributes during parse is O(M) instead of O(MΒ²) β€” a
well-formed-but-hostile attribute list can no longer wedge the parse. Behavior-preserving: attribute
order and duplicate resolution (last value wins, first position kept) are byte-identical. Non-breaking
and independent of requireWellFormed; ships on both maintained versions.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: 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

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


xmldom: Attribute name injection via setAttribute() bypasses requireWellFormed

CVE-2026-83605 / GHSA-4w3w-2rp5-g8jm

More information

Details

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 INVALID_CHARACTER_ERR (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
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
// 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
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.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: 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

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


xmldom: DocType name Injection Bypasses requireWellFormed

CVE-2026-83608 / GHSA-27p8-2357-5qqv

More information

Details

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 serializeToString() call sites and add it.

Proof of Concept - fixed path
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.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: 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

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


xmldom: Creation-time XML Name/QName validation is bypassable via an embedded line terminator, allowing injection on the default serialization path

CVE-2026-83609 / GHSA-3px3-54cx-rmw9

More information

Details

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.QName_exact.test(name). QName_exact = 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
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
    poc_creation_strict_serialization_bypass.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.

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: 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

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


xmldom: Parser silently accepts a not-well-formed end tag whose name is followed by a line break and trailing content

CVE-2026-83611 / GHSA-6h8r-xr42-gp59

More information

Details

Summary

xmldom's parser silently accepts a not-well-formed end tag whose valid name is followed by
trailing content β€” e.g. </a⏎junk>. The element is closed, the trailing content is discarded, and no
error is reported, even though the XML end-tag production allows only optional whitespace after the
name and both Chromium and Firefox reject such input as application/xml. An application that relies
on xmldom to reject not-well-formed input therefore receives a false "valid" result for a document the
specification and browsers consider malformed.

Details

Across every affected version, an end tag whose valid Name is followed by trailing content before
> is silently accepted: the element is closed, the residue is dropped, and no error is reported. How
much

βœ‚ Note

PR body was truncated to here.

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

βœ… All modified and coverable lines are covered by tests.
βœ… Project coverage is 63.83%. Comparing base (983884e) to head (9e03b0a).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1152      +/-   ##
==========================================
- Coverage   63.91%   63.83%   -0.09%     
==========================================
  Files          19       19              
  Lines        2425     2425              
  Branches      575      575              
==========================================
- Hits         1550     1548       -2     
- Misses        875      877       +2     

β˜” View full report in Codecov by Harness.
πŸ“’ Have feedback on the report? Share it here.

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants