Every XML Feature Is One You Turn Off

Last post ended on a question. XML is the format in this series that specified everything, so what did all that specifying buy?

An answer for every question, and an attack surface made entirely of answers.

The other formats in this series failed by leaving things out. CSV never had a standard. JSON declined to say what a number means. YAML wrote the rules down and then most implementations kept using the old ones. XML failed the other way. It defined a schema language, a query language, a transformation language, a namespace system, and an entity system, and the entity system will read files off your disk.


Two Kinds of Correct

Start with something XML got right, because it is the only format here that made this distinction at all.

The specification defines two separate bars:

A data object is an XML document if it is well-formed, as defined in this specification. In addition, the XML document is valid if it meets certain further constraints.

Well-formed is syntax. Tags match, there is one root, attributes are quoted. Valid is semantics: the document declares a schema and conforms to it.

CSV has neither concept. JSON has only the first one. XML separated them in 1998, which was ahead of its time, and then almost nobody used the second one. Most XML in production is well-formed and unvalidated, which means it has exactly the same guarantees as JSON with more punctuation.


The Entity System

XML documents are built from entities. Five are predefined, and you know them:

&  <  >  "  '

You can also declare your own in the document type declaration, which is how you get constants in a config file. And an entity can be declared to pull its content from somewhere else:

<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "file:///tmp/xmltest/secret.txt">
]>
<foo>&xxe;</foo>

That is a legal XML document. SYSTEM means “go get it.” Here is what a parser does with it:

ElementTree  ParseError: undefined entity &xxe;
lxml         XMLSyntaxError: Entity 'xxe' not defined
defusedxml   EntitiesForbidden(name='xxe', system_id='file:///tmp/xmltest/secret.txt')
lxml, resolve_entities=True    'SECRET-CANARY-12345\n'

The first three refuse. The fourth read a file off the disk and put its contents in the document tree.

The difference between the second line and the fourth is one keyword argument. Not a patch, not an old version, not a misconfiguration. A parser option, on a current library, named after something that sounds like ordinary XML processing. Of course you want entities resolved. Entities are how the format works.

This is XXE, and it is not a bug in any implementation. Every one of those parsers is behaving as specified. The specification says an external entity is fetched, so the ones that fetch it are correct and the ones that refuse are deliberately non-conforming for your safety.


The Bomb That Got Fixed

The same entity system nests, which produces the XML version of the billion laughs attack:

<!DOCTYPE lolz [
  <!ENTITY lol "lol">
  <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
  <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
]>

Each level multiplies by ten. A 291-byte file at three levels expands to 3,000 characters. Add two more levels and the arithmetic says 300,000, and the file is still under 500 bytes.

Except it doesn’t, and this is the part worth reporting honestly. At five levels, current libxml2 stops:

XMLSyntaxError: Maximum entity amplification factor exceeded,
see xmlCtxtSetMaxAmplification

It refuses even with resolve_entities=True. Somebody went and put a ratio limit in the parser, and it works. Compare that to YAML, where safe_load still expands aliases without complaint, and the equivalent 202-byte payload produced 74,732 nodes with no objection at all.

So the XML ecosystem fixed the denial-of-service and left the file-read one flag away. That ordering tells you something about which failure people actually hit.


What the Rest of It Bought

XML shipped an enormous amount of specification, and the pieces are individually good:

  • XSD defines 19 primitive datatypes with real inheritance, so <price>12.50</price> can be a decimal rather than a hopeful string. This is precisely what CSV lacks and what JSON refuses to commit to.
  • XPath addresses any node in a document without writing a traversal.
  • XSLT transforms one document into another declaratively.
  • Namespaces let two vocabularies coexist in one file without colliding, which is the problem every format in this series either ignores or solves with a naming convention.

None of that is bad engineering. Read the list again and notice that it is a description of the problems the other five posts were about. XML solved them. In 1998.

The cost was that using XML correctly means knowing which parts to switch off, and the defaults were set in an era that assumed documents came from people you knew. Every hardening guide for XML is a list of features to disable: no DTDs, no external entities, no network access, no schema fetching.

There is a version of this where the lesson is “XML was too complicated.” I don’t think that’s it. The formats that replaced it are simpler and they have the same problems, plus the ones XML had already solved. Nobody misses XSLT, and everybody has now written their own worse version of it.


What To Do About It

  • Disable DTD processing entirely unless you know you need it. In Python that is defusedxml; in Java it is disallow-doctype-decl. This closes XXE and entity expansion in one move.
  • Never enable resolve_entities on input you did not write. It is the single flag that turns a parser into a file reader.
  • Validate against a schema at the trust boundary, not just parse. Well-formed is not a security property. It is barely a correctness property.
  • Stream large documents. SAX and StAX read in constant memory; DOM builds the whole tree first, which is its own denial of service if the document is attacker-sized.
  • Know your parser’s defaults and pin them explicitly. They have changed over time, usually toward safety, and code that relies on a safe default is one dependency upgrade from a different one.

XML answered every question these posts have raised. It has types, a validation model, a query language, and a namespace system, and it had all of them a decade before the formats that replaced it. What it could not do was make the safe path the default one, and that turned out to matter more than any of the rest of it.

That is the actual pattern across this whole series. Not that formats are underspecified or overspecified, but that the defaults are the specification most people ever use.

Sources

I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].

Programming security File-formats Parsing Xml