Skip to content

CWE-611: Improper Restriction of XML External Entity Reference - Python

Overview

Python XML parsing is risky for untrusted input because parser behavior depends on the library, Python build, and underlying Expat/libxml2 version. Standard-library parsers do not fetch local files or open network connections through Expat by default, but untrusted XML can still create entity-expansion and resource-exhaustion risk, and third-party parsers can reintroduce external entity handling when unsafe options are enabled.

Primary Defence: Use defusedxml for standard-library XML parsing of untrusted data, or configure third-party parsers before parsing to disable DTD loading, external entities, and network access, and to reject oversized trees.

Common Vulnerable Patterns

xml.etree.ElementTree for Hostile Input

# VULNERABLE - xml.etree.ElementTree for Hostile Input
# RISKY - Standard parser used directly on hostile XML

import xml.etree.ElementTree as ET

def parse_xml(xml_string):
    root = ET.fromstring(xml_string)
    return root.find('name').text

# Attacker sends:
# <?xml version="1.0"?>
# <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
# <root><name>&xxe;</name></root>

Why this is risky:

  • Security depends on Python's bundled or system Expat version and parser limits.
  • Use defusedxml instead for hostile or unauthenticated input.

lxml.etree with Unsafe Configuration

# VULNERABLE - lxml with entity resolution and DTD loading enabled

from lxml import etree

def parse_lxml(xml_string):
    parser = etree.XMLParser(
        load_dtd=True,
        resolve_entities=True,
        no_network=False
    )
    root = etree.fromstring(xml_string, parser=parser)
    return root.find('.//name').text

Why this is vulnerable:

  • DTD loading and entity resolution are explicitly enabled.
  • Enables file disclosure, SSRF, and entity expansion DoS.

xml.dom.minidom

# VULNERABLE - minidom with default parsing

import xml.dom.minidom

def parse_minidom(xml_string):
    doc = xml.dom.minidom.parseString(xml_string)  # DANGEROUS!
    return doc.getElementsByTagName('name')[0].firstChild.data

Why this is vulnerable:

  • DTD handling can enable entity expansion and DoS.
  • External entity behavior depends on the underlying parser.

xml.sax

# VULNERABLE - SAX parser without features

import xml.sax
from xml.sax.handler import ContentHandler

class MyHandler(ContentHandler):
    def startElement(self, name, attrs):
        pass

def parse_sax(xml_string):
    handler = MyHandler()
    xml.sax.parseString(xml_string, handler)

Why this is vulnerable:

  • External entity and DTD handling are parser-dependent across SAX implementations.
  • Use defusedxml rather than parsing hostile XML with the standard library directly.

Secure Patterns

# SECURE - defusedxml library blocks XXE by default

import defusedxml.ElementTree as ET
from defusedxml.common import DefusedXmlException

def parse_xml_secure(xml_string):
    """Parse XML safely with defusedxml"""
    try:
        root = ET.fromstring(xml_string)
        return root.find('name').text
    except DefusedXmlException:
        # defusedxml raises EntitiesForbidden/DTDForbidden, which derive from
        # ValueError - not from ET.ParseError - so a ParseError-only handler
        # never sees the rejection. Do not interpolate the exception either:
        # it carries the system id the attacker supplied.
        raise ValueError("XML rejected: DTD or entity declaration")
    except ET.ParseError as e:
        raise ValueError(f"Invalid XML: {e}")

# Installation: pip install defusedxml

Why this works:

  • Blocks entity declarations and external references for the supported standard-library APIs - EntitiesForbidden on the classic payload and on an entity bomb, measured on defusedxml 0.7.1. A DOCTYPE that declares no entities is still accepted: forbid_dtd defaults to False, so pass forbid_dtd=True where a DTD is outside the API contract.
  • Drop-in replacement removes the need for parser-specific flags.

lxml with Secure Configuration

# SECURE - lxml with no_network and resolve_entities=False

from lxml import etree

def parse_lxml_secure(xml_string):
    """Parse XML with lxml securely"""
    parser = etree.XMLParser(
        no_network=True,           # Block network access
        resolve_entities=False,    # Don't resolve entities
        load_dtd=False,            # Don't load external DTDs
        dtd_validation=False       # Don't validate against DTD
    )

    try:
        root = etree.fromstring(xml_string.encode('utf-8'), parser=parser)
        return root.find('.//name').text
    except etree.XMLSyntaxError as e:
        raise ValueError(f"Invalid XML: {e}")

Why this works:

  • Disables network access, DTDs, and entity resolution.
  • Safe when you must use lxml for performance/features.

xml.etree with Manual Entity Prevention

# DEFENSE IN DEPTH - Check for entities before parsing

import xml.etree.ElementTree as ET
import re

def parse_xml_with_validation(xml_string):
    """Parse XML after validating no entities present"""

    # Block if contains DOCTYPE or entity declarations
    if '<!DOCTYPE' in xml_string or '<!ENTITY' in xml_string:
        raise ValueError("XML contains DTD/entities - rejected")

    # Block entity references
    if re.search(r'&(?!amp;|lt;|gt;|quot;|apos;)[a-zA-Z0-9_]+;', xml_string):
        raise ValueError("XML contains entity references - rejected")

    try:
        root = ET.fromstring(xml_string)
        return root
    except ET.ParseError as e:
        raise ValueError(f"Invalid XML: {e}")

# Note: defusedxml is still preferred over this brittle boundary check

Why this works:

  • Rejects obvious DOCTYPE/ENTITY and custom entity refs up front.
  • Useful as a boundary check, but not a replacement for defusedxml or secure parser settings.

Framework-Specific Guidance

Django

# SECURE - Django views with defusedxml

import logging

from django.http import JsonResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
import defusedxml.ElementTree as ET
from defusedxml.common import DefusedXmlException

logger = logging.getLogger(__name__)

@csrf_exempt
def process_xml(request):
    """Process XML upload securely"""
    if request.method != 'POST':
        return HttpResponseBadRequest('POST required')

    try:
        xml_data = request.body.decode('utf-8')

        # Parse with defusedxml
        root = ET.fromstring(xml_data)

        # Extract data
        name = root.find('name').text
        email = root.find('email').text

        # Validate
        if not name or len(name) > 100:
            return HttpResponseBadRequest('Invalid name')

        if not email or '@' not in email:
            return HttpResponseBadRequest('Invalid email')

        # Process data
        user = User.objects.create(name=name, email=email)

        return JsonResponse({
            'id': user.id,
            'name': user.name,
            'email': user.email
        })

    except DefusedXmlException:
        # defusedxml rejects the entity here, and EntitiesForbidden is a
        # ValueError, not an ET.ParseError - the arm below never sees it
        return HttpResponseBadRequest('Invalid XML')
    except ET.ParseError:
        # Do not interpolate the parser error - it quotes the document back,
        # which hands an attacker a reflection point and leaks file paths
        return HttpResponseBadRequest('Invalid XML')
    except Exception:
        logger.exception('XML processing failed')
        return HttpResponseBadRequest('Could not process request')

Flask

# SECURE - Flask API with defusedxml

from flask import Flask, request, jsonify
import defusedxml.ElementTree as ET
from defusedxml.common import DefusedXmlException

app = Flask(__name__)

@app.route('/api/users', methods=['POST'])
def create_user():
    """Create user from XML"""
    # Compare the parsed mimetype, not the raw header - request.content_type
    # is "application/xml; charset=utf-8" for a legitimate client, which an
    # equality check against "application/xml" rejects.
    if request.mimetype != 'application/xml':
        return jsonify({'error': 'Content-Type must be application/xml'}), 400

    try:
        xml_data = request.data.decode('utf-8')

        # Parse securely
        root = ET.fromstring(xml_data)

        # Extract and validate
        name = root.find('name')
        email = root.find('email')

        if name is None or not name.text:
            return jsonify({'error': 'Name is required'}), 400

        if email is None or not email.text or '@' not in email.text:
            return jsonify({'error': 'Valid email is required'}), 400

        # Create user
        user = {
            'name': name.text,
            'email': email.text
        }

        # Save to database...

        return jsonify(user), 201

    except DefusedXmlException:
        # without this arm the rejection falls through to `except Exception`
        # below and an attacker's document gets a 500 instead of a 400
        return jsonify({'error': 'Invalid XML'}), 400
    except ET.ParseError:
        return jsonify({'error': 'Invalid XML'}), 400
    except Exception:
        app.logger.exception('XML processing failed')
        return jsonify({'error': 'Internal server error'}), 500

if __name__ == '__main__':
    app.run(debug=False)

FastAPI

# SECURE - FastAPI with defusedxml

from fastapi import FastAPI, Request, HTTPException
from pydantic import BaseModel, EmailStr
import defusedxml.ElementTree as ET
from defusedxml.common import DefusedXmlException

app = FastAPI()

class User(BaseModel):
    name: str
    email: EmailStr

@app.post("/users", response_model=User)
async def create_user(request: Request):
    """Create user from XML request"""

    # Check content type. Split off parameters first - a legitimate client
    # sends "application/xml; charset=utf-8", which an equality check refuses.
    content_type = request.headers.get('content-type', '').split(';')[0].strip()
    if content_type != 'application/xml':
        raise HTTPException(400, 'Content-Type must be application/xml')

    try:
        # Read XML body
        xml_data = await request.body()
        xml_string = xml_data.decode('utf-8')

        # Parse securely with defusedxml
        root = ET.fromstring(xml_string)

        # Extract data
        name_elem = root.find('name')
        email_elem = root.find('email')

        if name_elem is None or not name_elem.text:
            raise HTTPException(400, 'Name is required')

        if email_elem is None or not email_elem.text:
            raise HTTPException(400, 'Email is required')

        # Create User (Pydantic validates)
        user = User(
            name=name_elem.text,
            email=email_elem.text
        )

        # Save to database...

        return user

    except DefusedXmlException:
        # nothing else catches this: without the arm the rejection leaves the
        # handler uncaught and the client gets a 500
        raise HTTPException(400, 'Invalid XML')
    except ET.ParseError:
        # the parser error quotes the document back - do not return it
        raise HTTPException(400, 'Invalid XML')

RSS/Atom Feed Parsing

# SECURE - Parse RSS feeds with defusedxml

import defusedxml.ElementTree as ET
import requests

def parse_rss_feed(feed_url):
    """Safely parse RSS feed"""

    # Fetch feed
    response = requests.get(feed_url, timeout=10)
    response.raise_for_status()

    # Parse with defusedxml
    root = ET.fromstring(response.content)

    items = []
    for item in root.findall('.//item'):
        title = item.find('title')
        link = item.find('link')

        if title is not None and link is not None:
            items.append({
                'title': title.text,
                'link': link.text
            })

    return items

# Alternative: Use a maintained feed parser and keep it updated

import feedparser

def parse_feed_with_feedparser(feed_url):
    """Parse feed with feedparser library"""
    feed = feedparser.parse(feed_url)

    return [{
        'title': entry.title,
        'link': entry.link
    } for entry in feed.entries]

SOAP/XML-RPC

# SECURE - SOAP response parsing with hardened lxml

from lxml import etree
import requests

def call_soap_service(endpoint, xml_request):
    """Call SOAP service securely"""

    headers = {
        'Content-Type': 'text/xml; charset=utf-8',
        'SOAPAction': 'urn:action'
    }

    # Send request
    response = requests.post(endpoint, data=xml_request, headers=headers)
    response.raise_for_status()

    parser = etree.XMLParser(
        no_network=True,
        resolve_entities=False,
        load_dtd=False,
        dtd_validation=False
    )
    root = etree.fromstring(response.content, parser=parser)

    # Extract data from SOAP envelope
    body = root.find('.//{http://schemas.xmlsoap.org/soap/envelope/}Body')
    return body

Input Validation

# Validate XML structure after parsing

import defusedxml.ElementTree as ET

def parse_and_validate_user_xml(xml_string):
    """Parse and validate user XML"""

    # Parse securely
    root = ET.fromstring(xml_string)

    # Validate root element
    if root.tag != 'user':
        raise ValueError('Root element must be <user>')

    # Extract required fields
    name = root.find('name')
    email = root.find('email')
    age = root.find('age')

    # Validate presence
    if name is None or not name.text:
        raise ValueError('Name is required')

    if email is None or not email.text:
        raise ValueError('Email is required')

    # Validate content
    if len(name.text) > 100:
        raise ValueError('Name too long')

    if '@' not in email.text:
        raise ValueError('Invalid email format')

    if age is not None and age.text:
        try:
            age_int = int(age.text)
        except ValueError:
            # Keep the conversion inside the try. With the range check in
            # here too, its ValueError is caught by this handler and reported
            # as "must be an integer", which is the wrong error for "200".
            raise ValueError('Age must be an integer')

        if age_int < 0 or age_int > 150:
            raise ValueError('Age out of range')

    return {
        'name': name.text,
        'email': email.text,
        'age': int(age.text) if age is not None and age.text else None
    }

Common Pitfalls

  • Using xml.etree.ElementTree directly on untrusted input because of what a particular build happens to do. On a current CPython the standard library is stronger than it used to be - ET.fromstring rejects the classic payload with undefined entity &xxe; because it does not process entity declarations at all, and Expat's input-amplification limit rejects a Billion Laughs bomb with limit on input amplification factor. The reason to reach for defusedxml is that none of this is a guarantee you control: it comes from the bundled Expat version, and the same code on an older interpreter or a rebuilt Expat behaves differently. Depend on the library that states the policy, not on the build that currently implements it.
  • Setting resolve_entities=False on an lxml.etree.XMLParser while leaving load_dtd=True or no_network=False - resolve_entities only controls entity substitution; it doesn't stop the DTD itself from being fetched over the network or filesystem when DTD loading is separately enabled.
  • Expecting resolve_entities=False to reject the document. It does not - it makes the reference expand to nothing, so the parse succeeds and the element arrives empty. lxml's own default parser is louder: it raises XMLSyntaxError: Entity 'xxe' not defined, so hardening the parser can turn a visible failure into a silent one. Check for None on every element you read, as the Flask and Django examples above do, because a hardened parser is exactly the case where a field can go missing without an error.
  • Using a regex or substring check for <!DOCTYPE/<!ENTITY as the primary defense before falling back to a raw xml.etree/xml.dom.minidom parse - this kind of boundary check is brittle against encoding tricks and payload variations and does nothing for entity-expansion DoS; defusedxml should be the primary control, not a hand-rolled filter in front of an unhardened parser.

Additional Resources