Vulnerability researchHigh · CVSS 8.2

CVE-2026-73650: bypassing SVGO's removeScripts filter

Two gaps between how SVGO recognizes a script and how a browser executes one. The result: JavaScript that survives optimization, and stored XSS for any app that trusts the filter.

Role
Discovery & report
Severity
High · 8.2
Advisory
Jul 11, 2026
CVE
Aug 13, 2026

01Context

SVGO is the go-to tool for optimizing SVG files in Node.js. It is embedded in many build tools and image pipelines and gets a very large number of weekly downloads.

One of its plugins, removeScripts, makes a specific promise: strip anything that can execute JavaScript from an SVG. In practice, applications use it as a security filter, for example to "clean" user-uploaded SVG avatars or logos before serving them from their own domain.

02How I found it

I was reading SVGO's source just to understand how the project was put together. removeScripts caught my attention for two reasons:

  • it's one of the only plugins making an explicit security claim;
  • it's used at a huge scale, so any gap has real impact.

So I looked at exactly how it recognizes a script. The answer: pattern matching. It looks for <script> elements and javascript: URIs. Both checks turned out to be stricter than what a browser actually accepts.

03The two bypasses

a. Namespace-prefixed script element

The plugin only recognizes a bare <script> tag. Declare a custom prefix bound to the SVG namespace and the tag survives untouched:

<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
  <svg:script>alert(document.domain)</svg:script>
</svg>

b. Case-sensitive javascript: matching

The scheme check compares the URI against a lowercase literal. A capitalized variant isn't caught:

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <a xlink:href="JavaScript:alert(document.domain)"><text>click</text></a>
</svg>

04Root cause

Both bugs share the same origin: a parser differential between the filter and the browser.

  • XML namespaces: browsers resolve elements by namespace URI, not by the prefix string. svg:script and script are the very same element, and the browser runs it.
  • URI schemes: RFC 3986 defines schemes as case-insensitive. JavaScript:, JAVASCRIPT: and javascript: are all equivalent to the browser.
The general lesson: a string-matching blocklist has to reason exactly like the final parser. Every normalization difference (case, namespace, encoding, whitespace) becomes a bypass.

05Impact

An "optimized" SVG carrying either payload executes as soon as it's opened directly or inlined, on whatever origin serves it. The realistic case: an app accepts user SVGs, runs them through removeScripts believing they're now safe, then serves them from its own domain. The result: stored XSS, with access to the victim's session on that domain.

Severity: High, CVSS 8.2.

06Disclosure & fixes

I reported both issues privately to the maintainers, with a proof of concept covering both variants. Details were only published once a fix was available (coordinated disclosure). Thanks to SethFalco and the SVGO team for the quick turnaround.

BranchAffectedFixed in
v11.0.0 – 1.3.2never (deprecated)
v22.0.0 – 2.8.22.8.3 (partial*)
v33.0.0 – 3.3.33.3.4
v44.0.0 – 4.0.14.0.2

* v2 never handled javascript: URIs at all: only the namespace bypass is closed there.

  1. July 11, 2026: advisory GHSA-2p49-hgcm-8545 published
  2. August 13, 2026: CVE-2026-73650 assigned

07Takeaways

  • Reading a filter's code means reading its assumptions. Every string comparison is a question: "does the browser think the same way?"
  • Allowlist over blocklist. Keeping only known-safe elements and attributes is sturdier than chasing dangerous ones.
  • Defense in depth on the app side: serve user files from a separate domain with a strict Content-Security-Policy, and use an actual sanitizer (e.g. DOMPurify) rather than an optimizer.
  • Disclosure is communication too: a clear report, a minimal reproducible PoC, and patience.