---
title: "An SVG Is a Document Wearing an Image Extension"
slug: "svg-sanitizer-bypasses"
language: "en"
date: "2026-10-28T10:34:47.167Z"
updated: "2026-10-28T10:34:47.000Z"
description: "Six of eight SVG sanitizers bypassed: namespace confusion, animation, XSLT in data URLs, and a response header Firefox obeys."
canonical: "https://fix.slopit.io/svg-sanitizer-bypasses/"
tags: ["security", "web", "svg", "xss"]
---

Gareth Heyes tested eight popular SVG sanitizers and bypassed six of them. The techniques are a tour of everything that makes SVG a bad format to accept from strangers.

SVG is XML that browsers treat as a document. It can contain scripts, event handlers, links, animations, embedded HTML, and — through XSLT — arbitrary transformations. Applications that accept SVG uploads usually run them through a sanitizer first. The sanitizer's job is to enumerate what's dangerous and remove it. The browser's job, from the attacker's perspective, is to keep adding features that compose into execution.

## Three families of bypass

**Namespace and case confusion.** You don't need `<foreignObject>` to get HTML into an SVG. Switch the XML namespace on one element to XHTML and the browser renders it as HTML inside the SVG — so a `<frame src="javascript:alert(document.domain)">` just works. Same with `<iframe srcdoc="...">`.

A subtler one: the browser converts `<foreignobject>` to camelCase `foreignObject` when parsing, but a sanitizer that later searches with `querySelectorAll('foreignobject')` finds nothing. The element is there; the selector isn't. A third: a nested `<svg onload="alert(1)"/>` goes straight through.

None of these are clever cryptography. They're mismatches between the parser's model and the sanitizer's model — the exact place where filters fail.

**Animation.** One library let `<animate>` through, so you can animate a link's `href` into a `javascript:` URL after load. Another bypass uses `<animateTransform>` to set a JavaScript URL in Safari, a technique credited to Kevin Mizu.

The general shape: a sanitizer checks the static attribute value, and the browser changes it later. Any sanitizer that validates once, before the document can animate, is checking a value that won't be the value.

**XSLT and data URLs.** This is the best one. An `xml-stylesheet` processing instruction can point at a data URL containing an XSLT stylesheet, with no separate file needed. The stylesheet matches the document root, outputs HTML, and uses `xsl:element name="{concat('scr','ipt')}"` to build a script tag — so the string "script" never appears literally in the payload. It executes in the document's origin.

Heyes notes the feature is deprecated and due for removal in November 2026. That's a good reminder that deprecation is not a security control: until it's gone, it's a bypass, and after it's gone, the next feature is the bypass.

## The header trick

The finding I'd file under "sanitizers don't model the whole response": Firefox lets you load external CSS via a `Link` response header, and data URLs work there too. Heyes worked out that the same applies to XSLT.

So you send a response whose body is harmless XML, with a `Link: <data:application/xml,...>; rel="stylesheet"; type="text/xsl"` header. The stylesheet runs, copies an `<img onerror>` into a `<foreignObject>`, and the JavaScript fires. The body a sanitizer inspected is clean. The document the browser built is not.

That's the deeper lesson: a sanitizer that only sees the body is sanitizing one of the inputs. Response headers, content type, and the embedding context are part of the document too.

## Safari's image import

Safari allows `<use href="...">` to import same-origin files, and it goes further than Firefox: it will import an image file as SVG. Upload a GIF containing the XML payload; it won't render when opened directly, but `<use href="/svg/test.gif#x">` imports it and executes the handler inside.

An image upload path that validates extensions or MIME types, and a sanitizer that only looks at `.svg` files, is defeated by a file the pipeline thought was a GIF.

## What held

DOMPurify and darylldoyle/svg-sanitizer survived everything in the post. That's worth noting: allowlist-based sanitizers that parse with the browser's own parser, rather than pattern-matching strings, are the ones that hold up. The failures cluster in libraries that enumerate bad things.

## My take

The right conclusion isn't "use DOMPurify" — although you should. It's that sanitizing SVG perfectly is an unbounded commitment. You're tracking a document format that the browser keeps extending, and every new feature is a potential composition.

The architectural answer is to stop treating sanitization as the only control: serve user-supplied SVG from a separate origin with no cookies, no ambient authority, and a restrictive content security policy, so that even a successful injection has nothing to steal. Sanitize as defense in depth, not as the boundary.

Heyes's post is a catalogue of why. Eight libraries, six bypasses, and two unnamed applications he's still disclosing.

*Source: [Scalable Vector Grief — Gareth Heyes, PortSwigger Research](https://portswigger.net/research/scalable-vector-grief).*
