Safeguard
Industry Analysis

XXE Prevention in Python with resolve_entities=False

Why lxml's XMLParser resolves external entities by default, how resolve_entities=False actually stops XXE, and where Python teams still leave file-read and SSRF paths open.

Aman Khan
AppSec Engineer
7 min read

In March 2013, security researcher Christian Heimes published a report cataloging five distinct XML denial-of-service and injection weaknesses baked into Python's standard library — the finding that led him to build defusedxml, a package now pulled in as a dependency by thousands of projects that parse untrusted XML. More than a decade later, the underlying problem hasn't gone away; it has just moved. Python's xml.etree.ElementTree was hardened against external entity resolution by design, but lxml — the parser most production teams actually reach for because it's faster and supports XPath — ships with resolve_entities=True as its default. That single flag means a <!ENTITY xxe SYSTEM "file:///etc/passwd"> payload dropped into an uploaded SOAP request, RSS feed, or DOCX file can read local files, hit internal metadata endpoints like 169.254.169.254, or trigger a denial-of-service, and the parser will happily comply. Here's what XXE prevention with resolve_entities=False actually does, and where it still falls short.

What Is XXE and Why Does It Still Show Up in 2026 Python Codebases?

XXE (XML External Entity) injection happens when an XML parser resolves entity declarations that point outside the document itself — a local file path, a http:// URL, or another entity — and the answer to why it persists is simple: XML is still everywhere, and most developers never touch parser configuration. SAML single sign-on, SOAP APIs, DOCX/XLSX/PPTX uploads (all are ZIP archives full of XML), SVG image uploads, RSS/Atom ingestion, and legacy B2B EDI pipelines all pass attacker-controlled XML into a parser. OWASP added XML External Entities as its own category (A4) in the 2017 Top 10 specifically because it kept surfacing in enterprise Java and Python stacks years after the attack class was first documented in the early 2000s. A typical malicious payload looks like this:

<?xml version="1.0"?>
<!DOCTYPE data [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<data>&xxe;</data>

If the parser resolves &xxe;, the contents of /etc/passwd get echoed back into whatever field the application renders — a search result, an error message, a generated PDF. Swap the SYSTEM identifier for an internal URL and the same bug becomes a server-side request forgery (SSRF) that reaches cloud metadata services or internal admin panels.

Why Does lxml's Default Configuration Put Applications at Risk?

Because lxml.etree.XMLParser() sets resolve_entities=True out of the box, and most developers never override it — the parameter exists precisely because lxml wraps libxml2, a C library built for full-fidelity XML processing, not secure-by-default web parsing. Compare the two standard library options:

  • xml.etree.ElementTree (via expat): does not resolve external entities by default, so classic file-disclosure XXE doesn't work out of the box. It's still vulnerable to entity-expansion denial-of-service (the "billion laughs" attack) unless defusedxml is used.
  • lxml.etree: resolves external entities and expands internal entity bombs by default unless you explicitly configure the parser.

This asymmetry is exactly why the keyword "xxe prevention python xmlparser resolve_entities" shows up in security audits so often — teams migrate from ElementTree to lxml for XPath support or speed, inherit the default XMLParser(), and unknowingly reintroduce a vulnerability class their previous stack didn't have. A 2016 public bug bounty disclosure against Uber's file-upload pipeline is a commonly cited example: a Word document containing a crafted external entity, uploaded through a resume-parsing feature, was enough to pull local files off the application server.

How Does Setting resolve_entities=False Actually Stop the Attack?

Setting resolve_entities=False on the XMLParser instance tells libxml2 to leave external entity references unexpanded, so &xxe; is treated as an inert string rather than a command to fetch a file or URL. The fix is a one-line change at parser construction time:

from lxml import etree

# Vulnerable: resolve_entities defaults to True
parser = etree.XMLParser()
tree = etree.parse(untrusted_file, parser)

# Hardened
parser = etree.XMLParser(
    resolve_entities=False,
    no_network=True,
    huge_tree=False,
    dtd_validation=False,
    load_dtd=False,
)
tree = etree.parse(untrusted_file, parser)

resolve_entities=False closes the file-read and SSRF vector. no_network=True blocks the parser from fetching remote DTDs even if entity resolution somehow occurs. huge_tree=False (the default) caps recursive entity expansion, mitigating billion-laughs-style memory exhaustion. All three matter — disabling only resolve_entities while leaving load_dtd=True still lets the parser fetch an external DTD, which under some libxml2 versions and misconfigurations has been enough to leak data through out-of-band techniques (parameter entities exfiltrating data via crafted DNS or HTTP requests to an attacker-controlled server).

What Other Python XML Libraries and Settings Need Hardening Besides lxml?

lxml gets the attention, but xml.sax, xml.dom.minidom, xml.dom.pulldom, and xmlrpc all route through the same expat-based or libxml2-based machinery and need their own checks — and the simplest fix across all of them is defusedxml, first released in September 2013 and still maintained as the de facto standard hardening layer. Instead of patching every parser call site individually, defusedxml provides drop-in replacements:

# Instead of: from lxml import etree
from defusedxml.lxml import parse

tree = parse(untrusted_file)  # entity resolution, DTDs, and network access disabled

defusedxml also patches xml.sax.make_parser() to disable the feature_external_ges (general entities) and feature_external_pes (parameter entities) features, and wraps minidom.parse() and xmlrpc.client similarly. For teams that can't take on a new dependency, the manual equivalent for SAX is:

import xml.sax

parser = xml.sax.make_parser()
parser.setFeature(xml.sax.handler.feature_external_ges, False)
parser.setFeature(xml.sax.handler.feature_external_pes, False)

The pattern to audit for across a codebase is any of: etree.parse, etree.fromstring, etree.XMLParser(, minidom.parse, minidom.parseString, xml.sax.parse, and xmlrpc.client usage — each one is a potential XXE entry point if it ever touches data from an HTTP request body, file upload, or third-party API response.

What Does an Unpatched XXE Actually Cost in Production?

The realistic cost ranges from a single leaked config file to full internal network pivoting, and the reason severity varies so widely is that XXE impact depends entirely on what the parser can reach from inside the application's network. In the SSRF variant, a payload targeting http://169.254.169.254/latest/meta-data/iam/security-credentials/ on an AWS-hosted service can return temporary IAM credentials directly in the parsed-XML response — turning a document-upload feature into a cloud account compromise path, a technique documented repeatedly in cloud incident post-mortems since the well-known 2019 Capital One breach popularized metadata-endpoint SSRF as an attack chain (that breach used a different vector, but the same metadata-service exposure that XXE-driven SSRF can reach). In the file-disclosure variant, even read access limited to world-readable files can expose .env files, SSH known_hosts, or application source containing hardcoded secrets. Because XXE payloads travel inside data formats developers don't manually inspect — DOCX, SVG, SAML assertions — they routinely bypass both code review and generic WAF signatures tuned for URL-based injection, which is why static analysis at the parser-configuration level catches what request-layer filtering misses.

How Safeguard Helps

Safeguard's software supply chain platform is built to catch exactly this class of issue before it ships, not after a pentest finds it. Our static analysis engine flags every lxml.etree.XMLParser(), etree.fromstring(), etree.parse(), and xml.sax.make_parser() call site that lacks explicit resolve_entities=False, no_network=True, or the equivalent SAX feature flags — surfacing the exact file and line number in your CI pipeline rather than leaving it to manual code review. Because dependency-introduced risk is part of the same attack surface, Safeguard also tracks whether defusedxml (or an equivalently hardened parsing wrapper) is present and actually used at the call sites that handle untrusted input, not just listed in requirements.txt and ignored. For teams managing SBOMs and provenance attestations, Safeguard correlates known XML-parsing CVEs against your dependency graph so a libxml2 or lxml version bump that reintroduces a fixed default doesn't slip through unnoticed between releases. The result is that XXE prevention stops depending on every engineer remembering one non-default constructor argument, and instead becomes a policy your pipeline enforces automatically — turning resolve_entities=False from a tribal-knowledge tip into a gate that blocks the merge if it's missing.

Never miss an update

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