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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed September 24, 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 swap — defusedxml 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

CVE-2026-4800: lodash Template Imports Allow Code Execution

CVE-2026-4800 affects lodash's `_.template()` templating API, where untrusted input reaching the `imports` option can lead to arbitrary code execution. The fix was shipped as a dependency upgrade from lodash 4.17.21 to 4.18.1 in the project's lockfile, though the PR itself notes it was never verified against the actual code paths in this repository.

critical

proxy-addr 2.0.7 IP Spoofing: CVE-2026-90711 Trust Bypass

A critical vulnerability in proxy-addr 2.0.7 allowed attackers to spoof client IP addresses by manipulating X-Forwarded-For headers when the trust chain evaluation contained specific misconfigurations. The fix in version 2.0.8 hardens the trust evaluation logic to prevent IP address falsification in Express.js applications relying on this common middleware dependency.

high

TweenMax `_applyCycle` Prototype Pollution via vars.cycle Keys

A bundled copy of the TweenMax animation library copied attacker-influenceable `vars.cycle` property names straight onto a tween configuration object using an unguarded `for...in` loop, so a key named `__proto__`, `constructor`, or `prototype` was written through to the object's prototype chain. The fix adds an explicit key denylist to both copies of the `_applyCycle` helper so those three names are skipped during the merge. No CVE or GHSA is assigned; the issue is tracked as CWE-1321 (Improperl

high

picomatch 2.3.1 ReDoS: Extglob Pattern Catastrophic Backtracking

picomatch versions below 2.3.2, 3.0.2, or 4.0.4 contain a Regular Expression Denial of Service vulnerability in extglob pattern parsing. An attacker can cause catastrophic backtracking with patterns containing nested alternations and quantifiers, freezing any Node.js process that evaluates untrusted glob expressions.

critical

pet-window.js Dynamic Code Evaluation: CWE-94 Hardening via Number

The pet-window module constructed dynamic JavaScript by embedding raw configuration values into code strings. An attacker with local access could inject arbitrary JavaScript by modifying stored configuration. The fix replaces string interpolation with explicit Number() coercion and NaN validation for all numeric configuration parameters.

high

sanitizeUnicodeInput(): Fullwidth U+ Bypasses Codepoint Validation

The `sanitizeUnicodeInput()` helper used by the project character-range settings screen rewrote `U+` prefixes to `0x` and called `parseInt()`, but never normalized its argument first. Compatibility-equivalent forms such as fullwidth `U+`, superscript digits, or mathematical alphanumerics never matched the `/U\+/gi` regex, fell through to the `else return inputString` branch, and were handed back to callers verbatim as "sanitized" values. The fix inserts a `String.prototype.normalize('NFKC')` pas