Choose your stack

Stacks without a card

  • Node.js and Express: no card yet. The nearest is Fix XML external entity processing in Python and Flask.
    Why there is no Node.js and Express card

    No Node.js XML parser that resolves attacker-controlled entities installs cleanly on Node 26, so a vulnerable-vs-fixed pair cannot be built honestly on this runtime. Verified: libxmljs2 0.37.0 (the libxml2 binding) fails to compile against Node 26.3.0's V8 ABI (node-gyp build error, GetAlignedPointerFromInternalField signature change); fast-xml-parser 5.3.6 does not expand DOCTYPE-declared entities (it keeps '&c;' literal) and throws 'External entities are not supported' (only programmatic addEntity expands), and its entity-expansion flaw CVE-2026-26278 affects only the older 4.1.3-5.3.5 range, which the pinning rule forbids shipping; @xmldom/xmldom 0.9.8 reports 'entity not found' for custom and external entities and resolves neither. That pure-JS XML parsers do not dereference external entities is itself a useful defensive fact: on Node the exposure is overwhelmingly entity-expansion (CWE-776) in older or misconfigured libraries, addressed by keeping the parser current and disabling DOCTYPE processing.

  • TypeScript and Next.js: no card yet. The nearest is Fix XML external entity processing in Python and Flask.
  • Python and FastAPI: no card yet. The nearest is Fix XML external entity processing in Python and Flask, in the same language.
  • Python and Django: no card yet. The nearest is Fix XML external entity processing in Python and Flask, in the same language.
  • Go and net/http: safe by default. Go's standard encoding/xml decoder does not load external DTDs or resolve external entities: it hands <!DOCTYPE> and <!ENTITY> declarations to the caller as uninterpreted directives and expands only the predefined entities, character references and the fixed strings you put in Decoder.Entity. Another XML parser, such as a binding to libxml2, needs its own check before it reads untrusted XML. Checked for Go 1.25 and 1.26, standard library encoding/xml against Go source: src/encoding/xml/xml.go (go1.26.0) and Go standard library: encoding/xml Decoder.
  • Ruby on Rails: safe by default. Rails parses only JSON request bodies by default (XML parameter parsing left Rails in 4.0 for the actionpack-xml_parser gem), and Nokogiri's default XML options, RECOVER | NONET | BIG_LINES, neither substitute entities nor reach the network. Setting Nokogiri's NOENT or DTDLOAD option, or clearing NONET, on untrusted documents. Keep the REXML gem at 3.3.3 or later for its entity-expansion fixes. Checked for Rails 8.0 and 8.1, Nokogiri 1.19 against Rails guides: upgrading from Rails 3.2 to Rails 4.0, Rails source: action_dispatch/http/parameters.rb (8-1-stable), Nokogiri source: XML::ParseOptions and Ruby security advisory: CVE-2024-41946, DoS in REXML.
  • PHP and Laravel: safe by default. With libxml2 2.9.0 or later, the minimum every supported PHP release builds against, PHP's XML extensions neither substitute entities nor load external ones unless the code passes LIBXML_NOENT, LIBXML_DTDLOAD or LIBXML_DTDVALID; this is why PHP 8.0 deprecated libxml_disable_entity_loader(). libxml2 also limits entity expansion unless LIBXML_PARSEHUGE is passed. Passing LIBXML_NOENT, LIBXML_DTDLOAD, LIBXML_DTDVALID or LIBXML_PARSEHUGE when parsing untrusted XML. Checked for PHP 8.2 to 8.5 (Laravel 12 and 13) with libxml2 2.9.0 or later against PHP manual: libxml_disable_entity_loader, PHP manual: libxml predefined constants and Laravel 13 release notes: support policy.

How it happens

  • A feature parses XML a client controls: a SAML or SOAP message, a document import, an RSS or sitemap fetch, an office-document part, a webhook body. The parser is created with library defaults, and for most Java and .NET-legacy XML parsers the default resolves external entities.
  • The attacker sends a DOCTYPE with an external general entity, <!ENTITY x SYSTEM "file:///etc/passwd">, and references &x; in an element the response echoes back. The parser reads the file and inlines its bytes, so the response leaks it (CWE-611). Pointing the entity at http://169.254.169.254/ or an internal host turns the parser into an SSRF client.
  • Where the response is not echoed, a parameter entity in the DTD can exfiltrate out of band: it builds a URL from the stolen file and forces the parser to fetch it from an attacker server (the classic error-based or OOB XXE).
  • Nested internal entities expand multiplicatively: ten entities each referencing the previous ten turn a few hundred bytes into gigabytes (the 'billion laughs' entity-expansion denial of service, CWE-776). No external access is needed.
  • Input-string defences fail. A byte or substring filter that rejects '<!DOCTYPE' or '<!ENTITY' is bypassed by encoding the document as UTF-16 (or UTF-7): the parser honours the encoding declaration and sees the DOCTYPE, while the ASCII filter never matched the bytes.

Fix principles

  • Disable the DTD entirely wherever the parser offers it: this is the single most effective control and the one the OWASP cheat sheet lists first. In Java set the Apache feature 'disallow-doctype-decl' to true; in .NET leave (or set) XmlReaderSettings.DtdProcessing = Prohibit; in Python use defusedxml with forbid_dtd=True.
  • Where some DTD support is genuinely required, disable the dangerous parts instead: external general entities, external parameter entities, external DTD loading and XInclude, and do not expand entity references. Set each feature off rather than relying on defaults.
  • Null out the entity/URL resolver so the parser has no way to fetch anything: XmlResolver = null in .NET, an EntityResolver that throws in Java, no_network=True in lxml.
  • Validate where the value is used, not by filtering the input string. Reject the DOCTYPE at the parser; do not try to strip '<!DOCTYPE' from bytes, which a different character encoding defeats.
  • Bound the input: cap the request body size, and cap entity expansion (JAXP secure processing, .NET MaxCharactersFromEntities, lxml's built-in limits) so a small document cannot expand without bound.
  • Keep parser error text off the response and prefer a data format without a DTD (JSON) when the XML itself is not a requirement.

How the cards prove it

  • Run each card's tests in three modes: the fixed implementation passes, and the vulnerable and the plausible-but-wrong (input-filter) implementations fail. run-all.mjs does this for every card.
  • Exploit tests use only self-contained inputs: a DOCTYPE whose external entity points at a marker file the test creates in its own temp directory (assert the marker never appears in the response and the parser rejects the DTD), and a small bounded internal-entity expansion a few levels deep (assert it is refused, never a real billion-laughs). Each vector is sent once as UTF-8 and once as UTF-16, which slips past the wrong implementation's byte filter. Functional tests parse an ordinary document, predefined and numeric character references, and non-ASCII text, and answer 400 for malformed or empty input.
  • Static analysis is strong at finding the sink and weak at proving a config safe. Each card's scannerRules records what each Semgrep rule, CodeQL query and built-in analyzer (Bandit, .NET CA3075) actually did on the three implementations; several flag the hardened code too, because they match the parser call rather than read its configuration.

References

  1. OWASP XML External Entity Prevention Cheat Sheet
  2. OWASP Top 10:2025 A02 Security Misconfiguration (CWE-611 and CWE-776 mapped here)
  3. CWE-611: Improper Restriction of XML External Entity Reference
  4. CWE-776: Improper Restriction of Recursive Entity References in DTDs ('XML Entity Expansion')
  5. CAPEC-221: Data Serialization External Entities Blowup
  6. CAPEC-197: Exponential Data Expansion
  7. PortSwigger Web Security Academy: XML external entity (XXE) injection
  8. W3C XML 1.0 (Fifth Edition): entity declarations and the DTD

Attack patterns:

Practice in the browser

Guides on this weakness