Raiju/includes/svg-sanitizer.php
2026-10-25 06:05:42 +03:00

95 lines
4.6 KiB
PHP

<?php
/**
* Neutralise active content in uploaded SVG files.
*
* SVG is XML and may carry scripts, event handlers, external references and
* SMIL animations that set dangerous attributes. Browsers execute these when
* the file is opened directly (`/assets/images/logo.svg`), so an uploaded SVG
* is a stored-XSS vector. This sanitiser rewrites the file with the active
* parts removed; it refuses files it cannot parse or that carry an inline DTD.
*/
/** Allow only navigational schemes in SVG hyperlinks; block javascript:, data:, etc. */
function raijuSvgIsSafeHref(string $value): bool {
$probe = preg_replace('/[\x00-\x20\x7f]+/', '', html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($probe === '') return true;
if (preg_match('/^([a-z][a-z0-9+.\-]*):/i', (string)$probe, $match)) {
return in_array(strtolower($match[1]), ['http', 'https', 'mailto', 'tel'], true);
}
return true; // relative path, fragment or query — safe
}
/**
* Rewrite an SVG file in place with active content removed. Returns false when
* the file cannot be parsed as SVG or carries a construct we refuse to store
* (inline DTD/entities — XXE and entity-expansion risk); the caller then
* discards the upload.
*/
function raijuSanitizeSvgFile(string $path): bool {
if (!class_exists('DOMDocument')) return false; // cannot sanitise → refuse the upload
$raw = @file_get_contents($path);
if ($raw === false || trim($raw) === '') return false;
// No inline DTD or entities: defeats XXE and billion-laughs before parsing.
if (preg_match('/<!(DOCTYPE|ENTITY)/i', $raw)) return false;
$document = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $document->loadXML($raw, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded || !$document->documentElement
|| strtolower($document->documentElement->localName) !== 'svg') {
return false;
}
// Elements that run code or embed foreign/remote content have no place in a static image.
$removeTags = ['script', 'foreignobject', 'handler', 'iframe', 'embed', 'object', 'audio', 'video'];
// Animation elements are kept unless they target an event handler or a hyperlink.
$animationTags = ['set', 'animate', 'animatetransform', 'animatemotion'];
// Strip event handlers, dangerous link targets and url() payloads from one element.
$cleanAttributes = function (DOMElement $element): void {
foreach (iterator_to_array($element->attributes) as $attribute) {
$name = strtolower($attribute->localName);
$value = (string)$attribute->value;
if (str_starts_with($name, 'on')) { $element->removeAttributeNode($attribute); continue; }
if ($name === 'href' && !raijuSvgIsSafeHref($value)) { $element->removeAttributeNode($attribute); continue; }
// Paint/style properties can reference javascript: or data: via url(...).
if (in_array($name, ['style', 'filter', 'fill', 'stroke', 'mask', 'clip-path'], true)
&& preg_match('/url\s*\(\s*["\']?\s*(javascript|data)\s*:/i', $value)) {
$element->removeAttributeNode($attribute);
}
}
};
$walk = function (DOMNode $node) use (&$walk, $removeTags, $animationTags, $cleanAttributes): void {
foreach (iterator_to_array($node->childNodes) as $child) {
if ($child instanceof DOMProcessingInstruction) { $node->removeChild($child); continue; }
if (!$child instanceof DOMElement) continue;
$tag = strtolower($child->localName);
if (in_array($tag, $removeTags, true)) { $node->removeChild($child); continue; }
// SMIL that animates onload/href can inject script; drop the whole element.
if (in_array($tag, $animationTags, true)) {
$attributeName = strtolower(trim($child->getAttribute('attributeName')));
if ($attributeName !== '' && (str_starts_with($attributeName, 'on')
|| $attributeName === 'href' || $attributeName === 'xlink:href')) {
$node->removeChild($child);
continue;
}
}
$cleanAttributes($child);
$walk($child);
}
};
// The root <svg> can carry onload/href itself, so clean it before recursing.
$cleanAttributes($document->documentElement);
$walk($document->documentElement);
$output = $document->saveXML();
if ($output === false) return false;
return @file_put_contents($path, $output) !== false;
}