Skip to content

CWE-91: XML Injection (aka Blind XPath Injection) - Python

Overview

XML Injection in Python applications occurs when untrusted user input is used to construct XML documents or queries without validating it or escaping it for the context it lands in. A value carrying XML metacharacters (<, >, &, ', ") then changes the structure of the document or the logic of the query instead of being read as text: it can add an <admin>true</admin> element the application never wrote, or turn an XPath predicate into one that matches every record.

Primary defense: Build XML with xml.etree.ElementTree or lxml instead of string concatenation or f-strings - these treat values assigned to .text/.set() as text content and escape them automatically. Where a builder isn't practical, escape by context: xml.sax.saxutils.escape() for element text, and xml.sax.saxutils.quoteattr() for an attribute, because escape() replaces only &, < and > and would leave a " free to close the attribute it sits in. For XPath, do not concatenate: lxml binds variables natively, tree.xpath("//user[name=$n]", n=value). If the same application also parses untrusted XML, disabling external entity processing (defusedxml, or XMLParser(resolve_entities=False)) is a separate fix for a separate finding - CWE-611 - and does nothing about either sink here.

Common vulnerability scenarios: Flask/Django endpoints that build XML responses or SOAP envelopes with f-strings, XML configuration files written from user preferences, and XPath queries built by concatenating a search term into the expression.

Python XML libraries:

  • xml.etree.ElementTree - standard library XML API, recommended for general use
  • lxml - feature-rich XML/HTML library with XPath and XSLT support
  • defusedxml - security-hardened parsing of untrusted XML (XXE protection)
  • zeep - modern SOAP client with automatic envelope construction

Common Vulnerable Patterns

String Concatenation into XML

# VULNERABLE - user input embedded directly in an XML f-string
def create_user_xml(username, email):
    xml = f"""<?xml version="1.0"?>
<user>
    <username>{username}</username>
    <email>{email}</email>
</user>"""
    return xml

# Attack: username = "</username><admin>true</admin><username>"
# Result: <username></username><admin>true</admin><username></username>
# Creates an unintended <admin> element

Why this is vulnerable: No XML special characters are escaped, so a value containing </username> closes the current element early and any markup that follows becomes part of the document structure instead of text content. The same flaw shows up wherever interpolated values land: SOAP envelopes (<UserId>{user_id}</UserId> lets an attacker inject <Role>admin</Role>), XML config files, and attribute values (custom="{attr_value}" - a value containing an unescaped " closes the attribute early and lets the attacker append new attributes). lxml.etree.fromstring() accepts whatever bytes it's given; it doesn't retroactively escape a string built before parsing. Database-sourced values are just as exposed as request parameters - a stored bio field containing </bio><admin>true</admin> produces the same structural injection when exported.

XPath Query Injection

# VULNERABLE - user input concatenated into an XPath expression
from lxml import etree

def find_user_by_name(xml_doc, username):
    root = etree.fromstring(xml_doc)
    xpath = f"//user[name='{username}']"
    return root.xpath(xpath)

# Attack: username = "' or '1'='1"
# XPath becomes: //user[name='' or '1'='1']  -> returns all users

Why this is vulnerable: This is a different sink from markup injection - the attacker isn't adding XML elements, they're changing the boolean logic of the query itself. Escaping XML entities does nothing here because the injection point is inside an XPath string literal, not XML markup.

Secure Patterns

ElementTree (Primary Defense)

# SECURE - ElementTree escapes text content automatically
import xml.etree.ElementTree as ET

def create_user_xml(username, email):
    if not isinstance(username, str) or len(username) > 100:
        raise ValueError("Invalid username")
    if not isinstance(email, str) or len(email) > 255:
        raise ValueError("Invalid email")

    root = ET.Element('user')
    ET.SubElement(root, 'username').text = username  # escaped automatically
    ET.SubElement(root, 'email').text = email         # escaped automatically

    xml_str = ET.tostring(root, encoding='unicode')
    return f'<?xml version="1.0"?>\n{xml_str}'

# create_user_xml("<script>alert('xss')</script>", "test@example.com")
# -> <username>&lt;script&gt;alert('xss')&lt;/script&gt;</username>

Why this works: Assigning to .text (or calling .set() for an attribute) treats the value as character data, not markup, so ElementTree escapes what the context requires before serialization - a value containing </username><admin>true</admin> becomes inert text rather than new elements. The escaping is context-aware rather than uniform: .text replaces &, < and > and leaves quotes alone, which is correct for character data, while .set() also replaces " because that is what would end an attribute early. This is the part hand-rolled escaping usually gets wrong in the other direction, applying one escape set everywhere and leaving attributes open. ElementTree is part of the standard library, has no dangerous DTD/entity-expansion features to misconfigure, and builds a tree internally rather than concatenating raw strings, so there is never a place for an attacker to break out. ET.SubElement(parent, tag) still takes the tag name as a plain string with no escaping of its own - building a tag name from user input is a separate, unaudited path even though .text is safe.

lxml with Safe API

# SECURE - lxml adds XPath/XSLT support with the same escaping guarantee
from lxml import etree
import re

def create_xml_with_lxml(data_dict) -> bytes:
    root = etree.Element('response')
    for key, value in data_dict.items():
        # fullmatch, not match: Python's $ also matches immediately before a
        # trailing newline, so re.match(r'^...$', 'total\n') succeeds
        if not re.fullmatch(r'[a-zA-Z_][a-zA-Z0-9_-]*', key):
            raise ValueError(f"Invalid XML element name: {key}")
        etree.SubElement(root, key).text = value  # escaped automatically

    # Returns bytes. lxml rejects xml_declaration with encoding='unicode'
    # ("Serialisation to unicode must not request an XML declaration"), since a
    # declaration names an encoding that a str does not have. Either serialize
    # to bytes as here, or use encoding='unicode' with no declaration.
    return etree.tostring(root, encoding='utf-8', xml_declaration=True)

Why this works: lxml's .text assignment escapes the same way ElementTree's does, but lxml adds XPath, XSLT, and XML Schema validation on top - use it when you need those features or better performance on large documents; use ElementTree for simpler cases with no extra dependency. The element-name check matters here specifically because, as above, tag/attribute names aren't escaped the way text content is - it is the only control on that argument, which is why the anchoring is worth getting right. Python's $ matches immediately before a final newline, so re.match(r'^[a-zA-Z_][a-zA-Z0-9_-]*$', 'total\n') returns a match object and the key passes; re.fullmatch without the anchors does not. A newline is not a valid XML name character, so what the loose version buys is a ValueError from lxml partway through building the document rather than a clean rejection.

SOAP with Zeep

# SECURE - zeep builds the SOAP envelope, not the developer
from zeep import Client

def call_soap_service_secure(user_id, action):
    if not isinstance(user_id, (int, str)) or not str(user_id).isalnum():
        raise ValueError("Invalid user_id")
    client = Client('https://api.example.com/service?wsdl')
    return client.service.GetUserData(UserId=user_id, Action=action)

Why this works: Zeep (the maintained successor to suds) fetches the WSDL, generates type-safe method calls, and serializes parameters through its lxml-based serializer, so a user_id of </UserId><Role>admin</Role> reaches the service as inert text. There is no hand-built f-string envelope left for an interpolated value to break out of. Pre-validating type and format still matters as defense-in-depth against oversized or malformed input.

XPath: Bind a Variable, or Iterate and Compare

# SECURE - lxml binds the value to $username instead of concatenating it
def find_user_with_xpath_vars(xml_doc, username):
    root = etree.fromstring(xml_doc)
    results = root.xpath("//user[name=$username]", username=username)
    return results[0] if results else None

# SECURE - no user input in the XPath expression itself
def find_user_by_name_secure(xml_doc, username):
    root = etree.fromstring(xml_doc)
    for user in root.xpath('//user'):
        name_elem = user.find('name')
        if name_elem is not None and name_elem.text == username:
            return user
    return None

Why this works: The first uses lxml's native XPath variable binding - username=username is passed as a separate keyword argument, not concatenated into the query string, so lxml handles it the same way a SQL driver handles a bound parameter. The expression string is fixed and holds nothing attacker-controlled; $username resolves to a single value at evaluation time, so quotes, or and function calls inside it are compared as text. Measured on lxml 6.1 against a document holding alice, bob and O'Brien: 1 node for alice, 0 for ' or '1'='1', where the f-string version returns all three. It also matches O'Brien, which the concatenated form cannot express at all - XPath 1.0 string literals have no escape sequence for their own delimiter, so 'O''Brien' and 'O\'Brien' are both XPathEvalError: Invalid predicate.

The second version's expression ('//user') is static and filtering happens afterward with an exact string comparison in Python. Reach for it when the comparison has to happen in Python - a locale-aware collation, a normalization step - or when you are on xml.etree.ElementTree, which supports only a restricted XPath subset and has no variable binding at all, making iterate-and-compare the only option there.

Framework-Specific Guidance

Flask and Django

Validate query/GET parameters before building the response, return a generic error body on failure, and set the content type explicitly:

@app.route('/api/user', methods=['GET'])
def get_user():
    username = request.args.get('username', '')
    if not re.fullmatch(r'[a-zA-Z0-9._-]+', username):  # fullmatch: $ allows a trailing newline
        return Response('<error>Invalid username</error>', status=400, mimetype='application/xml')

    root = ET.Element('user')
    ET.SubElement(root, 'username').text = username
    return Response(ET.tostring(root, encoding='unicode'), mimetype='application/xml')

Why this works: Validating request.args/request.GET before it reaches ElementTree is the earliest point in the request pipeline to reject malicious input. mimetype='application/xml' (Flask) or content_type='application/xml' (Django) sets the response header explicitly, and a generic error message avoids leaking why validation failed. Use defusedxml.ElementTree instead of the standard library when parsing attacker-supplied XML in the same application - it blocks DOCTYPE declarations and entity expansion (XXE), a separate concern from the document-construction escaping shown above.

Testing

  • Submit </username><admin>true</admin> and assert on the serialized output, not the HTTP status: it must come back as &lt;/username&gt;... text, and re-parsing the document must yield one element, not two.
  • Round-trip a value containing all five metacharacters (<script>&"') through .text, ET.tostring() and ET.fromstring(), and assert the text comes back identical. That single assertion catches both an escaper that misses a character and a "fix" that double-escapes. Repeat it for .set() and .get(), because the attribute escape set is not the text one.
  • Submit ' or '1'='1 to the XPath lookup and assert the result list has the same length as for a nonexistent user (zero), not the length of the whole document. The payload only bypasses a single-clause predicate: against //user[name=$n and password=$p] it has to appear in both fields, because and binds tighter than or.
  • Test normal inputs to confirm the secure pattern doesn't reject legitimate data, and include O'Brien - a bound query matches it where a concatenated one cannot express it at all.
  • Feed every allowlist on the page its own permitted value with "\n" appended and assert it is rejected. Python's $ matches before a final newline, so re.match(r'^[a-z]+$', 'alice\n') succeeds and re.fullmatch(r'[a-z]+', 'alice\n') does not.
  • Test boundary cases: empty strings, maximum-length values, Unicode characters.
  • Confirm attribute values and element names are both covered, not just element text content.
  • Re-scan with the security scanner that produced the original finding to confirm it no longer triggers.

Common Pitfalls

  • Setting .text safely but building the tag name dynamically from untrusted input, e.g. ET.SubElement(parent, user_input) - .text assignment escapes character data, but the name argument isn't escaped the same way; a value that happens to be a valid XML name still lets an attacker choose which element gets created.
  • Using defusedxml only when parsing untrusted XML while still building outgoing XML documents elsewhere by string concatenation - defusedxml hardens parsing, it does nothing for document construction, and fixing one is easy to mistake for fixing both.

Dependencies and Installation

  • xml.etree.ElementTree is part of the standard library - no dependency needed for the primary pattern.
  • pip install lxml for XPath, XSLT, and XML Schema support.
  • pip install defusedxml for XXE-hardened parsing of untrusted XML.
  • pip install zeep for a maintained SOAP client that builds envelopes safely.

Additional Resources