Cap the bytes before parsing, then turn the document into an exact schema: reject duplicate keys, unknown fields, booleans or floats where integers belong, and anything beyond the depth, item and total limits. For XML, refuse DTDs and entities instead of expanding them, and run a current parser library; Expat 2.6.0 provides the needed API, but later releases fixed many vulnerabilities. Parsing proves only syntax: authorization and business rules still follow.
- Bound bytes first, then depth, item counts and totals; per-item checks miss aggregate limits.
- Inspect JSON member pairs so a repeated key is rejected instead of silently keeping the last value.
- Check exact types: in Python
Trueis anintandint(1.5)truncates, so coercion invents allowed values. - Reject a DOCTYPE as soon as the parser reports it; a harmless-looking entity is still an excluded capability.
- The linked Expat version decides XML safety: check
pyexpat.EXPAT_VERSION, not only the Python version. - Never use
pickleor other object deserialization for untrusted input.
| Setting | Value | Note | Source |
|---|---|---|---|
| Document size | 8,192 encoded bytes in this example, checked before parsing | — | — |
| JSON nesting | At most four containers in this example | — | — |
| Items and amounts | 1 to 100 items; integer cents from 0 to 10,000,000; total at most 100,000,000 | — | — |
| Python JSON decoding | json.loads with object_pairs_hook rejecting duplicates and parse_constant rejecting NaN and Infinity | — | Python json documentation |
| Expat | The current release: 2.9.0, published 5 October 2026 | 2.6.0 is the minimum for reparse deferral, not a safe version: later releases fixed CVE-2024-45490, CVE-2025-59375 and more than 25 CVEs in 2026. | libexpat changelog |
| XML features | No DTD, entity declarations, processing instructions or namespaces for this vocabulary | — | OWASP ASVS v5.0.0 V1.5.1 |
- Run the project:
EXERCISE_MODULE=reference python3 -m unittest -v test_parsing.pypasses all 24 cases. - Send a document one byte over the cap, one level too deep and with 101 items; each must fail before business logic runs.
- Send
{"currency":"EUR","currency":"USD"}and{"cents":true}and confirm both are rejected with distinct reasons. - Send XML with an internal entity and with an external DTD reference and confirm both fail without any file or network access.
- Print
python3 -c "import pyexpat; print(pyexpat.EXPAT_VERSION)"on every runtime and compare it with the current Expat release.
- Applies to
- Python 3.13 and later for the project
- Expat 2.6.0 or later for reparse deferral; for security fixes, the current release on 10 October 2026, 2.9.0 (published 5 October 2026)
Data-only JSON and XML invoice imports, before domain authorization and durable writes.
- Part of
- Workflows: Fix a finding, Secure a feature, Test an application
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Define byte, depth, item and numeric limits for one document vocabulary
- Reject duplicate fields and coercion before business use
- Disable XML capabilities outside the vocabulary and test real parser behavior
Before you start
Developers implementing structured data imports and security engineers reviewing parser boundaries
- Read Python dictionaries, lists and exception handling
- Distinguish syntax validation from business authorization
- For the project: Python 3.13+ linked to Expat 2.6.0 or later, ideally the current release
Refresh first: Keep untrusted values out of executable syntax
Go to the guided practiceA JSON or XML parser accepts a language and turns it into values. The application still has to decide which values and structures belong to its business vocabulary. Syntax success does not establish that an invoice contains only allowed fields, that cents is an integer, or that the caller may submit an invoice for the chosen account.
Arbitrary object deserialization adds a more dangerous capability: the representation can select types or behavior rather than just data. Python pickle is designed to reconstruct Python objects and must not be used for untrusted input; the fix cards at /fix/deserialization/ show data-only replacements. Replacing such a format with JSON narrows capabilities, but the following schema and resource checks remain necessary. This guide uses actual JSON and XML parsing and never demonstrates loading an untrusted executable object.
The project accepts an invoice with exactly currency and items. Currency is USD or EUR. There must be 1–100 items, each containing exactly cents, an integer from 0 through 10,000,000. The aggregate must be at most 100,000,000. A document is at most 8,192 encoded bytes; JSON nesting is at most four containers. These values are explicit fixture requirements, not universal invoice limits.
The XML vocabulary expresses the same data as an invoice element with a currency attribute and item children with cents attributes. It permits XML 1.0 in UTF-8, whitespace and comments. It excludes DTDs, declared entities, processing instructions, namespaces, nested item children and non-whitespace text. XML cents must be 1–8 ASCII decimal digits before conversion. Decide on these rules before selecting a parser configuration.
JSON: {"currency":"USD","items":[{"cents":150},{"cents":225}]}
XML: <invoice currency="USD"><item cents="150"/><item cents="225"/></invoice>
Normalized result: {currency: USD, items: [150,225], total: 375}
Boundary order: bytes -> encoding/syntax/capabilities -> exact structure -> numeric limits -> normalized value
Authorization and database commit occur after this boundary and are not provided by it.A parser that retains only the last occurrence of a duplicate JSON name has already thrown away evidence before a normal dictionary validator runs. The reference uses Python’s object_pairs_hook to inspect member pairs and reject repeated names. parse_constant refuses non-finite constants accepted by Python’s permissive default JSON decoder; an integer-token bound limits conversion work.
Exact numeric type matters. In Python, bool is a subclass of int and int(True) becomes 1. int(1.5) also loses a fractional part. The reference therefore checks type(value) is int before accepting cents, then applies the stated range and aggregate bounds. Conversion should not silently invent an allowed business value from a forbidden one.
The depth precheck is a small state machine tracking structural braces and brackets outside quoted strings. It recognizes escaped quotes so string content does not inflate depth. It is only a resource guard; json.loads still validates JSON syntax. The byte cap comes first, limiting the amount of input the scanner and parser must examine. An HTTP endpoint must independently bound incoming and decompressed bytes before calling this function.
The reference uses Python’s Expat event interface for this narrow document vocabulary. It rejects a DOCTYPE declaration as soon as the parser reports it, disables parameter-entity parsing, and rejects external-entity callbacks. Event handlers construct only the allowed root and item structure; unexpected elements, attributes or text fail while parsing.
This distinction matters even for a harmless entity that expands to USD. The fixture rejects the capability to declare entities, independently of whether a particular expansion looks benign. A schema validator applied after expansion would be too late to decide whether expansion was allowed. A case-insensitive substring search for a few declaration tokens is not a replacement for an XML parser.
Check the underlying library version, not only the Python interpreter version. The project requires Expat 2.6.0 or newer and enables reparse deferral where supported. That is a capability floor, not a safe version: later releases fixed CVE-2024-45490 to CVE-2024-45492 (2.6.3), CVE-2024-50602 (2.6.4), CVE-2024-8176 (2.7.0), CVE-2025-59375 (2.7.2) and more than 25 CVEs in the 2026 releases 2.7.4 to 2.9.0. Run the current release, 2.9.0 as of 10 October 2026, and check what each runtime actually links with pyexpat.EXPAT_VERSION: on the machine used to verify this guide, Python 3.14.7 reported expat_2.7.4 while the python:3.11-alpine image linked the newer system expat_2.8.5, because the interpreter build decides whether it bundles its own copy. Keep the interpreter and linked libraries maintained. For broader XML processing, choose a maintained hardened parser and configure each required capability explicitly; the XXE fix cards at /fix/xxe/ show that configuration for Java, .NET and Python.
Download the bounded data parsing project linked with this guide. It requires only Python’s standard library and opens no sockets or files containing user data. Run python3 -m unittest -v test_parsing.py. The starter passes five of twenty-four cases and fails nineteen: its syntax parser works, but it coerces amounts, drops unknown fields and accepts unsupported XML features.
Repair parse_invoice in exercise.py against the written contract. Keep byte bounds, duplicate detection, parser capability choices and schema checks as distinct decisions. The reference command is EXERCISE_MODULE=reference python3 -m unittest -v test_parsing.py. All twenty-four cases pass. On PowerShell set the environment variable before running the same Python command; clear it before retesting your own code.
The suite keeps ordinary JSON and XML, zero cents and exactly one hundred items as positive controls. It also supplies a tiny internal entity expanding to a three-character currency and an unused external-DTD declaration. The tests do not read a local file, fetch a network entity or generate an entity-expansion flood. Their purpose is to establish the configured acceptance boundary safely.
Dropping unknown fields can be appropriate for some versioned protocols, but it violates this exact-field invoice contract. A client-supplied approved field must not silently become a later authority flag. Conversely, changing an established extensible format to reject every unknown field is a compatibility decision requiring a documented policy; do not generalize this narrow example blindly.
Checking each line item independently misses aggregate effects. Eleven values of 10,000,000 sum to 110,000,000, above the document cap despite valid individual ranges. Rejecting only negative numbers misses floats and booleans. Rejecting DTDs does not remove the need for byte and structure limits. Each check has a distinct counterexample.
After parsing, pass the normalized result to separate authorization and transactional business logic. Do not use parsing success as an approval signal. Likewise, do not log an entire rejected document by default: length, content type, policy reason and a request identifier often provide enough diagnostic evidence without creating a second sensitive-data store.
The outcomes below belong to this deliberately small vocabulary. Each rejection should have a useful internal reason while external error details follow the application’s disclosure policy.
| Input | Expected result | Why |
|---|---|---|
| 150 and 225 integer cents | Accept total 375 | Ordinary positive case |
true or 1.5 cents | Reject | No implicit numeric coercion |
Repeated currency name | Reject | Ambiguous member selection |
| 101 items | Reject | Cardinality bound |
| 11 × 10,000,000 cents | Reject | Total 110,000,000 exceeds 100,000,000 |
DTD declaring entity USD | Reject | Capability excluded even if payload is harmless |
Unknown XML attribute approved | Reject | Not in exact schema |
This project does not implement XML signatures, schema languages, namespace processing, XPath, multipart upload handling, decompression or an invoice storage service. It is a bounded example of translating two simple data syntaxes into one business object. A production integration must add service-level time/memory budgets and evaluate its actual parser features, document sizes and supported versions. Preserve the regression fixture whenever that vocabulary changes.
Guided practice
Review seven invoice candidates under the exact contract, repair the real parser, and explain which boundary rejects each case. No external documents or infrastructure are needed.
Supplied scenario material
J1 {"currency":"USD","items":[{"cents":150},{"cents":225}]}
J2 {"currency":"USD","items":[{"cents":true}]}
J3 {"currency":"EUR","currency":"USD","items":[{"cents":1}]}
J4: 11 items, each cents=10,000,000; currency=USD.
X1 <invoice currency="USD"><item cents="1"/></invoice>
X2 <!DOCTYPE invoice [<!ENTITY unit "USD">]><invoice currency="&unit;"><item cents="1"/></invoice>
X3 <invoice currency="USD" approved="true"><item cents="1"/></invoice>
All candidates fit the byte cap. The caller’s permission is not supplied.1. Calculate the positive controls
Determine normalized results for J1 and X1.
Expected result and reasoning
J1 produces
USD,[150,225], total 375. X1 producesUSD,[1], total 1. The same business vocabulary can have two explicitly supported serializations.2. Catch ambiguity and coercion
Explain why J2 and J3 must fail before a downstream business action.
Expected result and reasoning
J2 contains a Boolean, not integer cents; Python coercion would wrongly invent 1. J3 repeats
currency; a normal dictionary might retain onlyUSD. The pair-aware parser must reject the ambiguity before a single interpretation is assumed.3. Apply the aggregate cap
Compute J4 and identify why per-item validation does not suffice.
Expected result and reasoning
11 × 10,000,000 = 110,000,000, above the 100,000,000 document cap. Every individual amount is within range, so the aggregate check is independently necessary.
4. Separate capability from schema
Give different rejection reasons for X2 and X3.
Expected result and reasoning
X2 declares a DTD/entity, a parser capability excluded by the vocabulary even though this entity expands only to
USD. X3 has an unknownapprovedattribute and fails the exact schema. Neither document is allowed to supply a server-side approval decision.5. Repair and retain the evidence
Run the downloadable project, then state what all 24 passing checks do and do not establish.
Expected result and reasoning
They show this actual JSON/Expat implementation accepts the supplied positive controls and rejects the bounded negative cases. They do not establish caller authorization, safe database commit, general-purpose XML safety or resource behavior beyond the stated input limits. Keep those as separate controls.
Tiny synthetic documents, actual local parsers, no arbitrary object reconstruction or external entity retrieval. Limits and exact-schema rules are specific to the invoice vocabulary.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
XXE lab: Parse an uploaded invoice without resolving external entities
Pickle deserialization lab: Load a session token without pickle
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Parse bounded JSON and XML invoices safely
Repair a real JSON/XML parser boundary: reject duplicate fields, coercion, unknown schema, DTDs and oversized documents while keeping valid invoices usable.
In the extracted folder, run:
python3 -m unittest -v test_parsing.py24 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image, and includes Semgrep rules with their own tests in semgrep/.
Actual bounded JSON and Expat parsing; no arbitrary object deserialization, external entities, network requests or invoice authorization. Narrow invoice vocabulary, not a general XML schema engine.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- Python JSON decoder and compliance behavior. Python 3.15.
- Python XML security considerations. Python 3.15.
- Python Expat parsing callbacks and version behavior. Python 3.15.
- Python pickle: trusted data requirement. Python 3.15.
- libexpat changelog. Release 2.9.0, 5 October 2026.
- OWASP XML External Entity Prevention Cheat Sheet
- MITRE CWE-776: XML entity expansion. CWE 4.20.
Reference information
Python 3.13+ with Expat 2.6.0 or later; run the current Expat release. The project checks its parser runtime.
- SDLC phase
- Development
- ASVS 5.0.0 requirements
- v5.0.0-1.5.1 · v5.0.0-1.5.2 · v5.0.0-1.5.3 · v5.0.0-2.2.1