How XML External Entity (XXE) Injection Happens in Python and How to Fix It
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | XML External Entity (XXE) Injection |
| CWE | CWE-611 |
| Language | Python |
| Risk | File disclosure, SSRF, DoS via malicious XML input |
| Root Cause | xml.etree.ElementTree processes external entities by default |
| Fix | Drop-in replacement with defusedxml.ElementTree |
Introduction
The listKeyboardLayouts.py file does exactly what its name suggests — it reads and parses XML data to enumerate available keyboard layouts. Straightforward enough. But the way it imported Python's XML library introduced a subtle yet serious security flaw: it used the native xml.etree.ElementTree, a parser that has no protection against XML External Entity (XXE) attacks.
This is the kind of vulnerability that doesn't announce itself loudly. The code looks clean, the functionality works, and nothing seems obviously wrong — until someone hands it a crafted XML file.
The Vulnerability Explained
At the top of listKeyboardLayouts.py, a single import line determined how all XML parsing would behave throughout the file:
# Before the fix — vulnerable
import xml.etree.ElementTree as et
This import is then used in the read_names() function to parse XML from a file path:
def read_names(path: str) -> list[str]:
names = []
...
Python's xml.etree.ElementTree — along with the other flagged native XML modules (xml.sax, xml.dom.minidom, xml.dom.pulldom, xml.etree.cElementTree) — does not disable external entity processing by default. This means if an attacker can influence the content of the XML file being parsed (or the path itself), they can craft a payload like this:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE layouts [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<layouts>
<layout name="&xxe;" />
</layouts>
When et.parse(path) processes this file, the parser dutifully resolves &xxe; by reading /etc/passwd on the server and substituting its contents into the XML tree. If the application then returns or logs the name attribute, the attacker has successfully exfiltrated a sensitive system file.
What's the Real-World Impact?
For listKeyboardLayouts.py specifically, the attack surface depends on where the XML files come from. If the path argument to read_names() is derived from user input, a network share, a third-party package, or any source that could be tampered with, an attacker could:
- Read local files:
/etc/passwd,/etc/shadow, application config files with database credentials, private keys - Trigger Server-Side Request Forgery (SSRF): Replace
file://withhttp://to make the server issue outbound HTTP requests to internal services (e.g., cloud metadata endpoints likehttp://169.254.169.254/latest/meta-data/) - Cause Denial of Service: Use a "Billion Laughs" entity expansion attack to exhaust memory
Even if the current call sites for read_names() appear safe today, the presence of a vulnerable parser is an exploit primitive — a building block that automated attack tooling or a future code change could leverage.
The Fix
The fix is elegantly minimal: a single-line import change.
- import xml.etree.ElementTree as et
+ import defusedxml.ElementTree as et
defusedxml is a Python library created specifically to address the insecurity of Python's native XML parsers. It provides drop-in replacements for all the standard XML modules and disables the following dangerous features by default:
- External entity expansion (the core XXE vector)
- DTD processing (prevents Billion Laughs / entity expansion DoS)
- External DTD loading
- Python entity expansion
Because defusedxml.ElementTree exposes the same API as xml.etree.ElementTree, no other code in the file needed to change. The et.parse(), et.fromstring(), and all other calls work identically — they just now raise a DefusedXmlException if a malicious payload is encountered, rather than silently processing it.
Before vs. After
Before (vulnerable):
import xml.etree.ElementTree as et
def read_names(path: str) -> list[str]:
names = []
tree = et.parse(path) # Processes external entities — dangerous!
...
After (hardened):
import defusedxml.ElementTree as et
def read_names(path: str) -> list[str]:
names = []
tree = et.parse(path) # External entities blocked — safe!
...
The security improvement is profound despite the change being cosmetic. Any XML file that attempts to define or resolve external entities will now raise an exception rather than being silently exploited.
Prevention & Best Practices
1. Always Use defusedxml for XML Parsing in Python
The Python documentation itself warns against using the native XML libraries for untrusted input and recommends defusedxml. Install it with:
pip install defusedxml
Then replace your imports:
| Vulnerable Import | Safe Replacement |
|---|---|
import xml.etree.ElementTree |
import defusedxml.ElementTree |
import xml.sax |
import defusedxml.sax |
import xml.dom.minidom |
import defusedxml.minidom |
import xml.dom.pulldom |
import defusedxml.pulldom |
from xml.etree.cElementTree import ... |
from defusedxml.cElementTree import ... |
2. Validate File Paths Before Parsing
Even with a safe parser, validate the path argument to read_names() to ensure it points to an expected location:
import os
def read_names(path: str) -> list[str]:
# Ensure path is within the expected directory
base_dir = "/usr/share/keyboard-layouts"
resolved = os.path.realpath(path)
if not resolved.startswith(base_dir):
raise ValueError(f"Unexpected path: {path}")
...
3. Run Static Analysis in CI/CD
The Bandit rules that flagged this issue (B313–B320, B405–B410) are available in both Bandit and Semgrep. Add them to your CI pipeline:
# Example GitHub Actions step
- name: Run Semgrep
run: semgrep --config=p/python --config=p/bandit .
4. Principle of Least Privilege for File Access
Ensure the process running listKeyboardLayouts.py only has read access to the directories it legitimately needs. This limits the blast radius if an XXE attack does succeed.
Security Standards Reference
- OWASP: XXE is part of OWASP Top 10 A05:2021 – Security Misconfiguration and is addressed in the OWASP XXE Prevention Cheat Sheet
- CWE: CWE-611: Improper Restriction of XML External Entity Reference
Key Takeaways
xml.etree.ElementTreeis not safe for parsing untrusted XML — it's a well-known issue documented in the Python standard library docs, yet it remains a common mistake because the import looks harmless.- The fix in
listKeyboardLayouts.pywas a single import swap —defusedxmlis a true drop-in replacement, so there's no excuse not to use it. - The
read_names(path: str)function now safely handles malicious XML files without any logic changes to the parsing code itself. - Bandit rules B405–B410 specifically target XML library imports — if these rules fire in your codebase, treat them as high priority, not noise.
- XXE is an exploit primitive: even if today's call sites seem safe, leaving a vulnerable parser in place is a bet that no future code change or input source will ever be attacker-influenced.
How Orbis AppSec Detected This
- Source: The
pathparameter passed toread_names(path: str)inlistKeyboardLayouts.py, which accepts a file path to an XML document - Sink:
et.parse(path)— the call toxml.etree.ElementTree.parse()at the entry point of XML processing, where external entity resolution occurs - Missing control: No protection against external entity expansion; Python's native
xml.etree.ElementTreehas no mechanism to disable XXE by default - CWE: CWE-611 — Improper Restriction of XML External Entity Reference
- Fix: Replaced
import xml.etree.ElementTree as etwithimport defusedxml.ElementTree as eton line 1 oflistKeyboardLayouts.py
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
A single import statement was the difference between a parser that silently reads your /etc/passwd and one that safely rejects malicious XML. The vulnerability in listKeyboardLayouts.py is a textbook example of why "it works" is not the same as "it's secure" — Python's xml.etree.ElementTree parses XML perfectly well, it just does so without any of the guardrails that modern security requires.
The fix — swapping to defusedxml.ElementTree — took one line of code and zero changes to application logic. That's about as good a security-to-effort ratio as you'll ever find. The lesson for Python developers is clear: whenever you're parsing XML, reach for defusedxml first. Make it a habit, add Bandit or Semgrep to your CI pipeline to catch it when you forget, and treat any use of native XML libraries in code that handles external input as a bug waiting to be exploited.
References
- CWE-611: Improper Restriction of XML External Entity Reference
- OWASP XML External Entity (XXE) Prevention Cheat Sheet
- defusedxml on GitHub — Official Documentation
- Python xml.etree.ElementTree Security Warning (Official Docs)
- Semgrep rules for Python XML vulnerabilities
- harden: disable external XML entity processing in...