Web Security (OWASP Top 10:2025) Module 3 – A02:2025 Security Misconfiguration
XML parsing safely: XXE and entity expansion
How a DOCTYPE can make an XML parser read files or multiply text, what Python's parsers do by default, and how to refuse DTDs in data-only XML.
What you will learn
- Explain how a DOCTYPE, its entities and external references can make a parser read files, fetch URLs or expand text
- Compare what Python's standard-library parsers do with external and internal entities, and check the Expat version
- Configure a parser that refuses any DOCTYPE in data-only XML, and prove it with a canary test
- Recognise uploads and integrations that are XML underneath, such as SVG, DOCX, feeds and SAML
- Choose JSON or another format without entities when you control both ends
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how a DOCTYPE, its entities and external references can make a parser read files, fetch URLs or expand text
- Compare what Python's standard-library parsers do with external and internal entities, and check the Expat version
- Configure a parser that refuses any DOCTYPE in data-only XML, and prove it with a canary test
- Recognise uploads and integrations that are XML underneath, such as SVG, DOCX, feeds and SAML
- Choose JSON or another format without entities when you control both ends
Before you start
On this page
A 116-byte XML upload can name a file on your server, and a parser with one feature switched on reads that file into the data it returns. In this lesson’s test, a weak order importer returned the line of a canary file from its own folder for two of four documents, while the fixed one refused every document with a DOCTYPE before reading a single element.
You will see what a DOCTYPE lets a document ask for, compare what Python’s standard parsers do by default, refuse DTDs in data-only XML and prove it with a canary test, recognise the uploads that are XML underneath, and choose a format without entities when you can.
A DOCTYPE lets a document give the parser instructions
An XML document may begin with a DOCTYPE, which holds or names a DTD: A document type definition: rules for an XML document's structure, written in a DOCTYPE at its top or in a file it names. A DTD can also declare entities, which is what makes it risky in data from outside.: rules for the document’s
structure. A DTD may also declare an entity: In XML, a named piece of text declared in a DTD and used as &name; in the document. An internal entity holds its text; an external entity names a file or URL that a parser may fetch to get it., a name such as n that the document then
uses as &n;. An internal entity carries its own text. An external entity names a file or a URL, and a parser that
resolves it fetches what the name points to and pastes it into the document. Follow one upload whose DOCTYPE names a
file:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A DOCTYPE first. The upload begins with a DOCTYPE whose internal subset declares an entity, n, as SYSTEM "canary.txt". That is an external entity, a reference to text kept somewhere else.
- The reference. Inside the order, the note element holds &n;. A parser that resolves external entities now has to fetch the text the declaration names.
- The parser reads a file. The SYSTEM identifier is a relative URL, so the parser opens canary.txt in the importer's own folder, a file the upload was never meant to see.
- The text comes back. The file's line is substituted for &n;, as if it had been part of the upload. The order now carries text from the server's disk.
- The app shows it. The importer returns the note as data, and whatever displays or stores the order now exposes the file's content. The test's canary is how you notice before anyone else does.
- The fix refuses early. The fixed importer gives ElementTree a target whose doctype() raises. The parser calls it at the DOCTYPE, before any entity is declared or element read, so no file is ever opened.
That is XXE: XML external entity injection (CWE-611): a parser that resolves external entities reads a file or fetches a URL that an uploaded document names, and puts what it got into the data the app uses., CWE-611, and MITRE’s entry lists its consequences: files read from the server, unwanted requests made by the server, and a parser that runs out of memory or time. Entities may also refer to other entities, many times over, which is entity expansion: Entities that refer to other entities many times, so that a few hundred bytes of input expand into millions of characters and use up memory and time (CWE-776). Expat limits the growth; refusing DTDs removes it., CWE-776. OWASP’s Top 10:2025 counts both under A02 Security Misconfiguration, because they are settings of the parser, not bugs in your code:
| A DOCTYPE may declare | A parser that resolves it |
|---|---|
An internal entity, <!ENTITY n "text"> |
pastes the text, and can multiply it many times |
An external entity, SYSTEM "…" |
opens the file or fetches the URL it names |
| An external DTD or parameter entities | fetches more declarations, which may declare more entities |
Key idea
The DOCTYPE is the part of an XML document that can make a parser read files, fetch URLs and multiply text, and data that arrives from outside rarely needs it.
What do Python’s standard parsers do with entities?
Every XML parser in Python’s standard library is built on Expat, a C library that, as Python’s documentation notes,
opens no files and makes no connections by itself. What they do with entities still differs. This program runs each
of them on an external entity that names canary.txt, a made-up file in its own folder, on a small internal entity,
and on a 399-byte document whose six levels of ten references would expand to 5,000,000 characters:
# What the XML parsers of Python's standard library do with three test documents, with the Expat library this Python
# uses. The external entity names canary.txt, a made-up file in this example's own folder; a parser that prints its
# text has read a file the document named. The two other documents expand internal entities: a small one, and one
# whose six levels of ten references would expand to 5,000,000 characters from 399 bytes.
import io
import pyexpat
import xml.dom.minidom
import xml.dom.pulldom
import xml.etree.ElementTree as ET
import xml.sax
import xml.sax.handler
EXTERNAL = b'<?xml version="1.0"?>\n<!DOCTYPE order [<!ENTITY note SYSTEM "canary.txt">]>\n<order><note>¬e;</note></order>'
SMALL = b'<?xml version="1.0"?>\n<!DOCTYPE order [<!ENTITY co "Acme Ltd">]>\n<order><note>&co;</note></order>'
def nested(levels, fan=10):
entities = ['<!ENTITY e0 "laugh">'] + [f'<!ENTITY e{i} "{f"&e{i - 1};" * fan}">' for i in range(1, levels + 1)]
return f'<?xml version="1.0"?>\n<!DOCTYPE d [{"".join(entities)}]>\n<d>&e{levels};</d>'.encode()
DEEP = nested(6)
def with_etree(doc):
return "".join(ET.fromstring(doc).itertext())
def with_minidom(doc):
return "".join(n.data for n in xml.dom.minidom.parseString(doc).getElementsByTagName("*")[-1].childNodes if n.nodeType == n.TEXT_NODE)
def with_pulldom(doc):
return "".join(node.data for event, node in xml.dom.pulldom.parseString(doc.decode()) if event == xml.dom.pulldom.CHARACTERS)
class Text(xml.sax.handler.ContentHandler):
def __init__(self):
super().__init__()
self.parts = []
def characters(self, content):
self.parts.append(content)
def sax(external_entities):
def parse(doc):
handler, parser = Text(), xml.sax.make_parser()
parser.setContentHandler(handler)
if external_entities:
parser.setFeature(xml.sax.handler.feature_external_ges, True)
parser.parse(io.BytesIO(doc))
return "".join(handler.parts)
return parse
def outcome(parse, doc):
try:
text = parse(doc)
except Exception as error: # every parser raises its own kind of error; the message is what counts
message = error.getMessage() if isinstance(error, xml.sax.SAXParseException) else str(error)
return "refused: Expat's limit" if "amplification" in message else f"refused: {message.split(':')[0].split(' &')[0]}"
if "CANARY" in text:
return "READ canary.txt"
if doc is EXTERNAL:
return "not read"
return f"expanded to {len(text):,} chars"
PARSERS = [
("ElementTree.fromstring", with_etree),
("minidom.parseString", with_minidom),
("pulldom.parseString", with_pulldom),
("xml.sax, defaults", sax(False)),
("xml.sax, external_ges on", sax(True)),
]
print(f"{pyexpat.EXPAT_VERSION}; deep document: {len(DEEP)} bytes")
print(f"{'Parser':26} {'External entity':26} {'Small internal':22} Six levels deep")
for name, parse in PARSERS:
print(f"{name:26} {outcome(parse, EXTERNAL):26} {outcome(parse, SMALL):22} {outcome(parse, DEEP)}") Output
expat_2.8.5; deep document: 399 bytes Parser External entity Small internal Six levels deep ElementTree.fromstring refused: undefined entity expanded to 8 chars refused: Expat's limit minidom.parseString not read expanded to 8 chars refused: Expat's limit pulldom.parseString not read expanded to 8 chars refused: Expat's limit xml.sax, defaults not read expanded to 8 chars refused: Expat's limit xml.sax, external_ges on READ canary.txt expanded to 8 chars refused: Expat's limit
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stdlib_matrix.py
Only SAX with feature_external_ges switched on read the file. Python’s SAX documentation says it stopped processing
general external entities by default in 3.7.1, so the danger is code that switches the feature back on, for a
supplier’s DTD, say. ElementTree refused the reference as an undefined entity, and minidom, pulldom and default SAX
quietly left it out, an empty note with no error at all. Every parser expanded the small entity, and every parser
stopped the six-level document, because Expat itself refuses input that grows past its amplification limit.
The limit belongs to Expat, not to Python
The run used Expat 2.8.5. Python’s documentation warns that Expat versions lower than 2.7.2 may be open to entity
expansion, quadratic blowup and large tokens, and that Python may use its bundled copy or the system’s: print
pyexpat.EXPAT_VERSION in your diagnostics, and patch the image. From Python 3.14.6, xmlparser can also tune the
limit with SetBillionLaughsAttackProtectionMaximumAmplification().
Libraries outside the standard library have switches of their own. lxml, for example, stopped expanding external
entities by default in version 5.0 and does so again with resolve_entities=True, so search your dependencies for
such options. For untrusted XML in Python, OWASP’s cheat sheet recommends the parsing interfaces of defusedxml. In
other stacks the rule has the same shape: in Java’s JAXP, set the feature disallow-doctype-decl; in .NET, XmlReader
prohibits DTDs by default; with libxml2 2.9 and later, never pass XML_PARSE_NOENT or XML_PARSE_DTDLOAD for
untrusted input. OWASP also warns that XInclude and XSLT can load files without any DTD, so leave them off. ASVS
5.0.0 requirement 1.5.1 sums it up: XML parsers use a restrictive configuration, with external entity resolution
disabled.
Checkpoint
Check: What a DOCTYPE and its entities can do · Python's parsers and Expat's limit
Refuse a DOCTYPE in data-only XML, and prove it with a canary
Orders, invoices and settings are data. They have no use for a DTD, so the safest rule is the simplest: any document
with a DOCTYPE is refused, as OWASP’s cheat sheet advises wherever DTDs can be disabled. This program runs one
regression test against two importers: a weak one built on SAX with external entities switched on, and a fixed one
that gives ElementTree a target whose doctype() method raises. The canary is a one-line file beside the program:
CANARY-7f3a: a made-up line, no real secret # A regression test for an XML order import, run against a weak importer and a fixed one. Its documents are the kind
# your own tests should hold: an ordinary order, one whose DOCTYPE names canary.txt (a made-up file in this folder) as
# an external entity, one with an internal entity, and the external one again in UTF-16. The test fails when a parsed
# value contains the canary, or when a document with a DOCTYPE is accepted, because orders are data, not documents.
import io
import xml.etree.ElementTree as ET
import xml.sax
import xml.sax.handler
ORDER = b'<?xml version="1.0"?>\n<order id="A-1001"><note>gift wrap</note></order>'
EXTERNAL = b'<?xml version="1.0"?>\n<!DOCTYPE order [<!ENTITY n SYSTEM "canary.txt">]>\n<order id="A-1002"><note>&n;</note></order>'
INTERNAL = b'<?xml version="1.0"?>\n<!DOCTYPE order [<!ENTITY n "gift wrap">]>\n<order id="A-1003"><note>&n;</note></order>'
UTF16 = EXTERNAL.decode().replace('version="1.0"', 'version="1.0" encoding="UTF-16"').encode("utf-16")
CASES = [("ordinary order", ORDER), ("external entity", EXTERNAL), ("internal entity", INTERNAL), ("external, UTF-16", UTF16)]
class Order(xml.sax.handler.ContentHandler):
def __init__(self):
super().__init__()
self.order_id, self.note, self.in_note = None, "", False
def startElement(self, name, attrs):
self.order_id = attrs.get("id", self.order_id)
self.in_note = name == "note"
def characters(self, content):
if self.in_note:
self.note += content
def weak_import(data):
"""SAX with external entities switched on, "so that the supplier's DTD loads"."""
handler, parser = Order(), xml.sax.make_parser()
parser.setContentHandler(handler)
parser.setFeature(xml.sax.handler.feature_external_ges, True)
parser.parse(io.BytesIO(data))
return handler.order_id, handler.note.strip()
class NoDoctype(ET.TreeBuilder):
def doctype(self, name, pubid, system):
raise ValueError(f"DOCTYPE {name} refused: orders are data")
def fixed_import(data):
"""ElementTree with a target that refuses any DOCTYPE before a single element is read."""
parser = ET.XMLParser(target=NoDoctype())
parser.feed(data)
root = parser.close()
return root.get("id"), root.findtext("note")
def run_test(label, importer):
problems = 0
print(label)
for name, doc in CASES:
try:
order_id, note = importer(doc)
except ValueError as error:
print(f" {name:18} refused: {error}")
continue
verdict = ""
if "CANARY" in note:
verdict, problems = " <- FAIL: read canary.txt", problems + 1
elif b"<!DOCTYPE" in doc or "<!DOCTYPE" in doc.decode("utf-16", "ignore"):
verdict, problems = " <- FAIL: accepted a DOCTYPE", problems + 1
print(f" {name:18} {order_id}: {note!r}{verdict}")
print(f" test: {'FAIL, ' + str(problems) + ' problems' if problems else 'pass'}")
run_test("weak importer", weak_import)
run_test("fixed importer", fixed_import)
print(f"\nb'<!DOCTYPE' found by a byte search: UTF-8 document {b'<!DOCTYPE' in EXTERNAL}, UTF-16 document {b'<!DOCTYPE' in UTF16}") Output
weak importer ordinary order A-1001: 'gift wrap' external entity A-1002: 'CANARY-7f3a: a made-up line, no real secret' <- FAIL: read canary.txt internal entity A-1003: 'gift wrap' <- FAIL: accepted a DOCTYPE external, UTF-16 A-1002: 'CANARY-7f3a: a made-up line, no real secret' <- FAIL: read canary.txt test: FAIL, 3 problems fixed importer ordinary order A-1001: 'gift wrap' external entity refused: DOCTYPE order refused: orders are data internal entity refused: DOCTYPE order refused: orders are data external, UTF-16 refused: DOCTYPE order refused: orders are data test: pass b'<!DOCTYPE' found by a byte search: UTF-8 document True, UTF-16 document False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 xxe_check.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The weak importer failed three times: it read the canary twice, once from the UTF-16 copy of the document, and it accepted a DOCTYPE with an internal entity. The fixed importer refused all three documents with a DOCTYPE and parsed the ordinary order.
Switch the feature off and run the test again
Press Edit, delete the parser.setFeature(…) line in weak_import, and run it. The canary is gone, yet the test
still fails three times: the note of the external entity is now silently empty, and every DOCTYPE is still
accepted.
The refusal belongs in the parser. The parser calls the target’s doctype() as soon as it meets the declaration,
whatever the document’s encoding and before the root element is read, so no entity is ever used: nothing is opened and
nothing is expanded.
Searching the bytes for <!DOCTYPE
A check such as b"<!DOCTYPE" in data looks like a quick fix. The last line of the run shows it missing the UTF-16
document, which the parser reads without trouble. Refuse the DOCTYPE inside the parser, where the encoding is
already decoded.
Keep two limits as a second layer, as both OWASP and Python’s documentation suggest: a size limit on the upload before parsing, and, for compressed uploads, a limit on the size after decompression, because a compressed file can be far smaller than what it holds.
Key idea
Data-only XML refuses any DOCTYPE inside the parser, in every encoding, and a test with a canary file proves it on every change.
XML hides inside uploads and integrations
Many inputs that do not look like XML are XML, and each needs the same care. These are some of the common ones:
| Input | Where it arrives | What is inside |
|---|---|---|
| SVG image | avatars, logos, icons | an XML document (W3C’s SVG 2) |
| DOCX, XLSX, PPTX | document and spreadsheet uploads | XML parts in a ZIP package (ECMA-376) |
| Atom and RSS feeds | feed readers, imports | an XML document (RFC 4287 for Atom) |
| Sitemaps | SEO tools, crawlers | XML tags in UTF-8 (sitemaps.org) |
| SAML responses | enterprise single sign-on | signed XML from an identity provider |
This program checks two document uploads and three images. It opens each package, parses every XML part under the data rule, and reads SVG under a looser rule, because files made for SVG 1.1, whose specification had a DTD, often carry a DOCTYPE that names it: only that public DOCTYPE is allowed, and it is never fetched, while any internal subset, entity declaration or other DOCTYPE is refused:
# Uploads that are XML underneath. A .docx is a ZIP package of XML parts (Office Open XML), and an SVG image is an XML
# document. The importer parses every XML part of a package with the data policy (any DOCTYPE refused), and SVG with a
# looser one: only the public DOCTYPE of SVG 1.1 is allowed, and it is never fetched, while any internal subset, entity
# declaration or other DOCTYPE is refused. The files are built here, in memory.
import io
import zipfile
import xml.etree.ElementTree as ET
from xml.parsers import expat
class NoDoctype(ET.TreeBuilder):
def doctype(self, name, pubid, system):
raise ValueError("DOCTYPE in a data part")
def parse_data_part(data):
parser = ET.XMLParser(target=NoDoctype())
parser.feed(data)
return parser.close().tag
# The identifiers of the one DOCTYPE that files made for SVG 1.1 carry: the public and the system identifier.
SVG_1_1 = ("-//W3C//DTD SVG 1.1//EN", "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd")
def parse_svg(data):
"""Allows the public DOCTYPE of SVG 1.1 and nothing else; refuses every internal subset and entity declaration."""
parser = expat.ParserCreate()
root = []
def doctype(name, system_id, public_id, has_internal_subset):
if has_internal_subset:
raise ValueError("a DOCTYPE with an internal subset")
if (public_id, system_id) != SVG_1_1:
raise ValueError("a DOCTYPE other than the public one of SVG 1.1")
def entity(name, *details):
raise ValueError(f"entity {name!r} declared")
parser.StartDoctypeDeclHandler = doctype
parser.EntityDeclHandler = entity
parser.StartElementHandler = lambda name, attrs: root or root.append(name)
parser.Parse(data, True) # Expat itself never fetches the DTD a DOCTYPE names
return root[0]
def docx(document_xml):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as package:
package.writestr("[Content_Types].xml", '<?xml version="1.0"?><Types xmlns="urn:example:content-types"/>')
package.writestr("word/document.xml", document_xml)
return buffer.getvalue()
UPLOADS = [
("report.docx", docx('<?xml version="1.0"?><document><body>Quarterly figures</body></document>')),
("notes.docx", docx('<?xml version="1.0"?><!DOCTYPE document [<!ENTITY t "x">]><document><body>&t;</body></document>')),
("logo.svg", b'<?xml version="1.0"?>\n<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n<svg xmlns="http://www.w3.org/2000/svg"/>'),
("badge.svg", b'<?xml version="1.0"?>\n<!DOCTYPE svg [<!ENTITY a "x">]>\n<svg xmlns="http://www.w3.org/2000/svg">&a;</svg>'),
("icon.svg", b'<?xml version="1.0"?>\n<!DOCTYPE svg SYSTEM "http://other.example/svg.dtd">\n<svg xmlns="http://www.w3.org/2000/svg"/>'),
]
for name, data in UPLOADS:
if name.endswith(".docx"):
with zipfile.ZipFile(io.BytesIO(data)) as package:
parts = [(part, package.read(part)) for part in package.namelist() if part.endswith(".xml")]
check = parse_data_part
else:
parts, check = [(name, data)], parse_svg
print(name)
for part, xml_bytes in parts:
try:
print(f" {part:22} accepted, root element {check(xml_bytes)}")
except ValueError as error:
print(f" {part:22} refused: {error}") Output
report.docx
[Content_Types].xml accepted, root element {urn:example:content-types}Types
word/document.xml accepted, root element document
notes.docx
[Content_Types].xml accepted, root element {urn:example:content-types}Types
word/document.xml refused: DOCTYPE in a data part
logo.svg
logo.svg accepted, root element svg
badge.svg
badge.svg refused: a DOCTYPE with an internal subset
icon.svg
icon.svg refused: a DOCTYPE other than the public one of SVG 1.1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 uploads.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
In notes.docx the importer accepted the content-types part and refused the document part, which held a DOCTYPE,
while the ordinary report passed. logo.svg passed with its public DOCTYPE, which Expat never fetches, while
badge.svg, with an internal subset, and icon.svg, whose DOCTYPE names a DTD on another host, were refused. The SVG
rule uses two handlers of Python’s Expat interface: StartDoctypeDeclHandler, which reports the identifiers of a
DOCTYPE and whether it has an internal subset, and EntityDeclHandler, called for every entity declaration. SAML
deserves its own care: its responses are signed XML, and OWASP’s SAML cheat sheet asks for schema validation against
local, trusted copies of the schemas, never downloaded ones, before anything is trusted. Use a maintained library for
the standard rather than a parser of your own.
Checkpoint
Check: Refusing DOCTYPE, proved by a canary · Inputs that are XML underneath
When you control both ends, choose a format without entities
XML’s DTD features exist for documents. When your own services exchange data, JSON has none of them: no DOCTYPE, no entities and no references to other files, so there is nothing to switch off. It still needs limits, and Python’s documentation warns that a malicious JSON string may consume considerable CPU and memory, so limit the size you parse, and validate the result against what you expect. Choose the way for one integration of yours:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way to read it | Files or URLs fetched | Expansion | Fits the other side | What you must get right |
|---|---|---|---|---|
| XML, DOCTYPE refused How: a parser target that raises at any DOCTYPE | Good none: the parser stops at the DOCTYPE | Good none: no entity can be declared | Mixed only if the sender never needs a DOCTYPE | Good one target class and a size check |
| XML that needs its DTD How: entities off, nothing fetched, size limits | Mixed none, if every resolver is off and no handler opens files | Mixed bounded by Expat's limit and your size and time limits | Good accepts what the sender's DTD declares | Poor every feature of every parser in the path, kept off |
| An XML standard How: a maintained library for SAML, SOAP or OOXML | Mixed as safe as the library's defaults and its updates | Mixed as safe as the library's defaults and its updates | Good what the standard and the partner expect | Mixed choosing a maintained library and keeping it patched |
| JSON How: json.loads with size and depth limits | Good none: JSON has no references to resolve | Good none: no entities, only nesting to limit | Mixed only where you control both ends | Good size and depth limits, then validation |
How the answers decide
- “Does a standard or a partner require XML here, as SAML, SOAP or an Office file does?” Yes, XML is required: rules out JSON and adds a caveat to XML, DOCTYPE refused. The format is fixed, so read it with a maintained library for the standard; refusing DOCTYPE still fits most of them.
- “Does the input need its own DTD to be read correctly, for entities or default values?” No, it is plain data: rules out XML that needs its DTD. Without a need for the DTD, any DOCTYPE is refused: nothing to configure and nothing to keep switched off.
- “Does the input need its own DTD to be read correctly, for entities or default values?” Yes, the DTD matters: rules out XML, DOCTYPE refused and adds a caveat to XML that needs its DTD. The most to get right: entities off, no resolver that opens files or URLs, and limits on size, time and expansion.
- “Do you control the code on both ends of the exchange?” Yes, both ends are ours and “Does a standard or a partner require XML here, as SAML, SOAP or an Office file does?” No: adds a caveat to XML, DOCTYPE refused and XML that needs its DTD. With both ends yours and no standard in the way, JSON removes entities and fetches from the question altogether.
In short: XML with every DOCTYPE refused for data you must take as XML, a maintained library where a standard such as SAML or SOAP fixes the format, the hardened DTD path only where a DTD is genuinely needed, and JSON with limits wherever both ends are yours.
XML ↔ JSON Converter Convert a sample of an XML feed you receive into JSON, to see what a JSON version of the exchange would hold.Checkpoint
Check: Choosing a format without entities
Practise: an invoice parser that refuses DOCTYPE
Fix an invoice parser built on SAX with external entities switched on. The sample tests write a canary file next to your code, then feed it documents with external and internal entities, a UTF-16 document and an oversized upload:
Exercise · Medium · Python
An invoice parser that refuses DOCTYPE
Fix parse_invoice(data) in invoice.py. data is the bytes of an uploaded invoice, such as
<invoice><number>INV-1001</number><total>1200.00</total></invoice>
and the function returns {"number": "INV-1001", "total": "1200.00"}. Invoices are data, not documents, so:
- Any document with a
DOCTYPEraisesValueError, whatever its encoding (UTF-16 included) and whatever the DOCTYPE declares, before a single value is read. - An upload larger than 65,536 bytes raises
ValueErrorbefore it is parsed. - A document whose root element is not
invoiceraisesValueError.
The starter parses invoices with SAX and has external entities switched on. The sample tests write a canary file next to your code and feed you documents that name it.
Starter code · invoice.py
import io
import xml.sax
import xml.sax.handler
class _Invoice(xml.sax.handler.ContentHandler):
def __init__(self):
super().__init__()
self.fields, self._current = {}, None
def startElement(self, name, attrs):
self._current = name
def endElement(self, name):
self._current = None
def characters(self, content):
if self._current in ("number", "total"):
self.fields[self._current] = self.fields.get(self._current, "") + content
def parse_invoice(data):
"""Starter: it reads invoices, and with them whatever their DOCTYPE asks for."""
handler, parser = _Invoice(), xml.sax.make_parser()
parser.setContentHandler(handler)
parser.setFeature(xml.sax.handler.feature_external_ges, True) # added once for a supplier's DTD
parser.parse(io.BytesIO(data))
return {"number": handler.fields.get("number"), "total": handler.fields.get("total")} The sample tests · test_invoice.py
from pathlib import Path
from invoice import parse_invoice
PLAIN = b'<?xml version="1.0"?>\n<invoice><number>INV-1001</number><total>1200.00</total></invoice>'
EXTERNAL = b'<?xml version="1.0"?>\n<!DOCTYPE invoice [<!ENTITY n SYSTEM "canary.txt">]>\n<invoice><number>&n;</number><total>1</total></invoice>'
INTERNAL = b'<?xml version="1.0"?>\n<!DOCTYPE invoice [<!ENTITY n "INV-1002">]>\n<invoice><number>&n;</number><total>1</total></invoice>'
# A made-up file next to the code: a parser that puts its text into a result has read a file the document named.
Path("canary.txt").write_text("CANARY-7f3a\n")
def refuses(data, why):
try:
result = parse_invoice(data)
except ValueError:
return
assert "CANARY" not in str(result), f"{why}: the parser read canary.txt"
raise AssertionError(f"{why}: it returned {result!r} instead of raising ValueError")
def test_a_plain_invoice_is_parsed():
assert parse_invoice(PLAIN) == {"number": "INV-1001", "total": "1200.00"}
def test_an_external_entity_is_refused_and_the_canary_is_never_read():
refuses(EXTERNAL, "a DOCTYPE with an external entity")
def test_an_internal_entity_is_refused_too():
refuses(INTERNAL, "a DOCTYPE with an internal entity")
def test_a_doctype_in_utf16_is_refused():
utf16 = EXTERNAL.decode().replace('version="1.0"', 'version="1.0" encoding="UTF-16"').encode("utf-16")
refuses(utf16, "a UTF-16 document with a DOCTYPE")
def test_large_uploads_and_other_roots_are_refused():
refuses(PLAIN[:-10] + b"<note>" + b"x" * 70_000 + b"</note></invoice>", "an upload over 65,536 bytes")
refuses(b'<?xml version="1.0"?>\n<order><number>1</number></order>', "a root element that is not invoice") A hint
Use xml.etree.ElementTree.XMLParser(target=...) with a subclass of TreeBuilder whose doctype(name, pubid, system) method raises ValueError: the parser calls it as soon as it meets a DOCTYPE, in any encoding. Check len(data) first.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then work through the questions on entities, parsers and formats, explained the moment you answer:
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is XXE, and what is the main defence?
XXE, XML external entity injection, happens when an XML parser resolves an external entity that an uploaded document declares in its DOCTYPE. The entity names a file or a URL, so the parser reads that file or fetches that URL, and puts the result into data the application uses, where it may be shown, stored or logged. The main defence is to configure the parser so that it cannot happen: refuse any DOCTYPE in data-only XML, or, where a DTD is required, turn off external entities and DTD loading, with limits on size and expansion. Prove it with a test that uploads a document naming a canary file and fails if the canary appears or the document is accepted.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
Your app lets users upload SVG avatars and converts them to PNG on the server with an XML-based library. A review asks whether this upload path is safe from XXE and entity expansion. How do you check it, and what do you change?
-
Mid to staff
A partner sends a nightly XML product feed and insists that its DOCTYPE and DTD must be accepted, because the feed uses entities defined there. How do you parse it safely, and what do you push back on?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is XXE, and what can a DOCTYPE make a parser do?
A DOCTYPE may declare entities. An internal entity pastes text and can multiply it many times (entity expansion, CWE-776); an external entity names a file or URL that the parser opens or fetches and pastes into the data (XXE, CWE-611); an external DTD or parameter entities fetch more declarations. The results: files read from the server, requests made by the server, and a parser out of memory or time. OWASP counts these under A02 Security Misconfiguration, since they are parser settings.
-
How do Python's standard XML parsers handle external entities by default?
They sit on Expat, which opens no files and makes no connections itself, and SAX stopped processing general external entities by default in 3.7.1. In the lesson's test only SAX with feature_external_ges switched on read the canary file; ElementTree refused the entity, and minidom, pulldom and default SAX left it out silently. Expat versions below 2.7.2 may be open to entity expansion.
-
What is the safest rule for data-only XML, and where do you enforce it?
Refuse any document with a DOCTYPE, inside the parser (in Python a target whose doctype() raises), where every encoding is already decoded. Searching the bytes for <!DOCTYPE misses a UTF-16 document the parser reads fine. Prove it with a canary-file test, and keep size limits, also after decompression, as a second layer. ASVS 5.0.0 1.5.1 asks for a restrictive parser configuration.
-
Which inputs are XML underneath?
SVG images, DOCX, XLSX and PPTX packages (XML parts in a ZIP), Atom and RSS feeds, sitemaps, and SAML responses. Check every XML part by the same rules. SVG 1.1 files often carry a public DOCTYPE, which can be allowed if it is never fetched, while any internal subset, entity declaration or other DOCTYPE is refused. Leave XInclude and XSLT off.
-
What matches "refuse the DTD" in other stacks and libraries?
Java JAXP: set the feature disallow-doctype-decl. .NET: XmlReader prohibits DTDs by default. libxml2 2.9 and later: never pass XML_PARSE_NOENT or XML_PARSE_DTDLOAD for untrusted input. lxml stopped expanding external entities in 5.0 and does so again with resolve_entities=True. For Python, OWASP recommends defusedxml.
-
When both ends are yours, why prefer JSON to XML?
JSON has no DOCTYPE, entities or references to other files, so there is nothing to switch off. It still needs limits: a malicious JSON string may consume considerable CPU and memory, so limit the size you parse and validate the result.
Key takeaways
- A DOCTYPE can declare entities that make a parser read files, fetch URLs or multiply text, and data from outside rarely needs one.
- Python’s standard parsers sit on Expat, which fetches nothing by itself; only SAX with
feature_external_gesread the canary, and Expat 2.8.5’s amplification limit stopped the six-level document. - Refuse any DOCTYPE in data-only XML inside the parser, where every encoding is already decoded, and keep size limits as a second layer.
- SVG images, Office files, feeds, sitemaps and SAML responses are XML underneath, and each XML part of an upload is checked by the same rules.
- When both ends are yours, JSON with size limits removes entities and fetches from the question.
Terms in this lesson
- DTD
- A document type definition: rules for an XML document's structure, written in a DOCTYPE at its top or in a file it names. A DTD can also declare entities, which is what makes it risky in data from outside.
- entity
- In XML, a named piece of text declared in a DTD and used as &name; in the document. An internal entity holds its text; an external entity names a file or URL that a parser may fetch to get it.
- entity expansion
- Entities that refer to other entities many times, so that a few hundred bytes of input expand into millions of characters and use up memory and time (CWE-776). Expat limits the growth; refusing DTDs removes it.
- XXE
- XML external entity injection (CWE-611): a parser that resolves external entities reads a file or fetches a URL that an uploaded document names, and puts what it got into the data the app uses.
References
- A02:2025 Security Misconfiguration (OWASP Foundation)
- XML External Entity Prevention Cheat Sheet (OWASP Foundation)
- CWE-611: Improper Restriction of XML External Entity Reference (MITRE)
- CWE-776: Improper Restriction of Recursive Entity References in DTDs ('XML Entity Expansion') (MITRE)
- Application Security Verification Standard 5.0.0: V1 Encoding and Sanitization (OWASP Foundation)
- XML Processing Modules: XML security (Python Software Foundation)
- xml.sax: Support for SAX2 parsers (Python Software Foundation)
- xml.etree.ElementTree: TreeBuilder and XMLParser (Python Software Foundation)
- xml.parsers.expat: Fast XML parsing using Expat (Python Software Foundation)
- RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format (IETF (RFC Editor))
- json: JSON encoder and decoder (Python Software Foundation)
- lxml: parser options (resolve_entities, no_network) (lxml project)
- lxml 5.0 changes (lxml project)
- Scalable Vector Graphics (SVG) 2 (W3C)
- ECMA-376: Office Open XML File Formats (Ecma International)
- RFC 4287: The Atom Syndication Format (IETF (RFC Editor))
- Sitemaps XML format (sitemaps.org)
- SAML Security Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress