Back to Blog
medium SEVERITY6 min read

How XML External Entity (XXE) Injection happens in Python and how to fix it

A script that unpacks and parses XML from `.pptx`/`.docx`-style zip archives was importing Python's native `xml.dom.minidom`, a parser known to be vulnerable to XML External Entity (XXE) attacks. The fix swaps it for the drop-in `defusedxml.minidom` module, neutralizing the risk with a two-line import change and zero behavior changes for legitimate input.

O
By Orbis AppSec
Published September 9, 2026Reviewed September 9, 2026

Answer Summary

This is an XML External Entity (XXE) injection vulnerability (CWE-611) in a Python script that used `xml.dom.minidom` to parse XML pulled from a zip archive. Because the native `minidom` parser resolves external entities and DTDs by default, a malicious XML file could trigger file disclosure, SSRF, or denial-of-service. The fix replaces `xml.dom.minidom` with `defusedxml.minidom`, a hardened drop-in replacement that disables dangerous XML features by default.

Vulnerability at a Glance

cweCWE-611
fixReplaced `xml.dom.minidom` and `xml.parsers.expat.ExpatError` with `defusedxml.minidom` and `pyexpat.ExpatError`
riskMalicious XML in a parsed zip archive could read local files, trigger SSRF, or cause DoS via entity expansion
languagePython
root causeUse of native `xml.dom.minidom`, which resolves external entities/DTDs by default
vulnerabilityXML External Entity (XXE) Injection

Introduction

The scripts/prepare-overview-review.py file handles unpacking and inspecting document archives — likely .pptx, .docx, or similar OOXML-based zip files — using ZipFile and BadZipFile from Python's zipfile module. To parse the XML content extracted from those archives, the script imported xml.dom.minidom and xml.parsers.expat.ExpatError at line 26-27. That single import is the root of the issue: Python's standard library XML parsers, including minidom, are not safe by default when handling untrusted or externally-sourced XML.

This matters because any script that unzips a file and parses its internal XML — a very common pattern for handling Office documents, SVGs, RSS feeds, or SOAP payloads — inherits this risk automatically, often without the developer realizing it. If prepare-overview-review.py is ever run against a file that wasn't fully trusted (e.g., an uploaded document, a file fetched from a third party, or a report generated by another tool in the pipeline), the underlying XML parser will happily resolve external entities embedded in that file.

The Vulnerability Explained

Before the fix, the relevant imports looked like this:

import math
from pathlib import Path
import posixpath
from xml.dom import minidom
from xml.parsers.expat import ExpatError
from zipfile import BadZipFile, ZipFile

xml.dom.minidom is built on top of xml.parsers.expat, and by default it does not disable DTD processing or external entity resolution. This means that if the script calls something like minidom.parseString(xml_data) or minidom.parse(file_handle) on XML content extracted from a zip archive, an attacker who controls that archive can embed a malicious DOCTYPE declaration such as:

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

When minidom parses this, it will attempt to resolve the xxe entity by reading /etc/passwd (or any other file the process can access) and inline its contents into the parsed document. Depending on how the script surfaces or logs parsed XML data — for instance, if it prints element text or writes it to a review report — the contents of that file could leak into output that a reviewer or downstream system sees.

Beyond local file disclosure, the same primitive can be used for:
- SSRF: pointing the SYSTEM identifier at an internal URL (http://169.254.169.254/latest/meta-data/) to probe internal network services.
- Denial of Service: the classic "billion laughs" entity-expansion attack, where nested entity definitions cause exponential memory consumption when expanded.

In the context of prepare-overview-review.py, the attack scenario is straightforward: since the script's job is to open a zip archive and inspect XML parts inside it, an attacker only needs to craft one malicious .zip/.pptx/.docx-style file containing a poisoned XML entry. If that file is fed into the review pipeline — whether through an upload flow, a shared drive, or an automated ingestion job — the vulnerable parser processes the payload with no warning.

The Fix

The PR makes a minimal, surgical change to the imports at the top of the file — no logic elsewhere in the script needed to change, because defusedxml.minidom is designed as an API-compatible drop-in replacement for xml.dom.minidom.

Before:

from xml.dom import minidom
from xml.parsers.expat import ExpatError

After:

from defusedxml import minidom
from pyexpat import ExpatError

Here's why each half of the change matters:

  • from defusedxml import minidom replaces the vulnerable native parser with defusedxml's hardened version. defusedxml.minidom disables DTD processing, external entity resolution, and external general/parameter entities by default, and raises a clear exception (EntitiesForbidden, DTDForbidden, etc.) if malicious constructs are detected in the input — instead of silently resolving them.
  • from pyexpat import ExpatError keeps exception handling working correctly. ExpatError is the exception type raised on malformed XML, and since defusedxml wraps the same underlying pyexpat C extension, importing ExpatError directly from pyexpat (rather than xml.parsers.expat) keeps the error-handling code (except ExpatError:) functioning exactly as before, with no other code changes required.

Because defusedxml.minidom mirrors the public API of xml.dom.minidom (parse(), parseString(), Node, etc.), the rest of prepare-overview-review.py — wherever it calls minidom.parse(...) on extracted archive contents — continues to work unchanged for well-formed, benign XML. The only behavioral difference is that malicious XML payloads now fail safely with a defusedxml-specific exception instead of being silently parsed and potentially exploited.

Key Takeaways

  • The vulnerable pattern was a single import line: from xml.dom import minidom in scripts/prepare-overview-review.py:26, feeding XML extracted from zip/document archives into an XXE-capable parser.
  • prepare-overview-review.py processes zip-based document archives (via ZipFile/BadZipFile), making it a realistic target for a maliciously crafted archive containing an XXE payload.
  • The fix required no changes to parsing logic — swapping xml.dom.minidomdefusedxml.minidom and xml.parsers.expat.ExpatErrorpyexpat.ExpatError was sufficient because defusedxml is API-compatible.
  • This was flagged as a "defensive hardening" fix rather than a confirmed active exploit — but XXE primitives left in place are exactly the kind of pattern automated exploit tooling looks to chain with other weaknesses.
  • Static analysis rules covering Bandit's B313–B320 and B405–B410 checks exist precisely to catch native XML library imports like this before they ship.

How Orbis AppSec Detected This

  • Source: XML content embedded inside zip archives (e.g., .pptx/.docx-style files) read via ZipFile in scripts/prepare-overview-review.py.
  • Sink: xml.dom.minidom parsing calls (line 28 area), which resolve DTDs and external entities by default.
  • Missing control: No use of a hardened, entity-resolution-disabled XML parser — the native xml.dom.minidom module was used as-is on data derived from an archive that could originate from an untrusted source.
  • CWE: CWE-611 — Improper Restriction of XML External Entity Reference.
  • Fix: Replaced xml.dom.minidom and xml.parsers.expat.ExpatError with defusedxml.minidom and pyexpat.ExpatError, disabling external entity and DTD processing by default.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

XXE vulnerabilities are easy to introduce and easy to overlook, because the vulnerable code often looks completely ordinary — a plain import xml.dom.minidom and a call to parse(). In scripts/prepare-overview-review.py, that pattern sat quietly in a script designed to unpack and inspect archive-based documents, a workflow that regularly touches XML data from sources outside the developer's direct control. The fix was small — two import lines — but it closes off a real class of attacks: local file disclosure, SSRF, and XML-based denial of service. Whenever your Python code parses XML, especially XML pulled from files, uploads, or archives, default to defusedxml rather than the standard library's XML modules, and let static analysis tools catch the cases you miss.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1

Related Articles

critical

How CSS Injection via Weak Pattern Validation happens in Vue.js and how to fix it

A critical CSS injection vulnerability in `testpage/App.vue` allowed attackers to bypass weak HTML5 pattern validation and load malicious stylesheets. The fix replaces direct variable assignment with a hardened `setCustomStylesheetHref()` method using strict regex validation.

critical

How Unvalidated Dynamic Component Loading happens in TypeScript/Viewi and how to fix it

A critical vulnerability in Viewi's component loader allowed attackers to inject malicious JavaScript through compromised or MITM-attacked external component servers. The fix adds proper HTTP response validation before parsing dynamically fetched JSON components.

high

How Denial of Service via Crafted ZIP File happens in Node.js and how to fix it

CVE-2026-39244 is a high-severity denial of service vulnerability in the adm-zip npm package that allows attackers to crash Node.js applications by uploading maliciously crafted ZIP files. The fix upgrades adm-zip from version 0.5.16 to 0.6.0, which adds proper memory bounds checking to prevent excessive allocation during archive extraction.

critical

How prototype pollution happens in JavaScript AST traversal and how to fix it

A critical prototype pollution primitive was fixed in `src/traverse/estraverse` where visitor-supplied child keys were merged with `Object.assign(Object.create(this.__keys), visitor.keys)`. Because `Object.assign` uses assignment semantics, a key literally named `__proto__` reached the `Object.prototype` setter and rewired the prototype chain of the traversal key map instead of being stored as data. The fix replaces the merge with an object spread (`{ ...VisitorKeys, ...visitor.keys }`), which *

critical

How SQL injection happens in Python DuckDB view creation and how to fix it

A critical SQL injection flaw in `python/src/idx/api.py:265` built five DuckDB `CREATE VIEW` statements with Python f-strings, interpolating a filesystem path directly into SQL text. The fix replaces the interpolated path with a bound parameter (`read_parquet(?)`) and moves the view names into a hardcoded, non-interpolated statement map — eliminating any path where filenames or directory values can alter SQL structure.

medium

How gitlab.bandit.B501 happens in Python and how to fix it

The `proverbia-scraper.py` script disabled TLS certificate verification on its `requests.get()` call and silenced the resulting security warnings, exposing the scraper to man-in-the-middle attacks. The fix removes the `verify=False` flag and the warning suppression, restoring proper certificate validation while keeping the existing 30-second timeout intact.