September 23, 2026
Rendering User-Supplied SVG Without Getting Burned
An SVG is not an image. It is a program the browser will run. If you let users upload avatars, logos, or icons, you are letting them upload…

By Suleyman Musayev
3 min read
An SVG is not an image. It is a program the browser will run. If you let users upload avatars, logos, or icons, you are letting them upload code. What can hide in an SVG, and a small gem that finds it or cleans it out.
A PNG is data. Your program draws it and nothing it contains can act. An SVG is different: it is XML that a browser executes, which means it can carry a <script> tag, an onload= handler, a javascript: link, a <foreignObject> full of HTML, a reference that phones home to a tracking server, an <!ENTITY> payload that expands until your process runs out of memory, or a nest of <use> elements that unfolds into millions of nodes. Users love uploading logos and avatars. Attackers love that you let them.
So before you serve user SVG, you need to know what is in it. I wrote svg_sentinel to answer that: it reads an SVG and tells you what makes it dangerous, and if you want, rewrites it into a safe one.
Two modes: tell me, or clean it
Sometimes you want to reject anything suspicious and log why. Sometimes you want to keep the image but strip the danger out. svg_sentinel does both:
result = SvgSentinel.scan(svg_string)
result.safe? # => false
result.criticals # => [#<Finding code=:script_element … path="svg/script">]
clean = SvgSentinel.sanitize(svg_string) # a safe SVG String, or nilresult = SvgSentinel.scan(svg_string)
result.safe? # => false
result.criticals # => [#<Finding code=:script_element … path="svg/script">]
clean = SvgSentinel.sanitize(svg_string) # a safe SVG String, or nilscan reports and lets you decide. sanitize rewrites the SVG using an allowlist and hands back the cleaned version, or nil when the threat is structural and cannot be safely rewritten at all. Each finding carries a severity, a category, and the path in the document where it lives, so "this SVG is bad" becomes "this SVG is bad, here, because of this."
What it actually catches
The obvious things: <script>, on* handlers, javascript: and scriptable data: URIs. But the interesting catches are the ones people forget:
- External references, including in CSS. A
url(https://evil.example/...)hidden in a stylesheet is still a callout to someone else's server. svg_sentinel checks attributes and CSS, not just the easy places. - XXE and entity expansion. A
DOCTYPEwith entities can read local files or balloon into a memory bomb. svg_sentinel refuses DTDs outright, so the parser can never be turned against you. <use>expansion bombs. A handful of nested<use>elements can unfold into millions of nodes and hang your renderer. That is a denial of service in a logo.
The obfuscated variants are covered too: whitespace and control characters smuggled into a javascript: URI get normalized away before the check, because an attacker who can hide the colon can hide the payload.
Safe to point at hostile input
A tool that inspects attacks has to survive them. This is the part I spent the most care on. Input is normalized to valid UTF-8 first, so a single stray byte can never crash the scanner. Before the real parser runs, a streaming pass enforces a hard byte cap and limits on depth, node count, and attributes, so a pathological payload is refused cheaply instead of eating your memory. DTDs are rejected. And every parse failure becomes a finding, not an exception. Nothing is ever fetched over the network. The scanner assumes everything it is handed was written by someone who wants to hurt it, because sometimes it was.
It knows how the SVG will be used
Here is a nice subtlety: the same SVG is dangerous in different amounts depending on how you use it. An SVG dropped inline into your page can run scripts. The exact same SVG loaded through an <img> tag or a CSS background cannot, because browsers run those in a scriptless mode. svg_sentinel takes a context: and re-rates its findings accordingly:
SvgSentinel.scan(svg, context: :img) # scripting findings downgraded, safelySvgSentinel.scan(svg, context: :img) # scripting findings downgraded, safelyContext does not turn off the checks that still matter (an expansion bomb is a bomb no matter how you load it), but it stops you from rejecting a perfectly safe inline-only risk in a place where it can never fire.
Allowlist, not denylist
The strict profile is an allowlist: it permits a small, known-safe set of static SVG elements and flags everything else. This matters more than it sounds. A denylist protects you from the attacks you thought of; an allowlist protects you from the ones you did not, because a new dangerous element is caught simply for not being on the list. When the cost of being wrong is code execution, "deny what I know is bad" is the wrong default. "Permit only what I know is safe" is the right one.
There is also a general profile that keeps every security check but relaxes the cosmetic rules, and a command-line tool that speaks SARIF, so you can wire SVG scanning straight into a CI pipeline and let it fail a build on a dangerous upload.
Takeaways
- SVG is executable XML. Treat user-uploaded SVG as untrusted code, not as an image.
- The forgotten risks are external references in CSS, XXE via DOCTYPE, and
<use>expansion bombs. Cover those, not just<script>. - A tool that inspects hostile input has to be hardened itself: normalize the bytes, cap the size and structure, refuse DTDs, and turn failures into findings instead of exceptions.
- Prefer an allowlist. It catches the dangerous thing you never thought to ban.
svg_sentinel is on GitHub and RubyGems, pure Ruby, MIT licensed.
I am a backend engineer. I work on authentication, PKI, and cryptography in Ruby and C, and, occasionally, the fraud and abuse that come with running them. More at msuliq.github.io.