The hard problem of sanitizing user-uploaded SVGs (and why most libraries get it wrong)

The Scratch team’s blog post on SVG sanitization (linked from Hacker News this week) is one of those technical write-ups that really should be required reading for anyone who lets users upload images to a web app. The author’s account of trying to safely accept user-supplied SVG files for the Scratch project’s avatar and asset systems reads like a horror story, with each “we’ll just block this one thing” attempt running into another way an attacker can hide JavaScript inside what’s supposed to be a vector image.

The TL;DR if you’re shipping a web app that accepts SVG uploads: don’t, unless you really know what you’re doing. The longer version is a small tour of why SVG is the most dangerous “image format” you’ll ever serve.

Why SVG is different from PNG and JPEG

PNG and JPEG are byte-format images — pixels encoded with a header. The browser parses them, extracts pixels, paints them. There’s no executable surface in the file format. (Decoder bugs exist, but they’re CVE-grade rare and fixed quickly.)

SVG is XML. XML that the browser parses with the same engine that parses HTML. SVG can contain:

  • <script> tags. Yes, really. Inline JavaScript that runs when the SVG is rendered as a top-level document.
  • onload=, onerror=, onclick= attributes on basically any element. <svg onload="alert(1)"> is a working XSS payload in 2026 if your app serves SVG with the wrong Content-Type.
  • <foreignObject> sub-trees containing arbitrary XHTML, including iframes pointing at attacker-controlled URLs.
  • External entity references (XXE) that, on some legacy server-side parsers, will read files off your server’s disk and embed them.
  • CSS-via-<style> with url() directives that fetch external resources or run mostly-arbitrary attacks via @import.
  • References to other SVG sub-files via <use href="..."> — which, in the right circumstances, can execute scripts from the referenced fragment.

That’s why “I’ll just strip <script> tags” doesn’t work. There are a dozen other places executable behavior can hide.

If you must accept SVG, what to do

  • Use a real sanitizer library. DOMPurify on the client side, or its server-side equivalents (e.g. html-sanitizer in Python, bleach‘s SVG-aware fork, the svg-hush Rust crate). Don’t roll your own. The ones I’ve seen rolled by hand always miss something — usually <use href> or foreignObject.
  • Serve SVG from a separate origin. A different cookieless subdomain, ideally one that has no shared cookies or local storage with your main app. This way, even if a malicious SVG executes scripts when viewed at top-level, it can’t read or write anything that matters.
  • Set Content-Disposition: attachment on the served file when you can — this makes the browser download instead of render, eliminating the XSS surface entirely. Only useful if your use case is “download,” not “show inline.”
  • Serve via <img src="...">, never inline. Browsers run scripts inside SVG only when the SVG is the top-level document or embedded via <object>/<iframe>. When the same SVG is referenced from <img>, scripts and most active content are disabled. This isn’t a guarantee — there’s still some attack surface — but it’s a significant reduction.
  • If you’re rendering SVG server-side (e.g. converting to PNG for thumbnails), use a sandboxed renderer (Inkscape in a chroot, librsvg with strict options, a Lambda function). Never use a renderer that can fetch external resources from inside the SVG.

If you don’t actually need SVG

Convert. PNG and WebP are pixel formats. librsvg + resvg are well-maintained library converters that take an SVG and emit PNG or WebP. If you can do this conversion at upload time and never again show the original SVG to anyone, you’ve eliminated 99% of the attack surface — your users get a vector-quality input, you serve a pixel-format output. The only loss is “the SVG is no longer reusable as a vector elsewhere on your site,” which usually doesn’t matter.

This is what a lot of avatar / icon systems do internally now: accept SVG, convert to PNG at multiple sizes immediately, throw away the original. The cost is a few CPU seconds per upload; the benefit is that your “image upload” feature doesn’t accidentally become an “execute arbitrary JavaScript on every page that displays this avatar” feature.

The bigger lesson

SVG sanitization is one of those problems that feels like it should be solved. It isn’t. The XML standard is large, browser handling is inconsistent, and “safe” subsets are surprisingly hard to define when external references and CSS are in scope.

Whenever you find yourself maintaining a list of “tags to strip” from user-uploaded markup, you’re probably losing the security argument quietly. The list approach doesn’t fail loudly — it fails when an attacker uses a tag you forgot. Allow-lists (only these specific tags + attributes are kept, everything else dropped) are the only model that holds up over time, and DOMPurify-class libraries enforce them by default.

If your app accepts SVG today and you’ve never read your sanitizer’s allow-list, that’s the homework for this week.

Source: muffin.ink/blog/scratch-svg-sanitization

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.