Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · message by message One DOCTYPE, one file read Step through an upload whose DOCTYPE names a file, read by a parser with external entities on, then by the fix.
One DOCTYPE, one file read, step 6 of 6. Messages so far: Uploaded order to XML parser: <!ENTITY n SYSTEM …>; XML parser to Server's files: open canary.txt; Server's files to XML parser: CANARY-7f3a: a made-up line.Uploaded order116 bytes of XMLXML parserDOCTYPE refusedServer's filescanary.txt in the folder<!ENTITY n SYSTEM …>open canary.txtCANARY-7f3a: a made-up line

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

Five standard-library parsers, three test documents Python · matrix/stdlib_matrix.py
# 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>&note;</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

  1. Question 1 of 3 Where in an XML document can entities be declared?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: In its DTD, which the DOCTYPE at the top holds or names

    The DOCTYPE is where a document's DTD lives or is named, and the DTD is where entities are declared. That is why refusing the DOCTYPE removes every entity at once.

  2. Question 2 of 3 In the program's run, which standard-library parser read canary.txt from an external entity?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: xml.sax with feature_external_ges switched on

    Defaults protect you until someone changes them. Search your code for feature_external_ges and switches like it, and keep a test that fails if any parser reads the canary.

  3. Question 3 of 3 Why does the lesson tell you to print pyexpat.EXPAT_VERSION in your diagnostics?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: Expat, not Python, limits entity expansion, Python may use a bundled or a system copy, and versions below 2.7.2 may be open to expansion

    The expansion limit lives in the Expat library. Knowing which copy runs, and patching the image when it is old, keeps that layer of defence in place.

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.txt, the made-up file the test documents name Text · refuse/canary.txt
CANARY-7f3a: a made-up line, no real secret
One regression test, a weak importer and a fixed one Python · refuse/xxe_check.py
# 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

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:

Two packages and three images, each XML part checked Python · package/uploads.py
# 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

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

  1. Question 1 of 3 An endpoint refuses uploads when b"<!DOCTYPE" appears in their raw bytes. What does the lesson's run show about this check?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: It misses the same document in UTF-16, which the parser reads without trouble

    Check for the DOCTYPE where the document is already decoded: inside the parser, with a target whose doctype() method raises.

  2. Question 2 of 3 A user uploads a .docx file. What does the program's importer parse?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: Every XML part inside the ZIP package, each under the data rule

    Office files, SVG, feeds and sitemaps are XML underneath. Each XML part is parsed with the same restrictive rules as any other XML from outside.

  3. Question 3 of 3 SVG files made for SVG 1.1 start with a DOCTYPE that names its public DTD, so refusing every DOCTYPE would refuse them. What rule did the lesson use instead?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: Allow only the public SVG 1.1 DOCTYPE and never fetch the DTD it names, but refuse any internal subset, entity declaration or other DOCTYPE

    When a format needs a looser rule, loosen exactly one thing: here the one public DOCTYPE that files made for SVG 1.1 carry. Entities and every other DOCTYPE stay refused, and nothing is fetched.

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:

Interactive · answer and compare Which way should this input be read? Answer three questions about one integration of yours; the table rules out the ways that do not fit it.
Does a standard or a partner require XML here, as SAML, SOAP or an Office file does?
Does the input need its own DTD to be read correctly, for entities or default values?
Do you control the code on both ends of the exchange?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four ways to read structured input, by what each still needs from you
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

  1. One question Why does JSON avoid the problems of this lesson?

    Choose one answer.

    Show the answer: One question

    Answer: It has no DOCTYPE, no entities and no references to other files, so a parser has nothing to fetch or expand

    Choose the format whose features you do not have to turn off. JSON still needs a size limit and validation.

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 DOCTYPE raises ValueError, 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 ValueError before it is parsed.
  • A document whose root element is not invoice raises ValueError.

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.

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.

XML Formatter Indent an XML file you received and look at its first lines for a DOCTYPE before your code ever parses it.

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.

  1. Question 1 of 11 While writing test documents for an import, which declaration do you include to check that the parser never reads a file?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: An external entity with a SYSTEM identifier naming a canary file next to the code

    External entities are the file-reading half of the risk and internal entities the expansion half. A good test set holds both, plus a DOCTYPE in another encoding.

  2. Question 2 of 11 Why does refusing the DOCTYPE protect against both external entities and entity expansion at once?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: Both need entity declarations, and entities can only be declared in the DTD that a DOCTYPE holds or names

    One rule removes the shared cause. Size limits and Expat's own limit stay as further layers.

  3. Question 3 of 11 In the run, minidom met an external entity in an order's note. What did it return, and why is that still a problem for an importer?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: An empty note with no error, so the importer stores data that silently lost a part

    Not reading the file is not the same as handling the document correctly. Refusing any DOCTYPE gives one clear answer for every parser.

  4. Question 4 of 11 In a code review you find lxml's etree.XMLParser(resolve_entities=True) in an upload handler, with a comment that a partner needed it once. What do you ask for?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: Remove the option, which turns external entities back on, refuse DOCTYPE in the upload, and add the canary test

    Review every parser option in an upload path as a security setting. A one-time need is a reason to give that partner a separate, documented path, not to weaken the shared one.

  5. Question 5 of 11 Where does a data-only endpoint refuse a DOCTYPE in the lesson's fixed importer?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: In the parser, through a TreeBuilder target whose doctype() method raises ValueError

    Put the refusal where the parser has already decoded the document and has not yet used any entity.

  6. Question 6 of 11 If you delete the setFeature line from the weak importer, the canary is no longer read. How many problems does the regression test still report?

    Type a number.

    Show the answer: Question 6 of 11

    Answer: 3 problems

    Three: every document with a DOCTYPE is still accepted, and the external entity's note is now silently empty. Removing one switch is not the same as refusing the DOCTYPE.

  7. Question 7 of 11 Your invoice endpoint receives gzip-compressed XML uploads, already refuses DOCTYPE and limits the upload to 64 KiB. What is still missing?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: A limit on the size after decompression, because a small compressed file can hold far more than 64 KiB

    Limit what you receive and what you decompress, then parse with DOCTYPE refused. Each limit covers a different way of exhausting the server.

  8. Question 8 of 11 Which of these uploads are XML underneath? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 8 of 11

    Answer:

    • An Atom feed
    • An SVG avatar
    • A .xlsx spreadsheet

    Treat every one of them as XML from outside: parse each part with the restrictive rules, whatever the file's name says.

  9. Question 9 of 11 Your single sign-on code validates SAML responses against schemas it downloads from locations named in the response. What do you change?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: Validate against local, trusted copies of the schemas and never download them, using a maintained SAML library

    A response must never choose what the server fetches. Keep schemas local and let a maintained library handle the standard.

  10. Question 10 of 11 You move an exchange between two of your own services from XML to JSON. What protection do you still need?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: A size limit on what you parse, then validation of the result against what you expect

    A format without entities still needs limits and validation. That is far less to get right than a hardened XML parser.

  11. Question 11 of 11 A partner's interface is defined by a SOAP standard, so JSON is ruled out. How do you read its messages?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: With a maintained library for the standard, configured to refuse DOCTYPE wherever the standard allows it

    When the format is fixed, choose the most maintained implementation of it and keep its parser restrictive.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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_ges read 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.