Parse untrusted XML with defusedxml, not lxml or the stdlib. Pass forbid_dtd=True so any DOCTYPE is an error before an external entity is dereferenced (CWE-611) or entities expand (CWE-776); forbid_entities and forbid_external stay on. Do not try to strip '<!DOCTYPE' from the bytes: a UTF-16 copy of the same document has no such bytes and is parsed and resolved anyway.
- Find every place a request body or other untrusted bytes reach lxml.etree (parse/fromstring/XMLParser), the stdlib xml.* modules, or xmlrpc.
- Swap the parser for defusedxml.ElementTree and call fromstring/parse with forbid_dtd=True; keep its forbid_entities and forbid_external defaults so declarations and external references raise.
- If you must keep lxml, build the parser with resolve_entities=False, no_network=True and load_dtd=False, and still reject a DOCTYPE; lxml resolves inline SYSTEM file entities by default, so defaults are not safe.
- Catch defusedxml's DTDForbidden/EntitiesForbidden/ExternalReferenceForbidden (and ParseError for malformed input) and return a short 400; never echo the parser's message.
- Add regression tests with this card's payloads (an external file entity and a small bounded expansion, each as UTF-8 and UTF-16) and assert the marker never appears and the DTD is refused.
- Cap the request body size and keep Flask debug off so parser errors do not leak to the caller.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def _parser() -> "etree.XMLParser":
# resolve_entities=True substitutes entity values (including file:// reads)
# into the tree; load_dtd=True reads the internal subset; no_network=False
# lets the parser reach out. Nothing here refuses a DOCTYPE.
return etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=False)With IMPL=vulnerable, 4 of 9 tests fail (4 exploit tests).
The fix: refuse the DTD
# defusedxml's safety switches. forbid_dtd defaults to False, so it must be set
# explicitly; forbid_entities and forbid_external default to True and are kept.
SAFE = {"forbid_dtd": True, "forbid_entities": True, "forbid_external": True}With IMPL=fixed, 9 of 9 tests pass.
The fix: parse untrusted bytes
try:
root = DET.fromstring(raw, **SAFE)
except (DTDForbidden, EntitiesForbidden, ExternalReferenceForbidden) as exc:
raise ValueError("XML document type definitions are not allowed") from exc
except ParseError as exc:
raise ValueError(f"invalid XML: {exc}") from excWith IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
# The mistaken control: a case-insensitive byte blocklist. XML keywords are
# case-sensitive and ASCII, so .lower() adds nothing a UTF-16 document cannot
# evade -- its bytes never contain the ASCII sequence below.
BLOCKED = (b"<!doctype", b"<!entity")
def _looks_like_a_dtd(raw: bytes) -> bool:
lowered = raw.lower()
return any(token in lowered for token in BLOCKED)
def _parser() -> "etree.XMLParser":
# Unchanged from the vulnerable version: still resolves and expands.
return etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=False)With IMPL=wrong, 2 of 9 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
def assert_rejected(resp, needle: str):
body = resp.get_data(as_text=True)
assert needle not in body, f"XXE leak: {needle!r} appeared in the response"
assert resp.status_code == 400, (
f"expected the DTD to be refused (400), got {resp.status_code}: {body}"
)
def test_exploit_external_entity_file_utf8(client, tmp_path):
secret, marker = make_marker(tmp_path)
assert_rejected(post(client, utf8(ext_file(secret.as_uri()))), marker)
def test_exploit_external_entity_file_utf16(client, tmp_path):
secret, marker = make_marker(tmp_path)
assert_rejected(post(client, utf16(ext_file(secret.as_uri()))), marker)
def test_exploit_entity_expansion_utf8(client):
assert_rejected(post(client, utf8(EXPANSION)), EXPANSION_MARKER)
def test_exploit_entity_expansion_utf16(client):
assert_rejected(post(client, utf16(EXPANSION)), EXPANSION_MARKER)- lxml is not safe by default: a plain etree.XMLParser() resolves an inline <!ENTITY x SYSTEM "file://..."> and expands internal entities. You must set resolve_entities=False (and no_network=True, load_dtd=False) or, better, use defusedxml.
- defusedxml's forbid_dtd defaults to False. forbid_entities/forbid_external (both default True) stop entity declarations and external references, but to refuse the DOCTYPE itself you must pass forbid_dtd=True.
- A byte or string blocklist for '<!DOCTYPE'/'<!ENTITY' is bypassed by encoding the document as UTF-16 or UTF-7; the ASCII bytes are not present, but the parser honours the encoding declaration and reads the DTD. Reject at the parser, not on the input string.
- Predefined entities (& < > " ') and numeric character references (©) need no DTD and are always allowed; refusing the DOCTYPE does not break them, so legitimate documents keep working.
- Bandit B405 and Semgrep's use-defused-xml rule key on the stdlib xml package and do not see lxml, so an lxml-based hole looks clean to both. Do not take a clean scan as proof.
- Entity expansion (CWE-776) needs no external access at all; disabling only external entities still leaves a billion-laughs DoS unless the DTD is refused or expansion is bounded.
- defusedxml does not cover lxml; its lxml shim was dropped years ago. For lxml, harden the parser directly or move the untrusted parse to defusedxml/stdlib.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs python-flask does this for you).
- Each exploit posts a DOCTYPE document (as UTF-8 and UTF-16) whose external entity reads a marker file the test wrote in its own tmp dir, or expands a small bounded entity; it asserts the marker/expansion never appears and the request is refused with 400.
- Run bandit -r impl and semgrep --config r/python.lang.security.use-defused-xml.use-defused-xml impl; note both flag only fixed.py's stdlib import and miss the lxml parsers, then confirm the fix by reading the parser configuration and running the tests.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.lang.security.use-defused-xml.use-defused-xml | Flagged ONLY impl/fixed.py:9, the line from xml.etree.ElementTree import ParseError. It reported nothing on impl/vulnerable.py or impl/wrong.py, which do the actual damage through lxml. The rule matches import xml/from xml ... and has no model for lxml, so it fires on the hardened file (which imports a stdlib exception name) and misses the two resolving parsers. Treat it as 'you touched the xml package', then read the configuration by hand.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/python.lang.security.use-defused-xml.use-defused-xml impl |
| CodeQL | py/xxe | Path-problem query (@id py/xxe, CWE-611/827). Its model marks lxml.etree.fromstring/parse and stdlib sax/pulldom as XML-parse sinks and tracks request data as a remote source, so it is expected to flag vulnerable.py and wrong.py (lxml with resolve_entities) where Semgrep's stdlib rule does not. Whether it clears fixed.py depends on its defusedxml sanitiser model; the regression tests are the control here.Checked: github/codeql main; not executed |
| Bandit | B405import/use of xml.etree (blacklist) | One finding: impl/fixed.py:9 B405 (LOW/HIGH), the from xml.etree.ElementTree import ParseError import, suggesting defusedxml. Nothing on vulnerable.py/wrong.py: Bandit 1.9.4 ships no lxml XXE check (the old B320/B410 lxml rules are gone), so like the Semgrep rule it flags the safe stdlib import and misses the resolving lxml parser. The regression suite is the real control.Checked: Bandit 1.9.4; bandit -r impl |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 (libexpat 2.8.5) |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| werkzeug | 3.1.9 |
| lxml (vulnerable/wrong parser) | 6.1.3 |
| defusedxml (fixed parser) | 0.7.1 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xxe/python-flaskfolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs xxe/python-flask runs all three modes and checks the expected results.
Verified
- Toolchain
- python 3.14.8
- libexpat 2.8.5
- flask 3.1.3
- lxml 6.1.3
- defusedxml 0.7.1
- pytest 9.1.1
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 4 of 9 tests fail (4 exploit tests); wrong: 2 of 9 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 9/9 pass; IMPL=vulnerable 4 exploit tests fail (both file-read markers leak, both expansions return 128 bytes); IMPL=wrong 2 exploit tests fail (the UTF-16 file-read and UTF-16 expansion slip past the ASCII '<!DOCTYPE' blocklist; the UTF-8 pair is blocked).
- Scanners
- Semgrep 1.180.0 and Bandit 1.9.4 from a throwaway venv (../.tools/venv), not card dependencies.
- Notes
- lxml 6.1.3 was released 2026-09-02 (>14 days). defusedxml 0.7.1 (2021-03-08) is the current stable release. CPython 3.14.8 bundles libexpat 2.8.5 (the 3.14.7 runs linked 2.7.4); both meet the OWASP cheat sheet's >=2.7.2 note. libexpat 2.9.0 (5 October 2026) has since fixed CVE-2026-77214 and CVE-2026-102633. Versions and results are true for this date and machine.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP XML External Entity Prevention Cheat Sheet (Python section)
- defusedxml: README and API (forbid_dtd, forbid_entities, forbid_external)
- Python docs: XML vulnerabilities and the defusedxml recommendation
- lxml FAQ: parser security and resolve_entities
- CWE-611: Improper Restriction of XML External Entity Reference
- CWE-776: Improper Restriction of Recursive Entity References in DTDs ('XML Entity Expansion')
- CAPEC-221: Data Serialization External Entities Blowup
Attack patterns: