Get unstuck

Hint 1: Resolving entities is the switch

lxml only reads external entities when the parser is told to. Build the XMLParser with resolve_entities=False so a declared entity is left as an unresolved reference, not fetched.

Hint 2: Close every door, not just file://

Add no_network=True and load_dtd=False. A text blocklist for "file://" misses a bare absolute path and other schemes; configure the parser instead of inspecting the string.

Reveal the reference patch and explanation
Fixedinvoice.pyPython
from lxml import etree


def parse_invoice(xml):
    parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
    root = etree.fromstring(xml.encode("utf-8"), parser)
    return {
        "supplier": root.findtext("supplier") or "",
        "total": root.findtext("total") or "",
    }

With resolve_entities=False, no_network=True and load_dtd=False, an ordinary invoice returns its supplier and total, and & and the numeric é still expand (lxml always resolves the predefined and character entities). A DOCTYPE entity pointing at file://…/secret.txt or at a bare absolute path is left unresolved, so the supplier comes back empty rather than carrying the file’s contents; a network entity such as the cloud-metadata URL is never fetched. A harmless DOCTYPE with no entity still parses. External general entities are only read when a parser opts in with resolve_entities=True (lxml’s default since 5.0 is resolve_entities='internal'), so the fix is to opt out explicitly and keep DTD loading and network access off.

Compare it with your own fix, then rerun the tests. The editor is not changed when you open this explanation.

Inspect the actual test harness

The browser and the lab download run the same cases, including cases that catch common wrong fixes, such as comparing with one fixture value instead of the real rule. The download includes the complete test adapter and cryptographic fixtures.

Download the grader, python/cases.mjs
How the browser runs your Python

Your code runs in CPython 3.14.2 compiled to WebAssembly (Pyodide 314.0.7), in a Web Worker of its own. The test cases and the pass or fail verdict run in a second worker, which only sends your functions their inputs and reads back the values they return, so changing built-ins or the harness inside Python cannot change the result. All the cases together have a 8-second limit.

The first run downloads about 8.4 MB from this site, which your browser then keeps; later runs start in a second or two. Other pages never load it.

Available: the Python standard library, including sqlite3, ipaddress, pathlib, os.path and urllib.parse, and print(), whose output appears with your results. This lab also loads lxml 6.1.3: the exact wheel pinned by Pyodide 314.0.7, served from this site and checked against the lock file’s SHA-256 digest.

Not available: network access, subprocesses and threads, and other packages from PyPI. The lab download runs the same cases with your local python3 (3.12 or newer), once lxml==6.1.3 is installed (its README shows how).

Use the lesson in your own system

This lab parses one document. libxml2 (which lxml wraps) already caps entity amplification, so a classic billion-laughs document is rejected by default; still bound input size and parse time for untrusted XML. XInclude, XSLT document() and schema/DTD processing have their own external-reference switches that must also stay off for untrusted input. The behaviour here was confirmed against lxml 6.1.3 running in the browser (Pyodide); a server’s libxml2 build may differ, so test yours.

Your next task: Which parsers in your stack accept uploaded or third-party XML (SAML, SOAP, office documents, feeds), and is external entity resolution disabled on each, by configuration rather than by inspecting the bytes?

lxml FAQ: parsing untrusted XML safely

Related guide: Parse bounded JSON and XML into an explicit application schema