XML in Production · Security and Integration
The 1 KB Request That Took the API Down
A SOAP endpoint expanded nested internal entities. One small request expanded to gigabytes in memory and took every worker down.
Scenario
Example team: a legacy SOAP API used by travel agents parses requests with a library configured years ago to "support entities". Rate limits are by request count, and the largest request size allowed is 1 MB.
What you see in production
Within a minute of one agent's misbehaving script sending a few small requests, every API worker sits at 100% CPU with memory climbing until it's killed. Requests are tiny; nothing looks unusual in the access log.
The naive code
parser = etree.XMLParser(resolve_entities=True, huge_tree=True) # "support entities", "allow big documents"
etree.fromstring(request_body, parser)
Reproduce it
Modern libxml2 has an amplification limit; the simulation shows how fast expansion grows and where the limit stops it:
from lxml import etree
def bomb(levels, fanout=10):
decls = ['<!ENTITY e0 "lol">'] + [f'<!ENTITY e{i} "{f"&e{i - 1};" * fanout}">' for i in range(1, levels + 1)]
return f"<!DOCTYPE b [{''.join(decls)}]><b>&e{levels};</b>".encode()
for levels in (3, 5, 9):
data = bomb(levels)
expanded = 3 * 10 ** levels
try:
doc = etree.fromstring(data, etree.XMLParser(resolve_entities=True))
print(f"{levels} levels: {len(data)} bytes -> {len(doc.text):,} characters")
except etree.XMLSyntaxError as e:
print(f"{levels} levels: {len(data)} bytes -> would be {expanded:,} characters; stopped: {e.msg[:45]}")
try:
doc = etree.fromstring(bomb(9), etree.XMLParser(resolve_entities=False, load_dtd=False))
print("entities off:", repr(doc.text))
except etree.XMLSyntaxError as e:
print("entities off: still rejected while reading the declarations:", e.msg[:45])
print("pre-check:", "rejected, DOCTYPE not allowed" if b"<!DOCTYPE" in bomb(9) else "accepted")
Output
3 levels: 209 bytes -> 3,000 characters 5 levels: 319 bytes -> would be 300,000 characters; stopped: Maximum entity amplification factor exceeded, 9 levels: 539 bytes -> would be 3,000,000,000 characters; stopped: Maximum entity amplification factor exceeded, entities off: still rejected while reading the declarations: Maximum entity amplification factor exceeded, pre-check: rejected, DOCTYPE not allowed
Root cause
Internal entities can reference other entities; ten references per level for nine levels gives 10^9 copies (the "billion laughs" attack, CWE-776). Size limits on the request don't help, because the cost appears after parsing starts. Options like huge_tree=True disable built-in protections.
The fix
- Disable DTD processing for request bodies (the same setting that stops XXE).
- Keep library protections on: don't set
huge_tree=Trueor raise entity limits for untrusted input. - Put limits around parsing: maximum body size, a parse timeout, and memory limits per worker so one request can't take the whole pool.
Trade-offs
- Legitimate entity use in request bodies is extremely rare; turning DTDs off almost never breaks real clients.
- Worker memory limits and timeouts protect against other expensive inputs too (deep nesting, huge attributes).
Detect it in production
- Alerts on worker restarts, OOM kills and CPU saturation without a matching traffic increase.
- Log and block request bodies containing
<!ENTITY.
Key points
- Small XML can expand enormously; size limits on the request aren't enough.
- Disable DTDs, keep parser limits, and bound each request's CPU and memory.
Practice questions
What is a billion laughs attack and how do you prevent it?
Answer
A document defines entities that reference other entities many times, so a tiny file expands to gigabytes when parsed (CWE-776). Disable DTD/entity processing for untrusted input and keep the parser's expansion limits on.
Open in the app to mark lessons complete, take notes and track your progress.