XML in Production · Security and Integration

The Upload That Read the Server's Files

An invoice-upload endpoint parsed XML with external entities enabled. A tester uploaded a document that echoed the server's configuration file back in the error message.

Create a free account to save this page, take notes and track your progress. Sign in

Scenario

Example team: an accounts-payable portal lets suppliers upload invoices as XML. The Java service parses them with a default DocumentBuilderFactory, validates a few fields and, on error, returns a message that includes the invalid value.

What you see in production

A penetration tester uploads an invoice whose <supplier> is &cfg;, with <!ENTITY cfg SYSTEM "file:///app/config/application.properties">. The "invalid supplier" error response contains the database password. A second payload points the entity at the cloud metadata endpoint.

The naive code

DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();   // defaults
Document invoice = builder.parse(upload);

Reproduce it

The same attack against Python's lxml, with and without safe settings (a local file stands in for the secrets):

An XXE payload against two parser configurationsTry it yourself
from lxml import etree

with open("application.properties", "w") as f:
    f.write("db.password=s3cr3t")

upload = b"""<!DOCTYPE invoice [ <!ENTITY cfg SYSTEM "application.properties"> ]>
<invoice><supplier>&cfg;</supplier><total>99.00</total></invoice>"""

def handle(parser):
    try:
        supplier = etree.fromstring(upload, parser).findtext("supplier")
        return f"error: unknown supplier {supplier!r}"     # the value is echoed back to the client
    except etree.XMLSyntaxError as e:
        return f"rejected: {e.msg}"

vulnerable = etree.XMLParser(resolve_entities=True, no_network=False)
hardened = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
print("vulnerable:", handle(vulnerable))
print("hardened:  ", handle(hardened))

def reject_doctype(data):
    return "rejected: DOCTYPE not allowed" if b"<!DOCTYPE" in data[:1024].upper() else "accepted"
print("pre-check: ", reject_doctype(upload))

Output

vulnerable: error: unknown supplier 'db.password=s3cr3t'
hardened:   error: unknown supplier ''
pre-check:  rejected: DOCTYPE not allowed

Root cause

DTDs let a document declare external entities that the parser fetches from files or URLs. Java's default DocumentBuilderFactory, older libxml2 settings and many libraries resolve them. Combined with an error message that echoes input, the parser becomes a file reader and SSRF client for the attacker.

The fix

  • Disallow DTDs for untrusted input. Java: setFeature("http://apache.org/xml/features/disallow-doctype-decl", true). Python: defusedxml for the standard library, or lxml with resolve_entities=False, no_network=True. PHP: never pass LIBXML_NOENT. .NET: DtdProcessing.Prohibit.
  • Centralise parser creation in one helper so every endpoint gets the safe configuration.
  • Don't echo raw input in error messages.
  • Block outbound network access from parsing services (egress rules, IMDSv2 on AWS).

Trade-offs

  • Some legitimate formats use DTDs (DocBook, old SGML-derived documents). Allow them only from trusted sources, with a local catalogue and no network access.
  • A blanket <!DOCTYPE pre-check is crude but effective as defence in depth.

Detect it in production

  • SAST rules for unsafe parser construction (Semgrep, CodeQL, SpotBugs find-sec-bugs).
  • WAF or logs alerting on <!DOCTYPE and <!ENTITY in request bodies.
  • Egress logs showing parser hosts calling unexpected URLs.

Key points

  • XXE turns an XML parser into a file reader and SSRF client.
  • Disable DTDs and external entities for untrusted input, in one shared helper.
  • Don't reflect parsed input in errors; restrict egress.

Practice questions

How do you make Java's DocumentBuilderFactory safe against XXE?

Answer

Set the feature http://apache.org/xml/features/disallow-doctype-decl to true (and enable FEATURE_SECURE_PROCESSING, disable XInclude and entity expansion). This rejects any document with a DOCTYPE.

Why can XXE lead to SSRF?

Answer

An external entity can be an http:// URL, so the server's parser makes requests to internal services or cloud metadata endpoints on the attacker's behalf.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?