DEV Community

Cover image for XML External Entity (XXE) Injection: A Complete Guide for Developers
Iroro Chadere
Iroro Chadere

Posted on

XML External Entity (XXE) Injection: A Complete Guide for Developers

XXE (XML External Entity) injection is a vulnerability that turns standard XML features into security nightmares. Imagine three weeks after adding XML support to your API, you discover your application has been leaking AWS credentials to attackers. The culprit? A seemingly innocent XML parser doing exactly what it was designed to do.

Let's break down exactly how it works and how to prevent it.

Why XML Parsers Are Different

If you've worked with JSON APIs, you know the parser's job is straightforward: read the data structure and deserialize it. The JSON itself can't tell the parser to fetch external files or make network requests.

XML operates differently. XML includes a feature called Document Type Definitions (DTDs), a system originally designed to define structure and validation rules for documents. DTDs support entities, which work like variables you can define and reference throughout your document.

Here's a simple internal entity:

<!DOCTYPE note [
  <!ENTITY company "TechCorp">
]>
<note>
  <message>Welcome to &company;</message>
</note>
Enter fullscreen mode Exit fullscreen mode

When parsed, &company; gets replaced with "TechCorp". This is completely safe because the entity value is defined right there in the document—there's no external data source involved.

But XML also supports external entity references to files on the filesystem or URLs on the network:

<!DOCTYPE data [
  <!ENTITY external SYSTEM "file:///etc/passwd">
]>
<data>&external;</data>
Enter fullscreen mode Exit fullscreen mode

This is where things get dangerous. The SYSTEM keyword tells the parser to go fetch content from an external source. By default, most XML parsers will resolve this external entity without question. They'll read /etc/passwd from the filesystem and insert its contents into your document.

The parser doesn't distinguish between "content the developer intended to include" and "content an attacker specified in a malicious payload." It just follows instructions. If your application reflects that parsed content back to users in an API response, displays it in an admin panel, or logs it somewhere accessible, an attacker just read arbitrary files from your server.

The Three Main Attack Vectors

Direct File Disclosure

The most straightforward attack. An attacker sends XML with an external entity pointing to a sensitive file:

<!DOCTYPE order [
  <!ENTITY xxe SYSTEM "file:///var/www/app/config/database.yml">
]>
<order>
  <details>&xxe;</details>
</order>
Enter fullscreen mode Exit fullscreen mode

Here's what happens step by step. Your application receives this XML, maybe via an API endpoint that accepts XML data. The XML parser encounters the <!DOCTYPE> declaration and processes the DTD. The parser sees the entity definition and reads that file from disk. When the parser reaches &xxe; in the document body, it replaces it with the file's contents. Your application processes the parsed XML, maybe it stores the details field in a database or returns it in an API response. The attacker receives your database credentials.

Real-world target files include /etc/passwd for user account information on Linux systems, /var/www/app/.env for environment variables and secrets, /proc/self/environ for environment variables of the current process which often contains secrets on cloud platforms, C:\Windows\System32\drivers\etc\hosts for system configuration on Windows, and ~/.ssh/id_rsa for private SSH keys.

The key requirement for this attack to work: your application must echo back or expose the parsed content somehow. This happens more often than you'd think, debugging endpoints that return parsed data, error messages that include XML values, or admin dashboards that display imported data.

Server-Side Request Forgery via XXE

Instead of reading local files, attackers can make your server send HTTP requests to arbitrary URLs:

<!DOCTYPE data [
  <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/admin-role">
]>
<data>&xxe;</data>
Enter fullscreen mode Exit fullscreen mode

This example targets AWS's metadata service, an internal HTTP endpoint available to EC2 instances that returns sensitive information about the instance, including IAM credentials.

Here's why this is so dangerous. The metadata service at 169.254.169.254 is only accessible from inside the AWS network. You can't reach it from your laptop or from the internet. But when your server's XML parser encounters SYSTEM "http://169.254.169.254/...", it makes an HTTP request from inside your infrastructure. The parser fetches the response which contains AWS credentials and inserts it into the parsed document.

If your application returns this parsed content, the attacker now has credentials to read from and write to S3 buckets, query and modify databases, spin up new EC2 instances, and access any resource the role has permissions for.

This works because your server can reach internal services that should never be internet-accessible. The attacker is using your server as a proxy into your private network. They could target http://internal-api.company.local/admin/users for internal admin APIs, http://localhost:8080/actuator/health for Spring Boot management endpoints, http://192.168.1.50:9200/_cat/indices for Elasticsearch clusters on your internal network, or http://consul.service.consul:8500/v1/kv/ for service discovery systems.

The XML parser doesn't care that these are internal resources. It just follows the URL and returns whatever it finds.

Denial of Service Through Entity Expansion

This attack is called "Billion Laughs" and it's devastatingly simple:

<!DOCTYPE data [
  <!ENTITY lol "lol">
  <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
  <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
  <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
]>
<data>&lol4;</data>
Enter fullscreen mode Exit fullscreen mode

Let's trace what happens when the parser expands &lol4;. The entity lol4 contains 10 references to lol3. Each lol3 contains 10 references to lol2, so that's 10 times 10 equals 100 lol2 references. Each lol2 contains 10 references to lol, so that's 100 times 10 equals 1,000 lol references. Each lol contains the string "lol", so that's 1,000 times 3 characters equals 3,000 characters.

Now imagine adding more levels. With just a few more entity definitions, you can create billions of repetitions. By the time the parser finishes expanding, it's trying to hold billions of strings in memory. Your application crashes. Your server runs out of memory and the operating system kills the process. If this happens repeatedly, you've got a full denial of service.

The attack works because entity expansion happens during parsing before your application code even sees the data. You can't validate or limit it at the application layer because the parser consumes resources before you get a chance to intervene.

Where XXE Hides in Modern Applications

"Nobody uses XML anymore" is a dangerous assumption. XML processing happens in places you might not expect. SAML authentication systems use XML to pass authentication assertions between identity providers and applications. SOAP APIs are still common in enterprise integrations, especially with older systems. Office documents like .docx, .xlsx, and .pptx are ZIP files containing XML files that define document structure. SVG images are XML-based. RSS and Atom feeds for blogs and podcasts use XML. Configuration files like Maven POMs, Android layouts, and Spring configuration files are all XML.

The File Upload Problem

The most dangerous scenario is file uploads. This deserves special attention because it's often overlooked.

Here's why file uploads are particularly vulnerable. First, developers don't realize they're parsing XML. When you add a feature to let users upload profile pictures and accept SVG files, you might not think "I'm processing XML." But SVGs are XML documents. If you validate them, extract metadata, or resize them using a library that parses the XML structure, you're running an XML parser on untrusted input.

Second, the parser runs automatically. Many image processing libraries will automatically parse XML-based formats. You call image.open('avatar.svg') thinking you're just loading an image, but behind the scenes, an XML parser is processing the entire document structure including any DTDs and external entities.

Third, users expect file uploads to be safe. Unlike an API endpoint that explicitly accepts XML data, file uploads feel like simple data storage. Developers might thoroughly secure their API parsing but forget that uploaded files need the same scrutiny.

Example attack: An attacker uploads this SVG as their profile picture:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
  <text x="10" y="20">&xxe;</text>
</svg>
Enter fullscreen mode Exit fullscreen mode

If your application processes this SVG to validate its dimensions before storing it, generate a thumbnail preview, extract and display metadata, or render it server-side for a preview, then your XML parser reads /etc/passwd and potentially exposes its contents.

The attacker might see the file contents in an error message like "Invalid text content: root❌0:0:root:/root:/bin/bash...", a thumbnail that renders the text, server logs that your application saves, or admin panels that show "problematic uploads."

Office documents have the same issue. A .docx file is a ZIP archive containing these XML files: word/document.xml for the actual document content, word/styles.xml for formatting information, and [Content_Types].xml for file type mappings.

If your application accepts document uploads and uses a library to extract text, count words, or index content, you're parsing XML. An attacker can embed malicious entities in these XML files:

<!-- word/document.xml -->
<?xml version="1.0"?>
<!DOCTYPE document [
  <!ENTITY xxe SYSTEM "file:///var/www/app/.env">
]>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
  <w:body>
    <w:p><w:t>&xxe;</w:t></w:p>
  </w:body>
</w:document>
Enter fullscreen mode Exit fullscreen mode

When your document processor extracts text to index it for search or display a preview, it exposes your environment variables.

How to Fix XXE: Secure Configuration

The most effective defense is disabling external entity processing entirely. Unless you specifically need DTDs, and you probably don't, turn them off.

The reason this works: you're removing the dangerous functionality at its source. If the parser can't resolve external entities, all three attack vectors we discussed become impossible. The parser will either skip the entities or throw an error, but it won't make network requests or read files.

Let's walk through secure configurations for different languages and why each setting matters.

Java with DocumentBuilderFactory

Java's XML parsing ecosystem is complex because there are multiple parsers (DOM, SAX, StAX) and multiple ways to configure them. DocumentBuilderFactory is the most common for building DOM parsers.

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();

// Option 1: Disable DTDs entirely (most secure)
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);

// Option 2: If you must support DTDs, disable external entities
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);

// Disable entity expansion to prevent billion laughs attacks
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);

DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(xmlInput);
Enter fullscreen mode Exit fullscreen mode

Breaking down each setting: Setting disallow-doctype-decl to true is the nuclear option. It completely disables DTD processing, which means the parser will throw an error if it encounters <!DOCTYPE>. This is the most secure configuration because it eliminates the entire attack surface. Use this unless you have a specific business requirement for DTD validation.

Setting external-general-entities to false means general entities (the ones you reference with &entityName;) won't fetch external resources. The parser will still process internal entities defined within the document itself, but it won't make network requests or read files.

Setting external-parameter-entities to false affects parameter entities (referenced with %entityName;) which are used within DTDs themselves. They're less commonly exploited but can still be dangerous. Disabling them prevents attackers from using DTDs to pull in external DTD fragments.

Setting XIncludeAware to false disables XInclude, which is a separate W3C standard for including external XML documents. It uses <xi:include> elements instead of entities, but it has the same security implications. Disabling it prevents another vector for including external content.

Setting ExpandEntityReferences to false tells the parser not to expand entity references at all. Combined with the other settings, it provides defense in depth.

For SAX parsers:

SAXParserFactory factory = SAXParserFactory.newInstance();
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);

SAXParser parser = factory.newSAXParser();
Enter fullscreen mode Exit fullscreen mode

SAX parsers are event-driven, meaning they call your code as they encounter elements rather than building a full document tree in memory. They're often used for large documents, but they have the same XXE vulnerabilities.

Python with defusedxml

Python's standard library includes multiple XML parsing modules like xml.etree.ElementTree, xml.dom.minidom, and xml.sax, and all of them are vulnerable to XXE by default. The Python documentation even includes warnings about this.

The defusedxml library is a wrapper around these standard modules that applies secure defaults:

# Don't use this - vulnerable to XXE
# from xml.etree import ElementTree

# Use this instead
from defusedxml import ElementTree

tree = ElementTree.parse('data.xml')
root = tree.getroot()
Enter fullscreen mode Exit fullscreen mode

What defusedxml does under the hood: It disables DTD processing, external entity resolution, XInclude processing, and entity expansion beyond a safe limit.

The beautiful part: it's a drop-in replacement. You change one import line and your code is secure. The API is identical to the standard library.

Why this matters: Python makes it extremely easy to accidentally create XXE vulnerabilities because the vulnerable behavior is the default. You have to actively opt into security with the standard library. Defusedxml eliminates this complexity. Install it with pip install defusedxml and use it everywhere you process XML.

For lxml, a popular alternative XML library:

from lxml import etree

parser = etree.XMLParser(
    resolve_entities=False,  # Don't resolve external entities
    no_network=True,         # Disable all network access
    dtd_validation=False,    # Don't validate against DTDs
    load_dtd=False           # Don't load external DTDs
)

tree = etree.parse('data.xml', parser)
Enter fullscreen mode Exit fullscreen mode

lxml is faster than the standard library and has more features, but it requires explicit security configuration. The settings above disable all the dangerous functionality.

Node.js with libxmljs

Node.js has several XML parsing libraries. libxmljs is a popular choice that binds to the C library libxml2. It's fast but requires careful configuration:

const libxmljs = require('libxmljs');

const doc = libxmljs.parseXml(xmlString, {
  noent: false,  // Disable entity substitution
  nonet: true    // Prevent network access
});
Enter fullscreen mode Exit fullscreen mode

Understanding these options: noent stands for "no entity substitution." Setting it to false means entity substitution is disabled. This is confusing naming because it's a double negative, but it's how libxml2 works. When disabled, the parser won't replace &xxe; with external content.

The nonet option explicitly prevents all network access. Even if entity substitution is enabled, the parser won't make HTTP requests. This is defense in depth.

Alternative: xml2js is a pure JavaScript parser:

const xml2js = require('xml2js');

const parser = new xml2js.Parser({
  explicitCharkey: true,
  // xml2js doesn't support external entities by default
  // It's safer because it's pure JavaScript (no C bindings)
});

parser.parseString(xmlString, (err, result) => {
  if (err) {
    // Handle parsing error
    return;
  }
  // Process result
});
Enter fullscreen mode Exit fullscreen mode

xml2js is a pure JavaScript implementation, which means it doesn't have the same feature set as libxml2-based parsers. Importantly, it doesn't support external entities at all not because of security configuration, but because the feature was never implemented. This makes it inherently safer for untrusted input, though it's slower for large documents.

PHP with libxml

PHP uses libxml2 for XML parsing, the same C library that Node.js uses. It has a global setting that affects all XML operations:

// Disable external entity loading globally
libxml_disable_entity_loader(true);

$dom = new DOMDocument();
// The LIBXML_NONET flag prevents network access
$dom->loadXML($xmlString, LIBXML_NONET);
Enter fullscreen mode Exit fullscreen mode

Why global configuration matters in PHP: libxml_disable_entity_loader(true) affects every XML operation in your PHP process. This is both good and bad. Good because you can't accidentally forget to secure one XML parser. Bad because if any library or dependency needs external entities, it will break. In practice, most applications don't need external entities, so setting this globally is the right approach.

Additional libxml options:

// Load XML with multiple security flags
$dom = new DOMDocument();
$dom->loadXML($xmlString, 
    LIBXML_NONET |      // Disable network access
    LIBXML_NOENT |      // Disable entity substitution
    LIBXML_DTDLOAD |    // Don't load external DTDs
    LIBXML_DTDATTR      // Don't default attributes from DTDs
);
Enter fullscreen mode Exit fullscreen mode

These flags work as bitwise options combined with the pipe operator to layer multiple protections.

The pattern across all languages is consistent: you're telling the parser to ignore DTDs and external references. Once configured, XXE becomes impossible because the parser simply won't execute the dangerous operations.

Defense in Depth: Additional Layers

Disabling external entities is your primary defense, but secure systems use multiple layers. If a developer accidentally uses an insecure parser or if you need to support legacy systems, these additional protections can prevent exploitation.

Input Validation with XML Schemas

If your application only accepts XML with a specific structure, which is common in APIs, enforce that structure before parsing anything. XML Schema (XSD) lets you define exactly what your XML should look like:

<!-- schema.xsd -->
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
  <xs:element name="order">
    <xs:complexType>
      <xs:sequence>
        <xs:element name="id" type="xs:string"/>
        <xs:element name="amount" type="xs:decimal"/>
        <xs:element name="customer" type="xs:string"/>
      </xs:sequence>
    </xs:complexType>
  </xs:element>
</xs:schema>
Enter fullscreen mode Exit fullscreen mode

This schema says: "An order must have an id (string), amount (decimal), and customer (string), in that order. Nothing else is allowed."

Validate before parsing:

from lxml import etree

# Load your schema
schema_doc = etree.parse('schema.xsd')
schema = etree.XMLSchema(schema_doc)

# Create a parser that enforces the schema AND disables dangerous features
parser = etree.XMLParser(
    schema=schema,
    no_network=True,
    resolve_entities=False
)

try:
    doc = etree.parse('input.xml', parser)
    # If we get here, the XML is valid and safe
except etree.XMLSyntaxError as e:
    # Validation failed - reject the input
    print(f"Invalid XML: {e}")
except etree.DocumentInvalid as e:
    # XML is well-formed but doesn't match schema
    print(f"Schema validation failed: {e}")
Enter fullscreen mode Exit fullscreen mode

Why schema validation helps: It rejects unexpected structure early. If your schema doesn't include DTD declarations, any document with <!DOCTYPE> fails validation before the parser processes it. It limits what attackers can send. Even if they bypass your entity protections, they can't include arbitrary elements that might trigger other vulnerabilities in your application logic. It catches malformed attacks. Many XXE payloads will violate your schema's structural requirements, failing validation before reaching the vulnerable code.

Real-world example:

<!-- Attacker's XXE payload -->
<!DOCTYPE order [
  <!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<order>
  <id>&xxe;</id>
  <amount>100.00</amount>
  <customer>John Doe</customer>
</order>
Enter fullscreen mode Exit fullscreen mode

If your schema says id must be a string with max length 20 characters, and /etc/passwd is 1,500 characters, the validation fails. The content never reaches your application logic.

Caveat: Schema validation alone doesn't protect you. Older XML validators might resolve entities during validation. Always combine schema validation with secure parser configuration.

Sanitize File Uploads

For file types that contain XML like SVG and Office documents, apply extra scrutiny. The challenge here is that you often need to process these files you can't just store them as opaque blobs.

For SVG files, SVGs are particularly dangerous because they're images that contain executable code (JavaScript via <script> tags) and can reference external resources. Use a library specifically designed for sanitization:

const DOMPurify = require('isomorphic-dompurify');

function sanitizeSVG(svgContent) {
  // DOMPurify removes dangerous elements while preserving valid SVG
  return DOMPurify.sanitize(svgContent, {
    USE_PROFILES: { svg: true, svgFilters: true },
    ADD_TAGS: ['use'],  // Allow specific SVG features you need
    ADD_ATTR: ['href']  // Allow specific attributes
  });
}

// Process only the sanitized version
const uploadedContent = req.file.buffer.toString();
const cleanSVG = sanitizeSVG(uploadedContent);

// Now you can safely parse it
const doc = libxmljs.parseXml(cleanSVG, { noent: false, nonet: true });
Enter fullscreen mode Exit fullscreen mode

What DOMPurify removes: <!DOCTYPE> declarations and DTDs, external entity references, <script> tags for JavaScript in SVG, event handlers like onload="malicious()", <foreignObject> elements which can embed HTML with scripts, and references to external stylesheets that could leak data.

The result is a clean SVG that only contains drawing instructions.

For Office documents:

import zipfile
from defusedxml import ElementTree
from io import BytesIO

def extract_text_from_docx(file_path):
    """
    Safely extract text from a .docx file.
    """
    try:
        with zipfile.ZipFile(file_path, 'r') as docx:
            # Read the main document XML
            xml_content = docx.read('word/document.xml')

            # Parse with defusedxml (secure by default)
            root = ElementTree.fromstring(xml_content)

            # Extract text (namespace handling omitted for brevity)
            paragraphs = root.findall('.//{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t')
            text = ' '.join([p.text for p in paragraphs if p.text])

            return text
    except zipfile.BadZipFile:
        raise ValueError("Invalid document file")
    except ElementTree.ParseError:
        raise ValueError("Malformed XML in document")
Enter fullscreen mode Exit fullscreen mode

Why this is safer: We're explicitly reading only word/document.xml, not processing arbitrary files from the ZIP. We're using defusedxml which won't resolve external entities. We're only extracting text content, not executing any macros or scripts. We catch parsing errors and treat them as invalid uploads.

Alternative approach: Don't parse at all. If your use case is just storage and download, like in a document management system, the safest approach is:

// Just store the file
await s3.putObject({
  Bucket: 'uploads',
  Key: `documents/${userId}/${filename}`,
  Body: fileBuffer,
  ContentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  ContentDisposition: 'attachment' // Force download, not inline display
});

// Serve it back with security headers
res.set({
  'Content-Type': 'application/octet-stream',  // Generic binary
  'Content-Disposition': `attachment; filename="${filename}"`,
  'X-Content-Type-Options': 'nosniff',  // Don't MIME-sniff
  'Content-Security-Policy': "default-src 'none'"  // No script execution
});
Enter fullscreen mode Exit fullscreen mode

By not parsing the file, you avoid the entire vulnerability. Let users download files and open them in desktop applications where they control the security environment.

Network-Level Controls

Even with secure parsers, add network restrictions as a last line of defense. This protects you if a developer adds a new dependency with a vulnerable parser, a zero-day vulnerability is discovered in your XML library, or an attacker finds a way to bypass your parser configuration.

Firewall rules:

# Example iptables rule (Linux)
# Block outbound requests from application servers to internal networks

iptables -A OUTPUT -d 169.254.169.254 -j REJECT  # Block AWS metadata service
iptables -A OUTPUT -d 10.0.0.0/8 -j REJECT       # Block private network (10.x.x.x)
iptables -A OUTPUT -d 172.16.0.0/12 -j REJECT    # Block private network (172.16-31.x.x)
iptables -A OUTPUT -d 192.168.0.0/16 -j REJECT   # Block private network (192.168.x.x)
Enter fullscreen mode Exit fullscreen mode

This prevents your application servers from making requests to internal resources, even if XXE somehow succeeds. The parser tries to fetch http://169.254.169.254, but the request is blocked at the network layer.

Cloud security groups (AWS example):

# Security Group configuration
Outbound:
  - Protocol: TCP
    Port: 443
    Destination: 0.0.0.0/0
    Description: "HTTPS to internet only"

  # Explicitly deny internal ranges
  - Protocol: ALL
    Port: ALL
    Destination: 10.0.0.0/8
    Action: DENY
Enter fullscreen mode Exit fullscreen mode

Cloud platforms let you define security groups that act as virtual firewalls. Configure your application servers to only allow outbound HTTPS to the public internet, not to internal services.

IMDSv2 (Instance Metadata Service version 2) for AWS. AWS introduced IMDSv2 to make SSRF attacks harder:

# IMDSv2 requires a session token
# Step 1: Get a token (requires PUT request with headers)
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

# Step 2: Use the token to access metadata
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/
Enter fullscreen mode Exit fullscreen mode

XXE attacks can't do PUT requests or set custom headers, so they can't get the token. Enable IMDSv2 on all EC2 instances:

aws ec2 modify-instance-metadata-options \
  --instance-id i-1234567890abcdef0 \
  --http-tokens required \
  --http-put-response-hop-limit 1
Enter fullscreen mode Exit fullscreen mode

Allowlists for legitimate external resources. If your application legitimately needs to fetch external XML, like RSS feeds, use an allowlist:

ALLOWED_DOMAINS = [
    'rss.example.com',
    'feeds.partner.com'
]

def is_allowed_url(url):
    from urllib.parse import urlparse
    domain = urlparse(url).netloc
    return domain in ALLOWED_DOMAINS

# In your XML entity resolver
class SecureEntityResolver(etree.Resolver):
    def resolve(self, url, id, context):
        if not is_allowed_url(url):
            # Log the attempt
            logger.warning(f"Blocked XXE attempt to: {url}")
            # Return empty content
            return self.resolve_string("", context)

        # Allow only specific resources
        return None  # Use default resolution
Enter fullscreen mode Exit fullscreen mode

This provides defense in depth. Even if entities are enabled, only pre-approved domains can be accessed.

Testing Your Application

To verify your defenses work, test with actual XXE payloads in a safe environment. Never test on production.

Create a test server to receive callbacks:

# Start a simple HTTP server to detect SSRF attempts
python3 -m http.server 8000
Enter fullscreen mode Exit fullscreen mode

Test file disclosure:

curl -X POST http://localhost:3000/api/parse \
  -H "Content-Type: application/xml" \
  -d '<!DOCTYPE test [<!ENTITY xxe SYSTEM "file:///etc/hostname">]><data>&xxe;</data>'
Enter fullscreen mode Exit fullscreen mode

Expected result if properly secured: The application either rejects the document or returns <data>&xxe;</data> literally (entity not expanded).

Vulnerable result: The application returns the contents of /etc/hostname.

Test SSRF:

curl -X POST http://localhost:3000/api/parse \
  -H "Content-Type: application/xml" \
  -d '<!DOCTYPE test [<!ENTITY xxe SYSTEM "http://your-test-server:8000/xxe-test">]><data>&xxe;</data>'
Enter fullscreen mode Exit fullscreen mode

Watch your test server logs. If you see a request to /xxe-test, your application is vulnerable the XML parser made an outbound HTTP request.

Test denial of service, but be careful with this:

curl -X POST http://localhost:3000/api/parse \
  -H "Content-Type: application/xml" \
  -d '<!DOCTYPE lolz [<!ENTITY lol "lol"><!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;"><!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">]><lolz>&lol3;</lolz>'
Enter fullscreen mode Exit fullscreen mode

Expected result: The request is rejected or times out gracefully.

Vulnerable result: The application hangs or crashes with out-of-memory errors.

Automated testing with OWASP ZAP or Burp Suite. These tools have built-in XXE detection:

# OWASP ZAP active scan includes XXE checks
docker run -t owasp/zap2docker-stable zap-baseline.py \
  -t http://localhost:3000 \
  -r report.html
Enter fullscreen mode Exit fullscreen mode

Burp Suite Professional has XXE scanning in its active scanner. It will automatically test various payloads and entity expansion techniques.

When You Can't Disable XML

Some scenarios require XML processing with external entities. Legacy system integrations where you don't control the format and the vendor requires DTD validation. Standards compliance where some industry specifications require DTD support. Content management systems where users expect to use XInclude for modular documents.

These are rare, but they exist. In these cases, you need a different approach because completely disabling external entities breaks functionality.

Use a separate, isolated service. Run XML processing in a dedicated service with no access to sensitive resources.

The Bottom Line

XXE exists because XML parsers are powerful by default and most developers don't know how to lock them down. The vulnerability isn't complex, it's a dangerous default configuration that's been around for decades.

Disable external entities in your XML parser configuration. This is non-negotiable for any parser processing untrusted input. Validate input structure before parsing using XML schemas to enforce exactly what your application expects. Treat any XML from untrusted sources as dangerous, including API inputs, file uploads, data from partners, and anything users can control. For file uploads containing XML like SVG and Office documents, sanitize or avoid parsing entirely. Add network-level controls as defense in depth by blocking access to metadata services and internal networks from application servers.

XXE is completely preventable. The vulnerability only exists when developers don't configure their parsers securely or don't realize they're processing XML. Test your applications, review your dependencies, and make secure XML parsing the default in your codebase.

Top comments (0)