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

critical

LDAP Filter Injection in da_unique_email_validator Fixed

The registration-time email uniqueness validator, `da_unique_email_validator`, formatted the submitted email address straight into an LDAP search filter with Python's `%` operator, so filter metacharacters in the email were interpreted as filter syntax. The fix wraps the value in `ldap.filter.escape_filter_chars()` (and imports the `ldap.filter` submodule explicitly), so a submitted address is always treated as a literal attribute value. Any deployment with `ldap login` enabled and a bind accoun

high

installPlugin(): Unvalidated npm Package Names Reach npm install

A plugin manager service exposed an `installPlugin(plugin: PluginInfo)` method that passed `plugin.packageName` and `plugin.version` straight into the platform's npm install routine with no validation, no blocklist, and no integrity verification of the fetched tarball. Because npm treats a non-semver "version" as a fetch specifier — a tarball URL, a git ref, a local path — an attacker who could influence the plugin listing could get arbitrary code installed and executed with full Electron/Node p

critical

deleteNestedProperty Prototype Pollution via Dot-Notation Path

The `deleteNestedProperty` function in propertyUtils.ts allowed attackers to manipulate JavaScript object prototypes by passing specially crafted dot-notation paths like `__proto__.polluted`. A fix now blocks dangerous keys before processing, preventing prototype pollution attacks that could affect all objects in the application.

critical

eval() in Async Function Constructor Enables Runtime Escape

The eval.mjs command handler used raw `eval()` to execute JavaScript expressions, creating a critical code injection path if owner credentials are compromised. The fix replaces `eval()` with the `AsyncFunction` constructor and explicitly shadows `process`, `require`, and other runtime globals as parameters, preventing evaluated code from reaching the Node.js runtime even when authentication boundaries fail.

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.