Skip to content
datahacks.dev
XML

XML Security Problems to Close Before Production Launch

The XML attacks that matter for web services and loaders, how six parsers behave, one parse function with limits, and the tests that keep it closed.

18 min read
Summarize in AI
Flat illustration of paired angle-bracket tags wrapped around a document, with the XML logo.

XML has more ways to hurt a server than most data formats, and almost all of them are features. The format was designed to assemble big documents from parts, to fetch pieces by address, to define shorthand and to carry signed fragments, and every one of those is a path from a few bytes of input to something you did not intend. The attacks have names (XXE, billion laughs, XPath and XML injection, signature wrapping), they have been known for fifteen years, and they are still found in review after review, because the safe configuration is a set of options spread over a dozen libraries and nobody owns the list.

This guide is the list. It covers what each attack does, how six parsers behaved on the same hostile documents, one parse function that closes the common holes, the tests that keep it closed, and the output-side and signature-side problems that a safe parser does not fix. The spokes in this cluster go deeper on each piece and are linked as they come up.

The threat model in one paragraph

You run code that parses XML someone else wrote: an API request, an uploaded file, a feed you fetch, an SVG avatar, a Word document, a SOAP response, a partner's export. The sender is not trusted. They can choose the bytes. The goals they might have are to read a file or reach an internal address through your server, to make the server spend its memory and time until it fails, to make your application believe data that is not what it appears, or to make your XML say something you did not mean. Each goal has a family of attacks, and each has a control. None of the controls is expensive. The expensive thing is finding every place where XML enters and checking each one.

The attacks, in one table

AttackWhat the input doesWhat the parser doesControl
External entity (XXE)Declares an entity pointing at a file or URLReads the file or makes the requestRefuse DTDs, or disable external entities
Entity expansion (billion laughs)Nests entities so a few hundred bytes become gigabytesBuilds the expansionRefuse DTDs, keep amplification limits
Deep nestingThousands of levels of elementsAllocates stack or depthDepth cap
Huge node or widthOne 100 MB text node, or millions of siblingsAllocates memoryByte cap, element cap, streaming
Injection into outputUser text contains markupNot the parser: your templateBuild with a library, escape
Signature wrappingRearranges a signed documentVerifies one node, the app reads anotherVerify then use the verified node
Remote schema and stylesheet fetchesNames a schema, DTD or XSLT at a URLFetches itNo network, local copies only

Seven rows, and the first two disappear together if the parser refuses a DOCTYPE.

How six parsers behaved

The same three-line external entity document, pointing at the container's /etc/hostname, was parsed with each library at its defaults and then with the option people add to "make entities work".

ParserDefaultWith the common option
Java DocumentBuilderFactoryread the filestrict features block it
Python lxmlrefused (Entity 'x' not defined)resolve_entities=True read the file
Python ElementTreerefused (undefined entity)no option
PHP simplexml and DOMDocumentempty stringLIBXML_NOENT read the file
Node fast-xml-parser 5refused (External entities are not supported)none
Node xml2jsrefused (Invalid character entity)none

Three of the six read the file in some configuration, and in each case the option that opened it is one a developer would add in good faith: resolve_entities=True, LIBXML_NOENT, or in Java nothing at all. The mechanism and the per-language fixes are in what an XML external entity attack is, disabling DTDs and external entities in Java and Python and safe XML handling in PHP and Node. One finding to hold on to: a safe default is a property of a library version, not of the language, so you state the safe options explicitly and test them.

Denial of service by expansion, depth and size

The billion laughs file is 547 bytes and would expand to 30 million characters at seven layers. Every parser stopped it:

Text
lxml default                 XMLSyntaxError: Maximum entity amplification factor exceeded (0.00s)
ElementTree                  ParseError: limit on input amplification factor ... breached (0.11s)
Java                         JAXP00010001: more than "64000" entity expansions ... (133 ms)

The current libraries carry guards, and the guards are recent; older runtimes lack them, and a configuration that raises the limit "to make a big document work" removes them (stopping the billion laughs attack in your parser). Entities are not the only way to spend resources. Depth and size need their own caps, and the libraries disagree about them:

Inputlxml defaultlxml with huge_treeElementTree
257 levels deeprefused at 256acceptedaccepted
5,000 levels deeprefusedrefused at 2,048accepted
100,000 levels deeprefusedrefusedaccepted in 0.04 s
11 MB text noderefusedacceptedn/a
2,000,000 empty siblingsn/an/a329 MB peak (lxml)

ElementTree accepted a document nested 100,000 levels deep, and a recursive function over the result raised RecursionError, which is a small upload taking down a worker. A tree costs 15 to 40 times the file size in memory, which turns a byte cap into a memory budget: a 20 MB cap is up to 800 MB per request. The measurements and the sizing rule are in limiting XML size and depth for uploads.

One function that closes the common holes

The controls above can be written once and used everywhere XML enters. This is the version I use in Python for untrusted input, and the output of running it on seven inputs:

safe_parse.pyPython
MAX_BYTES = 1_000_000
MAX_DEPTH = 100
MAX_ELEMENTS = 100_000

class Rejected(Exception):
    pass

def parse_untrusted(data: bytes):
    if len(data) > MAX_BYTES:
        raise Rejected(f"larger than {MAX_BYTES} bytes")
    if b"<!DOCTYPE" in data[:4096].upper():
        raise Rejected("DOCTYPE not accepted")
    parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False,
                             dtd_validation=False, huge_tree=False)
    depth = count = 0
    try:
        for ev, el in etree.iterparse(io.BytesIO(data), events=("start", "end"), resolve_entities=False,
                                      no_network=True, load_dtd=False, huge_tree=False):
            if ev == "start":
                depth += 1; count += 1
                if depth > MAX_DEPTH: raise Rejected("nesting deeper than %d" % MAX_DEPTH)
                if count > MAX_ELEMENTS: raise Rejected("more than %d elements" % MAX_ELEMENTS)
            else:
                depth -= 1
    except etree.XMLSyntaxError as e:
        raise Rejected("not accepted: " + e.msg[:60])
    return etree.fromstring(data, parser)
Text
legit order      accepted <order>
external entity  rejected: DOCTYPE not accepted
billion laughs   rejected: DOCTYPE not accepted
deep nesting     rejected: nesting deeper than 100
5 MB payload     rejected: larger than 1000000 bytes
many elements    rejected: more than 100000 elements
not well-formed  rejected: not accepted: Opening and ending tag mismatch: b line 1 and a, line 1, col

Seven inputs, one accepted. Look at what each check does. The size check runs first, because it is the cheapest and needs no parsing. The DOCTYPE check looks in the first 4 KB for the declaration and rejects it, which stops both entity attacks with one rule (a crude test, and the parser's own settings are the second layer). The parser is configured with every dangerous option off. A streaming pass counts depth and elements, so the limits hold for any document without building a tree first. Only then is the tree built. Each rejection carries a reason that can be logged and returned to the sender. The pass reads the document twice, once to count and once to build, which costs a little time and buys a bound on memory before the expensive step.

The same shape works in other languages: in Java, a factory helper that sets disallow-doctype-decl, secure processing and the access properties, with a stream wrapper that counts bytes; in PHP, LIBXML_NONET plus a check on $doc->doctype and a size check on the request; in Node, a parser that rejects declarations (as fast-xml-parser 5 does) behind a body-size limit. Put the function in one module and ban direct calls to the raw parsers in code review or a lint rule.

Tests that keep it closed

A control that has no test is a control that quietly disappears in the next dependency update. The test suite for XML input is short:

  • The external entity file must be rejected, and no data from the target may appear in any output.

  • The billion laughs file must be rejected quickly (assert on time as well as on the error).

  • A document nested past your limit must be rejected.

  • A document just under and just over the byte cap must be accepted and rejected.

  • A legitimate document with the features you do need must still parse. A parser locked so tight that it rejects real input gets loosened by the first person it blocks.

Run the suite against the actual entry points, not only against the helper: the controller that receives the upload, the importer that reads the feed. Entry points that do not go through the helper are the finding.

The output side

Everything above is about reading XML. Writing it has a mirror problem. A name typed as Bob</name><role>admin</role><name>x, put into a template with an f-string, produced a user document with two role elements, admin first, and a value in an attribute that contained a quote and a space added an attribute of its own. Escaping the text, or building the tree with a library, made both harmless. A bell or a null character is not legal in XML 1.0 at all, and lxml refuses it where a template writes it into the file. The details, including the ]]> problem in CDATA sections, are in sanitizing data you put into an XML document. The rule is the one from SQL: never build structured text by pasting strings.

Signatures

If your application verifies XML signatures (SAML single sign-on, signed invoices), the attack surface includes the verifier's logic as well as the parser. In a toy example with an HMAC, the verifier looked up the signed element by id and the application read the first amount in the document. An attacker moved the signed element into a wrapper and put a forged one in its place: verification returned true and the application read 9999 where the genuine message said 10. The fix is to bind use to verification: read the value from the node that was verified, reject duplicate ids and validate the structure against a schema. See XML signature wrapping in plain language. Use a vetted library for the real standard, with strict settings, and keep it updated.

Fetching things the document names

Documents can name other documents: a DTD, a schema through xsi:schemaLocation, a stylesheet through a processing instruction, an XInclude, a document() call inside an XSLT stylesheet. A processor that fetches them lets the sender direct your server's network requests. The controls are to disable network access in the parser (no_network=True, LIBXML_NONET, --nonet in xmllint and xsltproc), to ship schemas and stylesheets with your code and refer to them by local path, and never to run a stylesheet you did not write on data you care about. The validation article covers the local-schema pattern: validating XML against an XSD schema.

Where XML hides

The expensive finding in a review is the parser nobody knew was there. Places XML enters that are not called "XML upload":

  • Images and documents. SVG avatars and logos, Office files (zip archives of XML parts), ODF, EPUB, Visio, KMZ. See common XML formats you will meet in real work for which formats are zips of XML.

  • Protocols. SOAP, SAML, WS-Security, XML-RPC, WebDAV, XMPP, RSS and Atom feeds your server fetches, OPML imports.

  • Configuration. Build files and application descriptors from third-party packages.

  • Libraries. A PDF or image library that parses XMP metadata, a spreadsheet library, a SOAP client. They bundle their own parsers, with their own defaults.

Make an inventory: for each, which parser, which version, which options. The list is short once written down, and it is the best use of an hour in this whole topic.

Order of work

If you have an afternoon, do these in order.

  1. Search the codebase for the dangerous options: resolve_entities=True, LIBXML_NOENT, LIBXML_DTDLOAD, huge_tree=True, setExpandEntityReferences(true), raised entity limits.

  2. Find every place XML is parsed and route it through one helper per language.

  3. Add the size cap at the web server and in the helper.

  4. Add the attack files to the test suite.

  5. Review output: any XML built from strings with user data.

  6. Review signature verification if you have any.

  7. Put parsers for untrusted input in workers with memory and time limits.

Reading the hostile documents

The test inputs are worth reading line by line, because each one is the smallest document that demonstrates its attack, and recognizing them in a log or a bug report is half the skill.

The external entity file has three lines of substance. The first declares a document type with an internal subset, the square-bracket section. Inside it, <!ENTITY x SYSTEM "file:///etc/hostname"> defines an entity named x whose replacement text comes from a system identifier, here a file URL. The third line puts &x; in an element. Nothing else is needed. Variants swap the file: scheme for http: to reach a server, or for php://filter and jar: in stacks that support those schemes. A DOCTYPE that you did not expect in a payload is the signature of all of them.

The billion laughs file is a staircase. The first entity is three letters. Each following entity is ten references to the previous one. No entity is large; the document is 547 bytes. The expansion happens when the parser substitutes, which is why attacks of this kind are called entity expansion. The "quadratic blowup" cousin defines one entity of a few kilobytes and references it tens of thousands of times in the body, which expands to hundreds of megabytes without any nesting, and it passes guards that look for deep chains. Refusing the DOCTYPE ends both.

The deep file has no entities. It is <a> repeated and then </a> repeated. It is legal, it is small, and it breaks whatever recurses.

The wide and the long are legal too: two million siblings or one 60 MB text node. They need no tricks at all.

The wrapped message is the genuine message plus one rearrangement, with every signed byte untouched.

Reading them this way also shows why the parse function has four checks. They are four different attacks, and no parser option stops all four.

What the controls cost

The usual objection to checking is speed, so I measured it. A document of 683 KB with 24,001 elements (8,000 orders with a customer and an item each), best of seven runs:

Text
plain etree.fromstring     6.1 ms
parse_untrusted (checked)  29.5 ms
size + DOCTYPE check only  0.003 ms

The checked function took 29.5 milliseconds against 6.1 for a plain parse, about five times as long, because it makes a streaming pass in Python to count depth and elements before it builds the tree. The two cheap checks, size and DOCTYPE, cost three microseconds. For an API that receives documents of a few kilobytes the overhead is not measurable, and for a batch endpoint that receives a few thousand a day it is seconds in total. If it matters, there are cheaper ways to get the same protection: use the parser's own depth limit (which lxml enforces at 256 by default), lower MAX_ELEMENTS and drop the counting pass, or count in a SAX handler that runs as part of the real parse. The point of the number is that the checks are not where your time goes. A parse that is five times slower and cannot be crashed by a stranger is a good trade, and an hour-long investigation of an outage is a bad one.

Why defaults differ and keep changing

Every item in the table of parsers has a history. Entities and external references are in the XML specification from the beginning, and early parsers implemented them fully because the spec said they could, and nobody was parsing hostile input. As XML moved onto the public web, the attacks followed, and libraries responded one at a time: first with options to turn the features off, then with defaults that turn them off, then with limits for the denial of service variants. Java's defaults stayed permissive for longest, which is why most Java advisories say "set these features". libxml2 changed its defaults several times, and the PHP flag LIBXML_NOENT lost its safety property when the underlying library stopped resolving external entities unless asked.

Two consequences. First, an old runtime is a different parser than the one in this article: a PHP 7 on an old libxml2, a Java 6 application server, a vendored Python of unknown age. Test the one you run. Second, defaults will keep changing, in both directions: libraries add guards, and some break compatibility by adding them, so someone opens a limit to restore the old behavior. Your explicit settings and your tests are what survive that.

Logging and alerting

A rejected document is information. A parser that quietly returns an error to the client and logs nothing throws it away. What to record for each rejection: the endpoint, the authenticated principal or source address, the size of the body, the reason code (DOCTYPE, depth, size, element count, not well-formed) and a short hash of the body, so repeated attempts can be matched without storing the content. Do not log the body itself without thought; it may carry personal data, and a hostile body in a log file is a hazard to whatever reads logs.

Count rejections by reason and source. A malformed document from a customer is normal at a low rate, and one in a thousand uploads failing the well-formedness check is a data quality issue. A DOCTYPE rejection is different in kind. Almost no legitimate client sends one, so even a single occurrence on an endpoint that has never had one is worth a look, and a burst from one source is an attack in progress. Alert on the reason code, not on the total.

When you find a hole

The usual path to discovery is a scanner, a bug report or a code review. The response is the same each time:

  1. Confirm with the smallest document. Reproduce with the three-line file, in the real service, not a script. Record exactly what came back.

  2. Close it at the entry point with the helper and the explicit options, and ship the test that failed first.

  3. Look for the sibling. If one endpoint used a raw parser, others may. Search for the parser calls, not for the endpoint name.

  4. Ask what could have been read. An XXE reads files as the service user. Check what that user could see (configuration, keys, environment files) and rotate secrets that could have been exposed. Check logs for the markers: requests with a DOCTYPE, and outbound connections to unexpected hosts.

  5. Reduce what the process can see. Run the service with least privilege, no access to key material it does not use, and egress limited to the hosts it needs. Those are what turn a successful attack into a small incident.

A review checklist

When I review a service that handles XML, I go through this list and expect a "yes" to each line.

  • Every parser for untrusted input is created by one helper.

  • The helper refuses a DOCTYPE, disables external entities, DTD loading and network access, and states each option explicitly.

  • A byte cap applies at the web server and in the helper, and a depth and element cap in the helper or the parser.

  • Parsing of untrusted input happens in a process with a memory limit and a timeout.

  • There are tests for the external entity, the billion laughs, the deep and the oversized documents, and the tests run against the real entry points.

  • XML written by the service is built with a library, and values are escaped for their context.

  • Signature verification, if any, uses a library, reads data from the verified node and validates the structure.

  • Schemas and stylesheets are local files and are not fetched.

  • Third-party libraries that parse XML (images, documents, protocols) are listed with their versions.

  • Rejections are logged with a reason code and alerted on by reason.

Any "no" is an action item, and the cheapest ones are the first three.

Containing the damage

Parser settings are the first line and the process around the parser is the second. A control that fails, or an attack nobody has described yet, should land on a process that can do little harm. Three habits make that true without much effort.

Run the parse where a crash is cheap. A worker process or a container with a memory limit and a CPU time limit turns an expansion or a deep-recursion attack into one killed job. Queue-based designs get this nearly free: the web request writes the upload to storage and enqueues a job, and the worker parses it, with a restart policy and a retry limit so a poison document is retried twice and set aside, not forever.

Give the process nothing to steal. An XXE reads what the service user can read. If the parser runs as a user that cannot read the application's environment file, the database credentials or the private key, a successful read returns less. Mount only the directories the job needs, keep secrets in a store that the parsing worker has no token for, and avoid running the parser in the same process as code that holds long-lived credentials.

Limit where it can connect. A server-side request forgery needs the server to reach an interesting address. A parsing worker that can only talk to the queue and the storage it needs cannot be turned into a scanner of your internal network, and cannot reach the cloud metadata address that hands out temporary credentials. Egress rules at the network level are a control that holds when every software control has failed.

None of these replaces the parser settings, and each makes the day a setting is missed less expensive. They also have a nice property for review: they are the same whatever the language, so one deployment pattern covers the Java importer, the Python worker and the Node gateway.

The spokes in this cluster: what an XML external entity attack is, stopping the billion laughs attack in your parser, disabling DTDs and external entities in Java and Python, safe XML handling in PHP and Node, XML signature wrapping in plain language, limiting XML size and depth for uploads and sanitizing data you put into an XML document. Related: safe XML parsing in Python with defusedxml and fixing XML parse errors fast.

The reference documents are the OWASP XML External Entity Prevention Cheat Sheet, CWE-611 and CWE-776, the Python documentation's XML vulnerabilities table, and the XML 1.0 specification for what entities are meant to do. Test a path in the XPath tester.

When XML security is not the first problem

The service has no authentication. Parser hardening on an open endpoint is a locked inner door in a house without walls. Authenticate, rate-limit and log first.

The data is internal and the producers are known. The exposure is small. It is still cheap to close, and internal feeds have a habit of acquiring external sources.

You can replace XML. If you control both ends and have no need for namespaces, schemas or signatures, JSON removes the entity and DTD machinery entirely, at the price of its own hazards (deep nesting, duplicate keys, prototype pollution).

The risk is in the logic, not the format. Authorization checks that trust a field in the document, or an import that overwrites records by id without checking who owns them, are application bugs that happen to arrive as XML. The parser settings will not find them.

Also inSecurity
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.