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:scriptandscriptare the very same element, and the browser runs it. - URI schemes: RFC 3986 defines schemes as case-insensitive.
JavaScript:,JAVASCRIPT:andjavascript:are all equivalent to the browser.
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.
| Branch | Affected | Fixed in |
|---|---|---|
| v1 | 1.0.0 – 1.3.2 | never (deprecated) |
| v2 | 2.0.0 – 2.8.2 | 2.8.3 (partial*) |
| v3 | 3.0.0 – 3.3.3 | 3.3.4 |
| v4 | 4.0.0 – 4.0.1 | 4.0.2 |
* v2 never handled javascript: URIs at all: only the namespace bypass is closed there.
- July 11, 2026: advisory GHSA-2p49-hgcm-8545 published
- 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.