Safeguard
Application Security•

Stop XXE Before Merge: Parser Settings, CI Checks, Upgrades

XXE prevention that holds: the parser settings that block an XXE attack in Java, .NET, Python, PHP, Go, Node.js and Ruby, the CI checks that stop an unsafe parser from merging, and the library versions that fix known XXE bugs.

Safeguard Security Team
37 min read

To stop XXE before merge you need three controls working together: a secure parser setting at every place your code creates an XML parser, a CI check that fails the build when a new parser appears without that setting, and a dependency gate that blocks XML library versions with known XXE advisories. Each control catches a failure the other two miss. A setting fixes today's code, a CI rule keeps tomorrow's code honest, and an upgrade gate covers bugs inside the libraries themselves.

This post is the enforcement half of XXE prevention. If you want the explainer first (what an XML external entity is, how the attack works and how to fix a single parser by hand), read our guide to XML external entity (XXE) prevention, and for Java specifics see how to fix XXE in Java. Here we assume you already know what XXE is and want it to stop showing up in pull requests across every language your teams write.

Key takeaways

  • XXE is mostly a configuration problem, not a library version problem. Most findings come from a parser created with defaults that allow DTDs or external entities, so the main fix is a setting in your own code.
  • Safe defaults differ by language and by version. libxml2 2.9 and later disables entity substitution by default, .NET Framework 4.5.2 and later made XmlDocument and XmlTextReader safe by default, and lxml 6.1.0 extended its safe resolve_entities='internal' default to iterparse() and ETCompatXMLParser(). Older runtimes and older libraries keep the unsafe behaviour.
  • A CI rule for XXE is simple in logic: find every place a parser or factory is constructed and fail if the secure setting is missing in the same scope, or if an unsafe option (such as entity substitution) is switched on.
  • A negative test, a small fixture document with a DOCTYPE that the parser must reject, proves the setting works on the implementation that actually loads at runtime.
  • Library bugs are real too. GitHub advisories list XXE fixes in lxml 6.1.0, Nokogiri 1.11.0, 1.12.5 and 1.19.4, and dom4j 2.0.3 and 2.1.3. A dependency gate in CI keeps the vulnerable versions out.
  • Some libraries have advisories with no patched version at all. Upgrading is not an option there, so plan for replacement, isolation or a patched build.

Why XXE keeps coming back after the explainer

Most teams have fixed a parser by hand after a scanner finding, and the finding still returns months later:

  1. A new parser appears in a new place. A SAML handler, an SVG preview, a SOAP client or a partner feed reader arrives with a one line parse call, and nothing tells its author that this parser needs the setting the others have.
  2. A hardened helper gets bypassed. A new service copies a documentation snippet instead of importing the team's safe XML helper.
  3. A library pulls in its own parser. A dependency parses XML internally with its own defaults. If it has an XXE bug, only an upgrade fixes it.
  4. The runtime changes underneath the code. A new base image brings a different libxml2 or Expat build, or a Java application loads a different parser implementation from the classpath.

Each is an enforcement failure, and the answer is checks that run on every pull request.

XXE in one paragraph

An XML document can carry a document type definition (DTD) that declares entities, and an external entity points at a URI such as a local file or a network address. A parser that resolves it may read the file or make the request on the attacker's behalf, and recursive entities can exhaust memory. The OWASP XML External Entity Prevention Cheat Sheet's safest advice is to disable DTDs completely when you do not need them, and almost no application needs them on input from outside.

Three layers of an XXE fix

LayerWhat it fixesWhere it runsWhat it misses
Parser settingUnsafe defaults in parsers your code createsYour source code, at each parser or factoryParsers created inside libraries; parsers added later
CI rule and negative testNew or changed parser code that lacks the settingPull request checksBugs inside library code; runtime implementation drift unless the test runs against the real runtime
Dependency gate and upgradeKnown XXE advisories in XML libraries and their bundled native parsersSoftware composition analysis plus a policy gate in CIYour own parser misconfiguration, which has no CVE

Layer 1: parser settings by language

The table below summarises what the OWASP cheat sheet and each library's own documentation say. Use it as the specification for your CI rules in Layer 2. The safe choice in every row is the same idea: do not process DTDs from untrusted input, and if you must, never resolve external entities or load external DTDs.

Language and parserSafe by default?Setting or practice that blocks XXESource
Java JAXP (DocumentBuilderFactory, SAXParserFactory, XMLInputFactory, TransformerFactory, SchemaFactory, Validator)No, varies by factory and implementationDisallow DOCTYPE declarations where supported; otherwise disable external general and parameter entities and external DTD loading; set ACCESS_EXTERNAL_DTD and related properties to emptyOWASP cheat sheet
.NET XmlReader, XmlNodeReader, XmlDictionaryReaderYesKeep DtdProcessing at Prohibit (or Ignore) and leave XmlResolver nullOWASP cheat sheet
.NET XmlDocument, XmlTextReader, XPathNavigatorYes on .NET Framework 4.5.2 and later; no beforeLeave XmlResolver null; on old frameworks set it to null and set DtdProcessing to ProhibitOWASP cheat sheet
ASP.NET on .NET FrameworkOnly if httpRuntime targetFramework is 4.5.2 or laterSet targetFramework on httpRuntime in Web.configOWASP cheat sheet
Python standard library (xml.etree, xml.dom, xml.sax, Expat)Expat does not access files or the network by defaultUse defusedxml interfaces for untrusted XML; keep Python and Expat updated (the cheat sheet says Expat versions below 2.7.2 may be affected by denial of service issues)OWASP cheat sheet
Python lxmlYes for the normal parsers since 5.0, and for all parsers since 6.1.0Set resolve_entities=False (or 'internal') explicitly; never pass resolve_entities=True for untrusted inputGHSA-vfmq-68hx-4jfw, lxml FAQ
PHP with libxml 2.9.0 or laterEntity substitution off by defaultDo not pass LIBXML_NOENT, LIBXML_DTDLOAD or LIBXML_DTDVALID for untrusted XML; install an external entity loader callback that returns nullOWASP cheat sheet, PHP manual
C and C++ libxml2 2.9 and laterYesNever pass XML_PARSE_NOENT or XML_PARSE_DTDLOAD to xmlCtxtReadMemory and related functions for untrusted inputOWASP cheat sheet
iOS NSXMLDocumentPartly (iOS 5 and later loads only entities that need no network)Pass NSXMLNodeLoadExternalEntitiesNeverOWASP cheat sheet
Go encoding/xmlThe package documents no option to load external DTDs or entitiesKeep the Entity map limited to names you control; treat Directive tokens (such as a DOCTYPE) as input to reject if you never expect themGo package docs
Node.js libxml2 bindingsDepends on the options the binding exposesDo not enable libxml2 entity substitution or DTD loading options, and check the binding's own documentation for the option nameslibxml2 guidance in the OWASP cheat sheet
Ruby NokogiriDefault XML parse options are documented as DEFAULT_XMLDo not add NOENT or DTDLOAD; keep NONET; never parse untrusted XSLT, whose DEFAULT_XSLT mask includes NOENT and DTDLOADNokogiri ParseOptions docs

Java

Java is where XXE findings are most common, because the JAXP factories have historically shipped with permissive defaults and because each factory type takes a different combination of features, attributes and properties. We will not repeat the code here. Our Java XXE fix guide walks through each factory. For enforcement, three facts from the OWASP cheat sheet matter most:

  • Every factory type needs its own setting. A hardened DocumentBuilderFactory does nothing for a TransformerFactory, SchemaFactory, Validator, XMLInputFactory or SAXParserFactory elsewhere in the code base. Your CI rule must cover each constructor, not just the one that caused the last finding.
  • The implementation can change. The cheat sheet warns that newInstance() can select a third party provider whose settings differ, and advises you to verify the provider's documented controls and fail if a required setting is unsupported. That is a direct instruction to test at runtime, not just to read code.
  • Returning null from a resolver is not a block. The cheat sheet notes that an EntityResolver returning null delegates resolution back to the parser. If you need internal DTDs, the resolver must throw for external references.

Some Java findings really are version problems, such as dom4j's unsafe default (see Layer 3).

.NET

.NET is the clearest example of a runtime version deciding safety. According to the OWASP cheat sheet:

  • XmlReader, XmlNodeReader and XmlDictionaryReader are safe by default before and after .NET Framework 4.5.2.
  • XmlDocument, XmlTextReader and XPathNavigator are unsafe by default before 4.5.2 and safe from 4.5.2 onward, because the internal XmlResolver is null by default from that version.
  • Any of them becomes unsafe again if your code assigns a non-null XmlResolver with default or unsafe settings, or sets DtdProcessing to Parse together with a non-null resolver.
  • XslCompiledTransform is only as safe as the parser you give it.

The trap the cheat sheet calls out is ASP.NET. An ASP.NET application must set httpRuntime targetFramework in Web.config to 4.5.2 or later, or it can be vulnerable regardless of the installed framework version. The effective version is the lower of the build target and the httpRuntime target, and the similar looking compilation targetFramework setting is not sufficient.

<!-- Web.config: the effective framework for XML defaults is the lower of
     the build target and this value -->
<system.web>
  <httpRuntime targetFramework="4.8" />
</system.web>

For enforcement in .NET, the CI rule logic is: flag any assignment of a non-null value to an XmlResolver property, flag DtdProcessing.Parse, and on .NET Framework projects flag a Web.config whose httpRuntime element is missing or targets a version below 4.5.2.

Python: standard library, defusedxml and lxml

The OWASP cheat sheet points Python developers to the language's own XML security guidance. The standard library parsers sit on Expat, which may be bundled with Python or supplied by the operating system. Expat itself does not access files or the network by default, but the cheat sheet warns that higher level APIs and custom handlers still need an external resource policy, and that Expat versions before 2.7.2 may be affected by entity expansion, large token and dynamic memory denial of service issues. It also notes that the standard library xmlrpc module is vulnerable to decompression bombs, so input size limits still matter.

For untrusted XML, the cheat sheet recommends the defusedxml parsing interfaces in place of the matching standard library functions, with entity and external resource rejection left on, and forbid_dtd=True where the API offers it and DTDs are unnecessary.

# Parse untrusted XML through defusedxml instead of xml.etree directly
from defusedxml.ElementTree import fromstring

def parse_upload(data: bytes):
    # forbid_dtd=True rejects any DOCTYPE; entity and external
    # resource rejection stay on by default
    return fromstring(data, forbid_dtd=True)

lxml is a separate library built on libxml2, with its own defaults and its own history. Its maintainers' advisory GHSA-vfmq-68hx-4jfw (CVE-2026-41066, published 21 April 2026) explains the timeline:

  • lxml 5.0 changed the default to resolve_entities='internal' for the normal XML and HTML parsers.
  • The default was not changed at that time for iterparse() and ETCompatXMLParser(), which kept resolve_entities=True and could let untrusted XML read local files.
  • lxml 6.1.0 makes the safe option the default for all parsers. The advisory's workaround for older versions is to set resolve_entities='internal' or resolve_entities=False explicitly.

The lxml FAQ adds a stricter pattern for protocols that forbid entity references entirely: parse with resolve_entities=False, then walk the tree for etree.Entity nodes and reject the whole document if any are present.

from lxml import etree

SAFE_PARSER = etree.XMLParser(
    resolve_entities=False,  # explicit, even on 6.1.0 and later
    no_network=True,
    load_dtd=False,
)

def parse_upload(data: bytes):
    root = etree.fromstring(data, parser=SAFE_PARSER)
    if any(True for _ in root.iter(etree.Entity)):
        raise ValueError("entity references are not allowed")
    return root

A CI rule that requires an explicit resolve_entities=False on every XMLParser, iterparse and ETCompatXMLParser call keeps you safe even if an old lxml slips back into a lock file.

PHP

PHP's XML extensions (DOMDocument, SimpleXML, XMLReader) use libxml2. The OWASP cheat sheet says that with libxml 2.9.0 or later, entity substitution is disabled by default, and warns not to enable LIBXML_NOENT, LIBXML_DTDLOAD or LIBXML_DTDVALID for untrusted XML without explicitly blocking external resources.

To reject external entity resolution, the cheat sheet recommends installing a resolver callback that returns null before processing untrusted XML. The PHP manual describes libxml_set_external_entity_loader() as a way to suppress the expansion of arbitrary external entities "even when LIBXML_NOENT has been set for the respective operation", and notes that if the callback returns null, entity reference resolution fails. The cheat sheet adds an important detail: passing null directly to libxml_set_external_entity_loader() is not the same as passing a callback that returns null, and does not block external entity loading.

<?php
// Install once, before any untrusted XML is parsed in this request
libxml_set_external_entity_loader(static function ($public, $system, $context) {
    return null; // resolution fails for every external entity
});

$doc = new DOMDocument();
// No LIBXML_NOENT, LIBXML_DTDLOAD or LIBXML_DTDVALID here
$doc->loadXML($untrustedXml, LIBXML_NONET);

CI rule logic for PHP: fail on any use of LIBXML_NOENT, LIBXML_DTDLOAD or LIBXML_DTDVALID in code that handles external input, and fail on libxml_set_external_entity_loader(null).

C, C++ and iOS (libxml2)

Native code and many language bindings sit on libxml2. The OWASP cheat sheet notes that XXE has been disabled by default since libxml2 2.9, and that you should search for uses of xmlCtxtReadDoc, xmlCtxtReadFd, xmlCtxtReadFile, xmlCtxtReadIO, xmlCtxtReadMemory, xmlCtxtUseOptions and xmlParseInNodeContext and make sure XML_PARSE_NOENT and XML_PARSE_DTDLOAD are not among the options. On iOS, the cheat sheet says the libxml2 shipped up through iOS 6 predates 2.9, and that NSXMLDocument loads only entities that need no network access from iOS 5 onward; to disable external entities completely, specify NSXMLNodeLoadExternalEntitiesNever when creating the document.

That list of function names is exactly the input a CI rule needs: match calls to those functions and fail when the options argument contains either flag.

Go

Go's standard encoding/xml package takes a different path. Its documentation describes a Decoder with a Strict field that defaults to true and an Entity map that "can be used to map non-standard entity names to string replacements", with the five standard XML entities always present. It exposes a Directive token type for constructs of the form <!text>, which is how a DOCTYPE reaches your code. The package documents no option to fetch external DTDs or external entities.

So in Go, the risk is what you add: flag code that fills Decoder.Entity from input you do not control, and reject documents that produce an unexpected Directive token. If a Go service calls libxml2 through cgo, the libxml2 rules apply instead.

Node.js

Node.js has pure JavaScript XML parsers and bindings to native libxml2. For the bindings, the libxml2 rule applies: never turn on entity substitution or external DTD loading for untrusted input, and check each binding's documentation for its exact option names before writing a CI rule.

The libxml2 bindings also show why version gates are not always enough. GitHub's advisory database lists two type confusion advisories for the libxmljs2 package (GHSA-mjr4-7xg5-pfvh, CVE-2024-34393, and GHSA-78h3-pg4x-j8cv, CVE-2024-34394, both published 2 May 2024) with no first patched version recorded. These are memory safety issues when parsing crafted XML rather than XXE in the strict sense, but they make the same point: when no fixed version exists, an upgrade gate can only tell you that you are exposed. You need a plan to replace the library, restrict what reaches it, or run a patched build.

Ruby: Nokogiri

Nokogiri documents its parse options in Nokogiri::XML::ParseOptions. The default mask for parsing XML documents and fragments is DEFAULT_XML. The documentation marks NOENT and DTDLOAD as unsafe for untrusted documents and warns that the DEFAULT_XSLT mask includes both, so you should not parse untrusted XSLT stylesheets. It recommends configuring options with a block and chained setters.

# Strict parsing with network access off; NOENT and DTDLOAD stay off
doc = Nokogiri::XML(untrusted_xml) do |config|
  config.strict.nonet
end

CI rule logic for Ruby: fail on noent, dtdload, ParseOptions::NOENT, ParseOptions::DTDLOAD or DEFAULT_XSLT in code that touches external input, and fail on any Nokogiri::XSLT call whose stylesheet comes from a request.

Nokogiri's XXE advisories are covered in Layer 3.

Layer 2: enforce the setting in CI

Settings fix the parsers you know about. CI catches the parser someone adds next week. You need two kinds of check: a static rule that reads source code, and a negative test that runs the real parser.

Check 1: a static rule for parser construction

You can express almost every XXE rule as one of two patterns.

Pattern A: a constructor without its hardening in the same scope. Find each call that creates a parser or factory (for example DocumentBuilderFactory.newInstance(), XMLInputFactory.newInstance(), new XmlTextReader(...), etree.XMLParser(...), etree.iterparse(...)). Then check that the required setting appears on the same object before it is used to parse. If it does not, fail.

Pattern B: an unsafe option anywhere. Find options that turn the unsafe behaviour on (resolve_entities=True, LIBXML_NOENT, XML_PARSE_NOENT, XML_PARSE_DTDLOAD, DtdProcessing.Parse, a non-null XmlResolver, Nokogiri noent or dtdload). These should be rare enough that every hit needs a reviewer.

The best implementation is a rule in the static analysis tool your teams already run, because those tools understand syntax trees and data flow and can follow a factory through a helper method. If you do not have one in place, a small script gives you most of the value on day one. The script below is a suggestion, not a product: a starting point to adapt to your own languages and conventions, with a regular expression match that will miss parsers configured in another method or file. It reports Pattern B hits and Pattern A constructors that lack an allow-list comment, and exits non-zero so the pull request check fails.

#!/usr/bin/env python3
"""xxe_guard.py: a suggested starting point, not a complete scanner.
Fails CI when an XML parser is created unsafely."""
import pathlib
import re
import sys

UNSAFE = [
    (r"resolve_entities\s*=\s*True", "lxml entity resolution switched on"),
    (r"\bLIBXML_NOENT\b|\bLIBXML_DTDLOAD\b|\bLIBXML_DTDVALID\b", "PHP libxml unsafe flag"),
    (r"libxml_set_external_entity_loader\s*\(\s*null\s*\)", "PHP loader reset to null does not block"),
    (r"\bXML_PARSE_NOENT\b|\bXML_PARSE_DTDLOAD\b", "libxml2 unsafe flag"),
    (r"DtdProcessing\.Parse", ".NET DTD processing switched on"),
    (r"XmlResolver\s*=\s*new\b", ".NET non-null XmlResolver"),
    (r"\.noent\b|\.dtdload\b|ParseOptions::NOENT|ParseOptions::DTDLOAD", "Nokogiri unsafe option"),
]

CONSTRUCTORS = [
    r"DocumentBuilderFactory\.newInstance\(",
    r"SAXParserFactory\.newInstance\(",
    r"XMLInputFactory\.(newInstance|newFactory)\(",
    r"TransformerFactory\.newInstance\(",
    r"SchemaFactory\.newInstance\(",
    r"new\s+XmlTextReader\(",
    r"etree\.(XMLParser|iterparse|ETCompatXMLParser)\(",
    r"xml\.etree\.ElementTree\.(parse|fromstring)\(",
    r"xml\.dom\.minidom\.parse(String)?\(",
]

EXTS = {".java", ".kt", ".cs", ".py", ".php", ".c", ".cc", ".cpp", ".rb", ".go", ".js", ".ts"}

def scan(root: pathlib.Path) -> int:
    problems = 0
    for path in root.rglob("*"):
        if path.suffix not in EXTS or "test" in path.parts:
            continue
        for n, line in enumerate(path.read_text(errors="ignore").splitlines(), 1):
            for rx, why in UNSAFE:
                if re.search(rx, line):
                    print(f"{path}:{n}: {why}")
                    problems += 1
            if "xxe-reviewed" in line:
                continue
            for rx in CONSTRUCTORS:
                if re.search(rx, line):
                    print(f"{path}:{n}: XML parser created; harden it and mark xxe-reviewed")
                    problems += 1
    return problems

if __name__ == "__main__":
    sys.exit(1 if scan(pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".")) else 0)

Three notes on running a rule like this:

  • Start on the diff, not the repository. On the first day, a rule over the whole code base will report every historical parser. Run it against changed files in the pull request so new code is held to the standard while the backlog is worked down separately.
  • Make the marker mean something. An allow-list marker is only useful if a reviewer adds it after checking the hardening. Require a code owner review on any line that adds one.
  • Graduate to syntax aware rules. Line matching cannot follow a factory configured in another method; once the simple rule has found the main patterns, port it to your static analysis tool.

In a pull request job, copy the changed files into a scratch directory with git diff --name-only against the base branch and run the script over that directory. It needs nothing beyond a checkout and Python.

Check 2: a negative test that runs the real parser

A static rule tells you the setting is written down. It does not tell you the setting works. The OWASP cheat sheet's warning that a factory may load a third party provider with different behaviour is the reason this second check exists.

The pattern is the same in every language:

  1. Keep a small fixture document in your test resources. It contains a DOCTYPE that declares an external entity pointing at another harmless file inside the same test resources directory, plus a reference to that entity in the body. Do not point it at a real system file or a network address; the test only needs to prove that resolution is refused.
  2. For every parser entry point your application uses (not just the one you fixed), write a test that parses the fixture through the production code path.
  3. Assert one of two outcomes: parsing raises an error because DTDs are prohibited, or parsing succeeds and the harmless file's contents do not appear anywhere in the result.
  4. Run the tests in CI on the image you ship, so the Expat, libxml2, classpath or Web.config under test are the ones in production.

Because the fixture only references a file inside your own test resources, the test is safe to run anywhere and needs no network access. It turns "we configured the parser" into "we proved the parser refuses entities", and it fails the build the moment a dependency change swaps the implementation.

Check 3: runtime version assertions

Several safe defaults depend on the version of a native library, not on your code. Add a short CI step that prints those versions and fails when one is older than your floor. Each of these reads a version through the library's own API:

# Python: Expat version used by the interpreter (OWASP: keep at 2.7.2 or later)
python3 -c "import pyexpat; print(pyexpat.EXPAT_VERSION)"

# Python lxml: lxml and the libxml2 it was built with
python3 -c "from lxml import etree; print(etree.LXML_VERSION, etree.LIBXML_VERSION)"

# PHP: the libxml2 version PHP was built with (2.9.0 or later for safe defaults)
php -r 'echo LIBXML_DOTTED_VERSION, PHP_EOL;'

# Ruby: Nokogiri and its libxml2
ruby -e 'require "nokogiri"; p Nokogiri::VERSION_INFO'

For .NET Framework applications, the equivalent check is a test that parses Web.config and asserts that httpRuntime targetFramework is present and at least 4.5.2.

Run these against the built container image, not the build agent. Base image updates are a common way for an old native library to come back.

The checks above cover your code and your runtime. Bugs inside libraries have CVE and GHSA identifiers, so a dependency gate catches them. That is Layer 3.

Layer 3: library versions that fix XXE

Some XXE bugs are not your configuration at all. The library shipped an unsafe default, or a protection did not work on one platform. The table below lists XXE and closely related advisories from the GitHub Advisory Database and from the library maintainers' own advisories, with the first patched versions they record.

LibraryAdvisoryProblemFixed in
lxml (Python)GHSA-vfmq-68hx-4jfw, CVE-2026-41066 (21 Apr 2026)Default configuration of iterparse() and ETCompatXMLParser() allows XXE to local files6.1.0
Nokogiri (Ruby)GHSA-vr8q-g5c7-m54m, CVE-2020-26247 (30 Dec 2020)Nokogiri::XML::Schema trusts input by default, exposing risk of XXE1.11.0
Nokogiri on JRubyGHSA-2rr5-8q37-2w7h, CVE-2021-41098 (27 Sep 2021)XXE on the JRuby implementation1.12.5
Nokogiri on JRubyGHSA-8678-w3jw-xfc2 (19 Jun 2026)XML::Schema allows network requests when NONET is set, bypassing the CVE-2020-26247 fix1.19.4
Nokogiri (Ruby)GHSA-6wj9-77wq-jq7p, CVE-2012-6685XXE in old versions1.5.4
dom4j (Java, org.dom4j:dom4j)GHSA-hwj3-m3p6-hj38, CVE-2020-10683 (5 Jun 2020)External entities allowed by default2.0.3, or 2.1.3 on the 2.1 line
dom4j (Java, old dom4j:dom4j coordinates)Same advisorySame problemNo patched version under the old coordinates; move to org.dom4j:dom4j
libxmljs2 (Node.js)GHSA-mjr4-7xg5-pfvh, CVE-2024-34393; GHSA-78h3-pg4x-j8cv, CVE-2024-34394Type confusion when parsing crafted XMLNo patched version recorded

And the platform level thresholds from the OWASP cheat sheet, which are not CVEs but behave like version floors:

PlatformSafe default fromNotes
libxml22.9XXE disabled by default; unsafe again if NOENT or DTDLOAD is passed
PHP with libxmllibxml 2.9.0Entity substitution off by default
.NET Framework XmlDocument, XmlTextReader, XPathNavigator4.5.2ASP.NET also needs httpRuntime targetFramework 4.5.2 or later
Expat (Python standard library)2.7.2Earlier versions may have entity expansion and related denial of service issues

Three things stand out when you read these advisories closely.

Fixes can be incomplete. lxml changed its default in 5.0 but left two entry points on the old behaviour until 6.1.0. Nokogiri fixed CVE-2020-26247 in 1.11.0, and the June 2026 advisory shows the protection was not correctly enforced on JRuby until 1.19.4. If your CI rule requires an explicit safe setting, you are protected even during the gap between a partial fix and a complete one.

Fixes can be breaking changes. The Nokogiri 1.19.4 advisory calls properly enforcing NONET on JRuby a breaking change for code that relied on loading network resources by default. Treat a failing test after the upgrade as news about a code path you did not know needed the network.

Coordinates can move. dom4j's fix shipped under org.dom4j:dom4j. A project still on the old dom4j:dom4j group never sees a patched version, so a version bump within the old coordinates cannot fix it. Your dependency gate has to flag the old coordinates themselves.

Bundled native libraries count too

Several Nokogiri advisories record updates to the libxml2 it packages, for example to 2.13.6 for CVE-2025-24928 and CVE-2024-56171 (Nokogiri 1.18.3) and to 2.13.8 for CVE-2025-32414 and CVE-2025-32415 (Nokogiri 1.18.8). A manifest scan sees the gem or wheel version, which stands in for the native library inside it. The runtime version check in Check 3 confirms what actually shipped.

Where Safeguard fits: policy gates and AI Remediate for the version layer

Safeguard's documented features map to Layer 3, the version layer. The parser setting rule and the negative test in Layer 2 stay in your pipeline as described above; they check your own code, which has no advisory identifier to match.

Software composition analysis. Safeguard's SCA documentation describes resolving the complete dependency graph from manifests and lockfiles, including deeply nested transitive dependencies, and matching every resolved package and version against CVE and GHSA advisories enriched with EPSS and CISA KEV. Each finding carries the vulnerable component and version, the advisory identifiers, the full transitive path from your project root and the safe upgrade target. Supported ecosystems in the docs include npm, pnpm and Yarn, PyPI, Maven and Gradle, Go modules, Cargo, NuGet, Composer and Ruby Bundler, plus components found inside container image layers. For XXE that means an old lxml pulled in by a reporting library, or an old Nokogiri pinned by a gem three levels down, shows up with the path that explains why it is there.

Policies and gates. The policies and gates documentation separates the two ideas. Policies are rules that evaluate your SBOMs and generate findings; gates are checkpoints that enforce policies, block deployments when policies fail, integrate with CI/CD pipelines and require approvals for exceptions. Policy conditions in the docs include vulnerability severity, CVSS score, EPSS score, license, component age, maintenance status and SLSA level, and each policy's action decides what a failure does to the gate:

ActionWhat the gate does
BlockThe gate fails
WarnThe gate passes, with a warning naming the policy
NotifyThe gate passes, and a notification is sent

Gates are evaluated by the platform on the scans it runs for your connected repositories. With policy outcomes switched on for your workspace, the documented CI command fails the job when the gate fails:

safeguard ci scan --wait --fail-on-gate <gate>

A practical XXE setup is a policy that blocks high and critical findings, linked to the gate your pull request jobs call. A new lockfile entry for a vulnerable lxml or dom4j version then fails the pull request, alongside your xxe_guard rule. When a team has a legitimate reason to keep a version for a short time, the documented exception flow asks for the policy and finding, a business justification and an expiry date, routes the request to approvers and records an audit trail. That is a better outcome than a silently disabled check.

AI Remediate. Safeguard's AI Remediate documentation describes Griffin, Safeguard's security model, analysing the vulnerability and your code base, choosing the safest upgrade path, checking breaking changes and compatibility, opening a pull request with the changes and running automated tests to verify the fix. For dependency upgrades the docs list moving vulnerable packages to fixed versions, handling transitive dependency conflicts and updating lock files. You can run it on a single vulnerability, in bulk across selected vulnerabilities (one pull request per repository) or from the Vulnerabilities page for every fixable finding. Settings include auto-creating pull requests for safe fixes, requiring approval before pull requests, running tests before creating them, a breaking change policy and branch naming. The docs grade breaking change risk as Safe, Minor, Major or Critical, and recommend not auto merging breaking changes.

For the advisories above, the Nokogiri 1.19.4 change on JRuby is the kind its own maintainers call breaking, which is exactly where a reviewer should read the pull request and its test results before merging.

What stays with you. When an advisory records no patched version, as with libxmljs2, there is no upgrade target. Replace the library, limit what reaches it, or use the fork and patch route below.

Putting it together: a four week rollout

WeekWorkExit criterion
1Inventory every XML parser entry point per service; run xxe_guard over the whole repository once and file the results; add the runtime version printout to CIA list of parser call sites and native library versions per service
2Turn on xxe_guard for changed files in pull requests; add the negative fixture and one test per parser entry point in the highest risk services (anything parsing uploads, SAML, SOAP or partner feeds)New unsafe parsers fail pull requests; high risk services have passing negative tests
3Connect repositories to SCA; create a policy that blocks high and critical findings and link it to the pull request gate; run safeguard ci scan --wait --fail-on-gate in CI (with policy outcomes switched on for the workspace)Vulnerable XML library versions fail pull requests
4Work the historical backlog: harden old parser call sites and add markers after review; use AI Remediate for library upgrades with approval required; move any dom4j:dom4j usage to org.dom4j:dom4jBacklog closed or covered by time limited exceptions

Which control catches which failure

FailureParser settingStatic ruleNegative testRuntime version checkDependency gate
Developer adds a new unhardened parserNoYesYes, if a test covers the entry pointNoNo
Hardening exists but a different implementation loadsNoNoYesPartlyNo
Base image brings an old libxml2 or ExpatNoNoSometimesYesPartly, for packaged native libraries
Library has an XXE default (old dom4j, old lxml iterparse)Yes, if set explicitlyYes, if the rule requires the explicit settingYesNoYes
Library has a bug in its protection (Nokogiri on JRuby)NoNoYes, if the test exercises that APINoYes
Advisory with no patched versionNoNoNoNoFlags it; you must replace, isolate or patch

No single column is all yes. That is the argument for running every check.

Common mistakes

  • Fixing the one parser in the finding. The finding names one call site. The same service often creates three other parsers, and a TransformerFactory or SchemaFactory is easy to miss. Search for every constructor before you close the ticket.
  • Relying on a default you have not pinned. lxml's default is safe for all parsers from 6.1.0, but a lock file can roll back. Set resolve_entities explicitly so safety does not depend on the version that resolves.
  • Passing null instead of a callback in PHP. libxml_set_external_entity_loader(null) does not block external entity loading. The callback must return null.
  • Trusting compilation targetFramework in ASP.NET. Only httpRuntime targetFramework changes the XML defaults, and the effective version is the lower of the two targets.
  • Testing on the build agent instead of the image. The Expat and libxml2 versions that matter are the ones in the image you deploy.
  • Bumping a version inside dead coordinates. dom4j:dom4j has no patched version. Move to org.dom4j:dom4j.
  • Auto merging a breaking fix. Some XXE fixes, such as stricter NONET handling, change behaviour on purpose. Read the test results before merging.

FAQ

What is the best way to prevent XXE?

Disable DTD processing for any XML that comes from outside your trust boundary, which is the OWASP cheat sheet's primary recommendation. Where you cannot disable DTDs entirely, disable external entities and external DTD loading. Then enforce that in CI with a static rule and a negative test, and keep XML libraries above the versions that fix known XXE advisories.

How do I fix XXE in Java?

Configure every JAXP factory your code creates (DocumentBuilderFactory, SAXParserFactory, XMLInputFactory, TransformerFactory, SchemaFactory, Validator) to disallow DOCTYPE declarations or, failing that, to disable external entities and external DTD access, as the OWASP cheat sheet lists per factory. Our Java XXE fix guide has the code. Then upgrade XML libraries with unsafe defaults, such as dom4j to 2.0.3 or 2.1.3.

What is an XXE attack?

An XXE attack sends an XML document whose DTD declares an external entity. If the parser resolves it, the server may read a local file or make a network request on the attacker's behalf and include the result in the parsed data. Recursive entity definitions can also exhaust memory. Our XXE prevention guide explains the mechanics in more depth.

Does upgrading my XML library fix XXE?

Only when the vulnerability is in the library. Most XXE findings are parser configuration in your own code, which no upgrade changes. Some are genuine library bugs, such as CVE-2026-41066 in lxml before 6.1.0 or CVE-2020-26247 in Nokogiri before 1.11.0, and those do need the upgrade.

How do I disable external entities in PHP?

Use libxml 2.9.0 or later, do not pass LIBXML_NOENT, LIBXML_DTDLOAD or LIBXML_DTDVALID for untrusted XML, and install libxml_set_external_entity_loader() with a callback that returns null. Passing null directly does not block loading.

Is Go's encoding/xml vulnerable to XXE?

Go's encoding/xml documents no option to load external DTDs or entities; it resolves the five standard entities plus any you put in Decoder.Entity, and returns a DOCTYPE as a Directive token. Keep the entity map under your control and reject unexpected directives. If you call libxml2 through cgo, the libxml2 rules apply.

Can a dependency scanner find XXE in my own code?

Not by itself. A composition scanner matches package versions against advisories, so it finds library bugs like the lxml and Nokogiri advisories above. Misconfigured parsers in your own code have no advisory identifier, which is why the static rule and the negative test are separate checks.

Most XXE prevention work happens in your own parser code, but the library layer is where teams lose time waiting: an advisory lands for lxml, Nokogiri or dom4j, and the upgrade sits in a backlog. And when an advisory records no patched version at all, as with the libxmljs2 entries above, there is nothing to upgrade to, so the supply chain options below matter as much as the parser settings.

Fix it without waiting for upstream

Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Griffin, Safeguard's security model, opens a policy gated pull request that moves the affected dependency to the safe version, and your own CI runs your tests on it before anyone merges. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation categories include dependencies, licenses, security posture and findings, and each one is set separately. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Auto-merge is a separate switch and stays off by default, so your team decides what merges on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds.

3. Gold Registry: start from components that are already fixed

Safeguard Gold rebuilds patched versions of open-source packages and images from source, signs each build, attaches an SBOM, SLSA provenance and VEX, and serves them from registry.safeguard.sh to your organization. Every Gold artifact is held continuously to zero exploitable critical or high CVEs and a completed license check, with signed build provenance (signed with Safeguard's key and verifiable with cosign, SLSA Build Level 3 as the design target) and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened build of a specific package. Ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingSafeguard policies and gates in CI
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

See how Safeguard would help

The demo below walks through a Safeguard CLI session: installing the CLI, asking the assistant for a fix, approving each step and scanning. For an XXE advisory you could ask it for the upgrade, such as lxml to 6.1.0 or dom4j to 2.1.3, then review the change against the parser settings in this post.

Click through it at your own pace, or open it full screen.

This demo sets up the Safeguard local runner on your own machine, approves it with a device code and then scans from the terminal. For XXE work it is a way to scan a service and see which XML libraries it actually resolves, before you push a branch.

Click through it at your own pace, or open it full screen.

The last demo connects Claude to the Safeguard MCP server with OAuth and chooses which tools Claude can use. Once connected, you can ask which projects carry a vulnerable XML library version and through which dependency path, from the assistant you already use.

Click through it at your own pace, or open it full screen.

To try it on your own code, download Safeguard and follow the CLI guide.

Quick check

You upgraded lxml to 6.1.0. Do you still need resolve_entities=False in your code?

Yes, as a safeguard. 6.1.0 makes the safe default apply to every parser, but a lock file can roll back to an older version where iterparse() and ETCompatXMLParser() still resolve entities. An explicit setting keeps you safe regardless of the version that resolves, and lets a CI rule check for it.

Your PHP code calls libxml_set_external_entity_loader(null). Are external entities blocked?

No. The OWASP cheat sheet notes that passing null directly is not equivalent to a callback that returns null, and does not block external entity loading. Install a callback that returns null.

An ASP.NET app runs on .NET Framework 4.8 but has no httpRuntime targetFramework in Web.config. Are XmlDocument defaults safe?

Not necessarily. The OWASP cheat sheet says ASP.NET applications must set httpRuntime targetFramework to 4.5.2 or later, or risk unsafe defaults regardless of the installed version, and that omitting the tag results in unsafe by default behaviour.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.