{"id":"CVE-2026-41674","aliases":["GHSA-f6ww-3ggp-fr8h"],"url":"https://o3.security/vulnerability/CVE-2026-41674","summary":"xmldom: XML injection through unvalidated DocumentType serialization","details":"## Summary\n\nThe package serializes `DocumentType` node fields (`internalSubset`, `publicId`, `systemId`) verbatim\nwithout any escaping or validation. When these fields are set programmatically to attacker-controlled\nstrings, `XMLSerializer.serializeToString` can produce output where the DOCTYPE declaration is\nterminated early and arbitrary markup appears outside it.\n\n---\n\n## Details\n\n`DOMImplementation.createDocumentType(qualifiedName, publicId, systemId, internalSubset)` validates\nonly `qualifiedName` against the XML QName production. The remaining three arguments are stored\nas-is with no validation.\n\nThe XMLSerializer emits `DocumentType` nodes as:\n\n```\n<!DOCTYPE name[ PUBLIC pubid][ SYSTEM sysid][ [internalSubset]]>\n```\n\nAll fields are pushed into the output buffer verbatim — no escaping, no quoting added.\n\n**`internalSubset` injection:** The serializer wraps `internalSubset` with ` [` and `]`. A value\ncontaining `]>` closes the internal subset and the DOCTYPE declaration at the injection point.\nAny content after `]>` in `internalSubset` appears outside the DOCTYPE in the serialized output as\nraw XML markup. Reported by @TharVid (GHSA-f6ww-3ggp-fr8h). Affected: `@xmldom/xmldom` ≥ 0.9.0\nvia `createDocumentType` API; 0.8.x only via direct property write.\n\n**`publicId` injection:** The serializer emits `publicId` verbatim after `PUBLIC` with no\nquoting added. A value containing an injected system identifier (e.g.,\n`\"pubid\" SYSTEM \"evil\"`) breaks the intended quoting context, injecting a fake SYSTEM entry\ninto the serialized DOCTYPE declaration. Identified during internal security research. Affected:\nboth branches, all versions back to 0.1.0.\n\n**`systemId` injection:** The serializer emits `systemId` verbatim. A value containing `>`\nterminates the DOCTYPE declaration early; content after `>` appears as raw XML markup outside\nthe DOCTYPE context. Identified during internal security research. Affected: both branches, all\nversions back to 0.1.0.\n\nThe parse path is safe: the SAX parser enforces the `PubidLiteral` and `SystemLiteral` grammar\nproductions, which exclude the relevant characters, and the internal subset parser only accepts a\nsubset it can structurally validate. The vulnerability is reachable only through programmatic\n`createDocumentType` calls with attacker-controlled arguments.\n\n---\n\n## Affected code\n\n**`lib/dom.js` — `createDocumentType` (lines 898–910):**\n\n```js\ncreateDocumentType: function (qualifiedName, publicId, systemId, internalSubset) {\n    validateQualifiedName(qualifiedName);          // only qualifiedName is validated\n    var node = new DocumentType(PDC);\n    node.name = qualifiedName;\n    node.nodeName = qualifiedName;\n    node.publicId = publicId || '';               // stored verbatim\n    node.systemId = systemId || '';               // stored verbatim\n    node.internalSubset = internalSubset || '';   // stored verbatim\n    node.childNodes = new NodeList();\n    return node;\n},\n```\n\n**`lib/dom.js` — serializer DOCTYPE case (lines 2948–2964):**\n\n```js\ncase DOCUMENT_TYPE_NODE:\n    var pubid = node.publicId;\n    var sysid = node.systemId;\n    buf.push(g.DOCTYPE_DECL_START, ' ', node.name);\n    if (pubid) {\n        buf.push(' ', g.PUBLIC, ' ', pubid);\n        if (sysid && sysid !== '.') {\n            buf.push(' ', sysid);\n        }\n    } else if (sysid && sysid !== '.') {\n        buf.push(' ', g.SYSTEM, ' ', sysid);\n    }\n    if (node.internalSubset) {\n        buf.push(' [', node.internalSubset, ']');  // internalSubset emitted verbatim\n    }\n    buf.push('>');\n    return;\n```\n\n---\n\n## PoC\n\n### internalSubset injection\n\n```js\nconst { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom');\n\nconst impl = new DOMImplementation();\nconst doctype = impl.createDocumentType(\n    'root',\n    '',\n    '',\n    ']><injected/><![CDATA['\n);\nconst doc = impl.createDocument(null, 'root', doctype);\nconst xml = new XMLSerializer().serializeToString(doc);\nconsole.log(xml);\n// <!DOCTYPE root []><injected/><![CDATA[]><root/>\n//                   ^^^^^^^^^^  injected element outside DOCTYPE\n```\n\n### publicId quoting context break\n\n```js\nconst { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom');\n\nconst impl = new DOMImplementation();\nconst doctype = impl.createDocumentType(\n    'root',\n    '\"injected PUBLIC_ID\" SYSTEM \"evil\"',\n    '',\n    ''\n);\nconst doc = impl.createDocument(null, 'root', doctype);\nconsole.log(new XMLSerializer().serializeToString(doc));\n// <!DOCTYPE root PUBLIC \"injected PUBLIC_ID\" SYSTEM \"evil\"><root/>\n// quoting context broken — SYSTEM entry injected\n```\n\n### systemId injection\n\n```js\nconst { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom');\n\nconst impl = new DOMImplementation();\nconst doctype = impl.createDocumentType(\n    'root',\n    '',\n    '\"sysid\"><injected attr=\"pwn\"/>',\n    ''\n);\nconst doc = impl.createDocument(null, 'root', doctype);\nconsole.log(new XMLSerializer().serializeToString(doc));\n// <!DOCTYPE root SYSTEM \"sysid\"><injected attr=\"pwn\"/>><root/>\n// > in sysid closes DOCTYPE early; <injected/> appears as sibling element\n```\n\n---\n\n## Impact\n\nAn application that programmatically constructs `DocumentType` nodes from user-controlled data and\nthen serializes the document can emit a DOCTYPE declaration where the internal subset is closed\nearly or where injected SYSTEM entities or other declarations appear in the serialized output.\n\nDownstream XML parsers that re-parse the serialized output and expand entities from the injected\nDOCTYPE declarations may be susceptible to XXE-class attacks if they enable entity expansion.\n\n---\n\n## Fix Applied\n\n> **⚠ Opt-in required.** Protection is not automatic. Existing serialization calls remain\n> vulnerable unless `{ requireWellFormed: true }` is explicitly passed. Applications that pass\n> untrusted data to `createDocumentType()` or write untrusted values directly to a\n> `DocumentType` node's `publicId`, `systemId`, or `internalSubset` properties should audit\n> all `serializeToString()` call sites and add the option.\n\n`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:\n\n- **`publicId`**: throws if non-empty and does not match the XML `PubidLiteral` production (XML 1.0 [12])\n- **`systemId`**: throws if non-empty and does not match the XML `SystemLiteral` production (XML 1.0 [11])\n- **`internalSubset`**: throws if it contains `]>` (which closes the internal subset and DOCTYPE declaration early)\n\nAll three checks apply regardless of how the invalid value entered the node — whether via `createDocumentType` arguments or a subsequent direct property write.\n\n### PoC — fixed path\n\n```js\nconst { DOMImplementation, XMLSerializer } = require('@xmldom/xmldom');\nconst impl = new DOMImplementation();\n\n// internalSubset injection\nconst dt1 = impl.createDocumentType('root', '', '', ']><injected/><![CDATA[');\nconst doc1 = impl.createDocument(null, 'root', dt1);\n\n// Default (unchanged): verbatim — injection present\nconsole.log(new XMLSerializer().serializeToString(doc1));\n// <!DOCTYPE root []><injected/><![CDATA[]><root/>\n\n// Opt-in guard: throws InvalidStateError\ntry {\n  new XMLSerializer().serializeToString(doc1, { requireWellFormed: true });\n} catch (e) {\n  console.log(e.name, e.message);\n  // InvalidStateError: DocumentType internalSubset contains \"]>\"\n}\n```\n\nThe guard also covers post-creation property writes:\n\n```js\nconst dt2 = impl.createDocumentType('root', '', '');\ndt2.systemId = '\"sysid\"><injected attr=\"pwn\"/>';\nconst doc2 = impl.createDocument(null, 'root', dt2);\nnew XMLSerializer().serializeToString(doc2, { requireWellFormed: true });\n// InvalidStateError: DocumentType systemId is not a valid SystemLiteral\n```\n\n### Why the default stays verbatim\n\nThe 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.\n\n### Residual limitation\n\n`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.\n\nWhen 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.","published":"2026-05-07T03:47:51.140Z","modified":"2026-09-12T03:30:23.657281818Z","cvss":null,"epss":{"score":0.00457,"percentile":0.37727,"asOf":"2026-08-14"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"@xmldom/xmldom","fixedVersion":"0.8.13"},{"ecosystem":"npm","name":"@xmldom/xmldom","fixedVersion":"0.9.10"},{"ecosystem":"npm","name":"xmldom","fixedVersion":null}],"fix":{"url":"https://github.com/xmldom/xmldom/commit/372008f9ae0e20fd69f761c7b79e202598267314","label":"xmldom/xmldom@372008f"},"references":[{"type":"WEB","url":"https://github.com/xmldom/xmldom/releases/tag/0.8.13"},{"type":"WEB","url":"https://github.com/xmldom/xmldom/releases/tag/0.9.10"},{"type":"WEB","url":"https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-41674.json"},{"type":"ADVISORY","url":"https://access.redhat.com/errata/RHSA-2026:20034"},{"type":"ADVISORY","url":"https://access.redhat.com/errata/RHSA-2026:21338"},{"type":"ADVISORY","url":"https://access.redhat.com/errata/RHSA-2026:21703"},{"type":"ADVISORY","url":"https://access.redhat.com/errata/RHSA-2026:26234"},{"type":"ADVISORY","url":"https://access.redhat.com/security/cve/CVE-2026-41674"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/41xxx/CVE-2026-41674.json"},{"type":"ADVISORY","url":"https://github.com/xmldom/xmldom/security/advisories/GHSA-f6ww-3ggp-fr8h"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-41674"},{"type":"REPORT","url":"https://bugzilla.redhat.com/show_bug.cgi?id=2467620"},{"type":"FIX","url":"https://github.com/xmldom/xmldom/commit/372008f9ae0e20fd69f761c7b79e202598267314"},{"type":"PACKAGE","url":"https://github.com/xmldom/xmldom"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-12T03:30:23.657281818Z"}}