Back to Blog
medium SEVERITY7 min read

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

A medium-severity XML External Entity (XXE) vulnerability was discovered in `listKeyboardLayouts.py`, where Python's native `xml.etree.ElementTree` library was used to parse XML data. This library is susceptible to XXE attacks, which can allow attackers to read local files, perform server-side request forgery, or cause denial of service. The fix replaces the unsafe import with `defusedxml.ElementTree`, a drop-in hardened alternative recommended by the Python documentation itself.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is an XML External Entity (XXE) injection vulnerability (CWE-611) in Python, found in `listKeyboardLayouts.py`. Python's built-in `xml.etree.ElementTree` does not disable external entity processing by default, allowing attackers who control XML input to exfiltrate local files or trigger SSRF. The fix is a one-line change: replace `import xml.etree.ElementTree as et` with `import defusedxml.ElementTree as et`, which disables all dangerous XML features while preserving normal parsing behavior.

Vulnerability at a Glance

cweCWE-611
fixReplace `xml.etree.ElementTree` with `defusedxml.ElementTree` (drop-in hardened replacement)
riskAttackers can read local files, trigger SSRF, or cause DoS by embedding malicious XML entities
languagePython
root causePython's native `xml.etree.ElementTree` processes external XML entities by default
vulnerabilityXML External Entity (XXE) Injection

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:// with http:// to make the server issue outbound HTTP requests to internal services (e.g., cloud metadata endpoints like http://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.


Key Takeaways

  • xml.etree.ElementTree is 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.py was a single import swapdefusedxml is 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 path parameter passed to read_names(path: str) in listKeyboardLayouts.py, which accepts a file path to an XML document
  • Sink: et.parse(path) — the call to xml.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.ElementTree has no mechanism to disable XXE by default
  • CWE: CWE-611 — Improper Restriction of XML External Entity Reference
  • Fix: Replaced import xml.etree.ElementTree as et with import defusedxml.ElementTree as et on line 1 of listKeyboardLayouts.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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #166

Related Articles

high

How Regular Expression Denial of Service (ReDoS) Happens in Node.js trim-newlines and How to Fix It

CVE-2021-33623 exposed a Regular Expression Denial of Service (ReDoS) vulnerability in the npm package `trim-newlines` versions 1.0.0 and earlier. The vulnerable `.end()` method used an inefficient regex pattern that could cause severe performance degradation when processing malicious input. Upgrading to version 4.0.1 patches the regex implementation and eliminates the attack surface.

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.