← Research ledger
MediumGHSA-9rjx-3jch-6vjfCVSS 5.4Stored XSSPHP / svg-sanitizeFixed in 1.0.0· 8 min read

The entity that changed meaning: stored XSS through svg-sanitize's href check

A DTD entity named 	 is a hash sign to the sanitizer's XML parser and a TAB character to the browser's HTML5 parser. One character of difference in meaning is all it takes to walk a javascript: URL through the most popular SVG sanitizer in the PHP ecosystem — untouched.

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 bug in the sanitizer itself). Fixed in 1.0.0. Published as GHSA-9rjx-3jch-6vjf under coordinated disclosure. Exploitation requires the sanitized SVG to be rendered inline in HTML.

Summary

enshrined/svg-sanitize is the standard answer to "how do we let users upload SVGs safely" — 45.2 million Packagist downloads, the engine behind the WordPress Safe SVG plugin (1M+ active installs), and a dependency of TYPO3, Drupal modules and 90+ other packages. Its job is to guarantee that whatever comes out the other end contains no active content. This finding breaks that guarantee.

The bug is a context collision, not a parser bug. XML and HTML5 each give meaning to the token 	 — but different meanings. In XML, a DTD can define an entity named Tab with any replacement text, say #. In HTML5, 	 is a built-in Named Character Reference that resolves to U+0009 TAB. The sanitizer validates the expanded value (an innocent fragment link) and ships the reference (a whitespace-prefixed javascript: URL the browser is happy to execute on click). The same trick works with 
 (U+000A) and any other HTML5 reference whose name an attacker can register as a DTD entity.

The mechanism, step by step

StepWhereWhat happens
1Attacker's SVGA DTD declares <!ENTITY Tab "#"> — the entity name deliberately collides with the HTML5 Named Character Reference &Tab;.
2Attacker's SVGA link carries the payload: <a href="&Tab;javascript:alert(document.domain)"> with a big red CLICK ME rect as bait.
3Sanitizer (XML context)DOMDocument expands the entity: the href value becomes #javascript:alert(document.domain). isHrefSafeValue() sees a leading # — a fragment reference — and returns TRUE.
4Sanitizer outputsaveXML() serializes the attribute with the entity reference &Tab; preserved, while the DOCTYPE that gave it meaning is stripped from the document.
5Victim's browser (HTML5 context)Rendered inline, &Tab; now resolves as an HTML5 Named Character Reference → U+0009 TAB. The URL parser strips leading whitespace, leaving javascript:alert(document.domain). One click, code execution in the page origin.

The elegance — and the danger — is that every individual component does its documented job. The XML parser expands what it is told to expand. The href check correctly identifies fragments as safe. The serializer preserves a legal reference. The browser parses valid HTML5. The vulnerability lives in the seam between two specifications that read the same five bytes differently.

Root cause

The href guard in the sanitizer whitelists fragment links — anything starting with # points inside the document and can do no harm:

// isHrefSafeValue() — the fragment shortcut (representative sketch)
if (strpos($value, '#') === 0) {
    return true;   // "#..." is a fragment — no scheme check ever runs}

By itself, that check is fine. The defect is what it checks. Validation evaluates the expanded value — #javascript:..., after the XML parser resolved the entity — while the output document keeps the reference — &Tab;javascript:.... Two contexts, two different strings, one false equivalence: the sanitizer certified a string it never actually shipped. This is a pure logic bug inside svg-sanitize; it reproduces on any PHP version and has nothing to do with any ext/dom or libxml2 quirk. The DOCTYPE strip in the output stage completes the trap: the definition travels no further, but the reference does, and the next parser in line — the browser — applies its own definition.

Proof of concept

The malicious upload:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [
  <!ENTITY Tab "#">   <!-- name collides with the HTML5 reference &Tab; -->
]>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60">
  <a href="&Tab;javascript:alert(document.domain)">    <rect width="200" height="60" fill="#d33"/>
    <text x="100" y="35" fill="#fff" text-anchor="middle">CLICK ME</text>
  </a>
</svg>

Sanitizing it the way every downstream project does:

<?php
use enshrined\svgSanitize\Sanitizer;

$sanitizer = new Sanitizer();
$clean = $sanitizer->sanitize(file_get_contents('evil.svg'));
file_put_contents('clean.svg', $clean);   // passes clean — payload intact

The sanitized output — DOCTYPE gone, the reference intact:

<!-- after sanitize(): DOCTYPE stripped, &Tab; reference preserved -->
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60">
  <a href="&Tab;javascript:alert(document.domain)">    <rect width="200" height="60" fill="#d33"/>
    <text x="100" y="35" fill="#fff" text-anchor="middle">CLICK ME</text>
  </a>
</svg>

What the browser makes of it:

HTML5 tokenizer:  &Tab;  →  U+0009 TAB          // Named Character Reference, no DOCTYPE needed
URL parser:       strips leading whitespace from the attribute
                  "\tjavascript:alert(document.domain)"
                  → "javascript:alert(document.domain)"
click             →  alert(document.domain) fires in the page origin

Confirmed end-to-end in Chrome 148: the sanitized file, embedded inline in an HTML page, executes on click. &NewLine; (U+000A) works as an alternative reference for the same primitive.

Where it fires — and where it doesn't

The context requirement is the one thing standing between this bug and mass exploitation: the payload only detonates when the sanitized SVG is rendered inline in an HTML document. That is the common high-value case — themes and plugins inline SVGs for styling, and Safe SVG itself documents inline rendering as the way to get CSS/JS-capable vector images.

An <img src="....svg"> embedding is not affected: the image is loaded through the XML parser, where &Tab; without its DOCTYPE is an undefined entity — a parse error, not a TAB. The collision needs the HTML5 tokenizer to be the one reading the reference, and that only happens in an HTML document.

Impact

This is stored XSS through a security boundary whose entire purpose is to prevent it. The attack chain in the real world: a user with upload privileges — a WordPress Author role is enough — uploads the crafted SVG through any svg-sanitize-protected upload path. It passes clean and is stored. When the file is later rendered inline in a page and someone clicks the big red rect, JavaScript runs in that page's origin: session theft, and — if the clicker is an administrator — full account takeover and site compromise.

Honest scoping

The official score is CVSS 3.1: 5.4 (Medium) — CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N — and it is fair: the attacker needs upload privileges (PR:L), a victim must click (UI:R), and the file must be rendered inline. The advisory also explores a 6.1 scoring variant; we report the official 5.4. But the preconditions describe the default posture of huge deployments: Safe SVG alone runs on 1M+ WordPress sites where multi-author uploads and inline SVGs are routine. A sanitizer bypass in a library with 45.2M downloads is a supply-chain-sized blast radius even at Medium — the score describes one victim's click, not the number of doors the key fits.

The fix

Patched upstream in 1.0.0. The advisory laid out three remediation options, in order of preference:

  1. Strip the DOCTYPE before parsing (recommended). No DTD, no attacker-defined entities, no collision — the advisory provides a ready regex for pre-parse removal.
  2. Re-validate hrefs after serialization. Check the attribute values that actually ship, not the ones seen mid-parse — closing the validate/output gap directly.
  3. Expand entities before validation. Resolve all entity references up front so the validator and the output see the same string.

Option 1 removes the entire class; options 2 and 3 are defense-in-depth for any sanitizer that validates in one representation and emits another.

Disclosure timeline

  • The collision was found by Denis Rostilov during an ExPatch audit of svg-sanitize's attribute pipeline and confirmed end-to-end in Chrome 148.
  • Reported privately to the maintainer via GitHub Security Advisories, with the PoC and the three remediation options.
  • Fix shipped upstream in 1.0.0.
  • GHSA-9rjx-3jch-6vjf published; ExPatch-LLC / ExPatch Security Research credited as reporter. No CVE was assigned — the GHSA is the identifier.
  • 2026-09-25 — this writeup.

References