claude-news
blogPortSwigger Research·27 Oct 2026

Scalable Vector Grief

A PortSwigger researcher tested eight SVG sanitizers and bypassed six, including a trick where a response header runs JavaScript in Firefox.

Extract

Published: Tuesday, 27 October 2026 at 14:29 UTC Updated: Tuesday, 27 October 2026 at 14:29 UTC I tested eight popular SVG sanitizers and bypassed six of them. In this post, I'll show you how a response header can run JavaScript in Firefox even when the response body is completely harmless XML. I'll also show you how to get Safari to import a GIF as SVG and execute JavaScript from it. Hacking SVG sanitizers The targets SVG is an image format that can contain scripts, event handlers, links, animations and even embedded HTML, so applications that accept SVG uploads usually run them through a sanitizer first. To see how well those sanitizers hold up, I tested the most popular SVG libraries according to ChatGPT: https://github.com/darylldoyle/svg-sanitizerhttps://github.com/cloudflare/svg-hushhttps://github.com/cure53/DOMPurifyhttps://github.com/Borewit/svg-sanitizerhttps://github.com/bgalek/safe-svghttps://github.com/mattkrick/sanitize-svghttps://github.com/alnorris/SVG-Sanitizerhttps://github.com/jams2/py-svg-hushWarming up: namespace confusionLibrary affected: mattkrick/sanitize-svg This library has so many bypasses that I'll only cover the interesting ones. My first finding was that you don't need <foreignObject> to get HTML into an SVG. Switch the XML namespace on a single element to XHTML and the browser renders it as HTML directly inside the SVG. A <frame> with a JavaScript URL is all it takes: <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 namespace trick works with iframes, which gives you srcdoc to play with: <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="&lt;script&gt;alert(document.domain)&lt;/script&gt;"/></svg>The library could also be bypassed with a plain old <foreignObject>. It renders the SVG, which converts the element name to its camel-case form, foreignObject. It then hunts for the element with querySelectorAll('foreignobject'), which finds nothing, so the payload goes straight through: <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 got through: <svg xmlns="http://www.w3.org/2000/svg"><svg onload="alert(1)"/></svg>Animating your way to a JavaScript URLLibrary affected: Borewit/svg-sanitizer This one didn't take much effort. The …

Explained

What happened

A PortSwigger researcher tested eight popular open source SVG sanitizers and bypassed six of them. Only DOMPurify and darylldoyle/svg-sanitizer held up. The bypasses fall into a few families:

  1. Namespace confusion. Put a single element in the XHTML namespace inside an SVG and the browser renders it as HTML. A <frame> or <iframe> with a javascript: URL or a srcdoc then runs script, no <foreignObject> needed.
  2. Case mismatch. One library rendered the SVG (which turns foreignobject into camel-case foreignObject) and then searched for the lowercase name with querySelectorAll. It found nothing and let the payload through.
  3. Animation into a script URL. Libraries that allowed <animate> or <animateTransform> let an attacker animate a link's href into javascript:alert(1).
  4. Self-contained XSLT. An xml-stylesheet processing instruction can point at a data: URL holding an XSLT stylesheet. The stylesheet outputs an HTML page with a script that runs in the document's own origin.
  5. XSS from a response header. Firefox accepts a Link: <data:...>; rel=stylesheet; type=text/xsl header. Combined with the XSLT trick, a response whose body is harmless XML still executes JavaScript.
  6. Images imported as SVG in Safari. Safari's <use href="file.gif#x"> will parse a same-origin image file as SVG. An uploaded GIF with an SVG payload inside will not render on its own, but a <use> reference executes it.

Why it works

SVG is not an image format in the way PNG is. It is an XML document that can hold scripts, event handlers, links, animation, embedded HTML and external references. A sanitizer has to model everything the browser might do with that document, across three engines that each behave differently.

Most of these libraries use a blocklist of dangerous tags and attributes and check them by name. That fails whenever the browser reaches the same capability through another path: a different namespace, a different letter case, an animation that changes an attribute after sanitizing, a processing instruction, an HTTP header, or a cross-file reference. The sanitizer sees a safe document. The browser builds a dangerous one.

The bigger pattern

This is classic parser differential and mutation XSS: the checker and the executor disagree about what the input means. It is the same root cause behind HTTP request smuggling and many deserialization bugs.

What is new here is the reach of the XSLT and Link header tricks. They move the attack out of the SVG body entirely, so any defense that only inspects uploaded content misses them. The Safari <use> behavior is also notable because it turns any same-origin upload, even a GIF, into a possible script carrier.

What it means in practice

  • If you accept SVG uploads, assume a sanitizer alone is not enough. Use one with a strong record (DOMPurify with its SVG profile) and keep it updated.
  • Serve user uploads from a separate, cookieless domain so script that slips through runs in an origin with nothing to steal.
  • Set Content-Security-Policy: script-src 'none' and Content-Disposition: attachment on user files where possible, and set X-Content-Type-Options: nosniff.
  • Consider rasterizing SVG to PNG on upload, for example in your image or video thumbnail pipeline, and never serving the original.
  • Check that your CDN and app never let user input reach response headers such as Link.
  • When you write any allowlist check, compare against the parsed form the consumer will actually use, not the raw string.