src/Sanitizer.php
<?php
namespace enshrined\svgSanitize;
use enshrined\svgSanitize\data\AllowedAttributes;
use enshrined\svgSanitize\data\AllowedTags;
use enshrined\svgSanitize\data\AttributeInterface;
use enshrined\svgSanitize\data\TagInterface;
use enshrined\svgSanitize\data\XPath;
use enshrined\svgSanitize\ElementReference\Resolver;
/**
* Class Sanitizer
*
* @package enshrined\svgSanitize
*/
class Sanitizer
{
/**
* @var \DOMDocument
*/
protected $xmlDocument;
/**
* @var array
*/
protected $allowedTags;
/**
* @var array
*/
protected $allowedAttrs;
/**
* @var
*/
protected $xmlLoaderValue;
/**
* @var bool
*/
protected $xmlErrorHandlerPreviousValue;
/**
* @var bool
*/
protected $minifyXML = false;
/**
* @var bool
*/
protected $removeRemoteReferences = false;
/**
* @var int
*/
protected $useThreshold = 1000;
/**
* @var bool
*/
protected $removeXMLTag = false;
/**
* @var int
*/
protected $xmlOptions = LIBXML_NOEMPTYTAG;
/**
* @var array
*/
protected $xmlIssues = array();
/**
* @var Resolver
*/
protected $elementReferenceResolver;
/**
* @var int
*/
protected $useNestingLimit = 15;
/**
* @var bool
*/
protected $allowHugeFiles = false;
/**
*
*/
function __construct()
{
// Load default tags/attributes
$this->allowedAttrs = array_map('strtolower', AllowedAttributes::getAttributes());
$this->allowedTags = array_map('strtolower', AllowedTags::getTags());
}
/**
* Set up the DOMDocument
*/
protected function resetInternal()
{
$this->xmlDocument = new \DOMDocument();
$this->xmlDocument->preserveWhiteSpace = false;
$this->xmlDocument->strictErrorChecking = false;
$this->xmlDocument->formatOutput = !$this->minifyXML;
}
/**
* Set XML options to use when saving XML
* See: DOMDocument::saveXML
*
* @param int $xmlOptions
*/
public function setXMLOptions($xmlOptions)
{
$this->xmlOptions = $xmlOptions;
}
/**
* Get XML options to use when saving XML
* See: DOMDocument::saveXML
*
* @return int
*/
public function getXMLOptions()
{
return $this->xmlOptions;
}
/**
* Get the array of allowed tags
*
* @return array
*/
public function getAllowedTags()
{
return $this->allowedTags;
}
/**
* Set custom allowed tags
*
* @param TagInterface $allowedTags
*/
public function setAllowedTags(TagInterface $allowedTags)
{
$this->allowedTags = array_map('strtolower', $allowedTags::getTags());
}
/**
* Get the array of allowed attributes
*
* @return array
*/
public function getAllowedAttrs()
{
return $this->allowedAttrs;
}
/**
* Set custom allowed attributes
*
* @param AttributeInterface $allowedAttrs
*/
public function setAllowedAttrs(AttributeInterface $allowedAttrs)
{
$this->allowedAttrs = array_map('strtolower', $allowedAttrs::getAttributes());
}
/**
* Should we remove references to remote files?
*
* @param bool $removeRemoteRefs
*/
public function removeRemoteReferences($removeRemoteRefs = false)
{
$this->removeRemoteReferences = $removeRemoteRefs;
}
/**
* Get XML issues.
*
* @return array
*/
public function getXmlIssues() {
return $this->xmlIssues;
}
/**
* Can we allow huge files?
*
* @return bool
*/
public function getAllowHugeFiles() {
return $this->allowHugeFiles;
}
/**
* Set whether we can allow huge files.
*
* @param bool $allowHugeFiles
*/
public function setAllowHugeFiles( $allowHugeFiles ) {
$this->allowHugeFiles = $allowHugeFiles;
}
/**
* Sanitize the passed string
*
* @param string $dirty
* @return string|false
*/
public function sanitize($dirty)
{
// Don't run on an empty string
if (empty($dirty)) {
return '';
}
do {
/*
* recursively remove php tags because they can be hidden inside tags
* i.e. <?p<?php test?>hp echo . ' danger! ';?>
*/
$dirty = preg_replace('/<\?(=|php)(.+?)\?>/i', '', $dirty);
} while (preg_match('/<\?(=|php)(.+?)\?>/i', $dirty) != 0);
$this->resetInternal();
$this->setUpBefore();
$loaded = $this->xmlDocument->loadXML($dirty, $this->getAllowHugeFiles() ? LIBXML_PARSEHUGE : 0);
// If we couldn't parse the XML then we go no further. Reset and return false
if (!$loaded) {
$this->xmlIssues = self::getXmlErrors();
$this->resetAfter();
return false;
}
// Pre-process all identified elements
$xPath = new XPath($this->xmlDocument);
$this->elementReferenceResolver = new Resolver($xPath, $this->useNestingLimit);
$this->elementReferenceResolver->collect();
$elementsToRemove = $this->elementReferenceResolver->getElementsToRemove();
// Start the cleaning process
$this->startClean($this->xmlDocument->childNodes, $elementsToRemove);
// Save cleaned XML to a variable
if ($this->removeXMLTag) {
$clean = $this->xmlDocument->saveXML($this->xmlDocument->documentElement, $this->xmlOptions);
} else {
$clean = $this->xmlDocument->saveXML($this->xmlDocument, $this->xmlOptions);
}
$this->resetAfter();
// Remove any extra whitespaces when minifying
if ($this->minifyXML) {
$clean = preg_replace('/\s+/', ' ', $clean);
}
// Return result
return $clean;
}
/**
* Set up libXML before we start
*/
protected function setUpBefore()
{
// This function has been deprecated in PHP 8.0 because in libxml 2.9.0, external entity loading is
// disabled by default, so this function is no longer needed to protect against XXE attacks.
if (\LIBXML_VERSION < 20900) {
// Turn off the entity loader
$this->xmlLoaderValue = libxml_disable_entity_loader(true);
}
// Suppress the errors because we don't really have to worry about formation before cleansing.
// See reset in resetAfter().
$this->xmlErrorHandlerPreviousValue = libxml_use_internal_errors(true);
// Reset array of altered XML
$this->xmlIssues = array();
}
/**
* Reset the class after use
*/
protected function resetAfter()
{
// This function has been deprecated in PHP 8.0 because in libxml 2.9.0, external entity loading is
// disabled by default, so this function is no longer needed to protect against XXE attacks.
if (\LIBXML_VERSION < 20900) {
// Reset the entity loader
libxml_disable_entity_loader($this->xmlLoaderValue);
}
libxml_clear_errors();
libxml_use_internal_errors($this->xmlErrorHandlerPreviousValue);
}
/**
* Start the cleaning with tags, then we move onto attributes and hrefs later
*
* @param \DOMNodeList $elements
* @param array $elementsToRemove
*/
protected function startClean(\DOMNodeList $elements, array $elementsToRemove)
{
// loop through all elements
// we do this backwards so we don't skip anything if we delete a node
// see comments at: http://php.net/manual/en/class.domnamednodemap.php
for ($i = $elements->length - 1; $i >= 0; $i--) {
/** @var \DOMElement $currentElement */
$currentElement = $elements->item($i);
/**
* If the element has exceeded the nesting limit, we should remove it.
*
* As it's only <use> elements that cause us issues with nesting DOS attacks
* we should check what the element is before removing it. For now we'll only
* remove <use> elements.
*/
if (in_array($currentElement, $elementsToRemove) && 'use' === $currentElement->nodeName) {
$currentElement->parentNode->removeChild($currentElement);
$this->xmlIssues[] = array(
'message' => 'Invalid \'' . $currentElement->tagName . '\'',
'line' => $currentElement->getLineNo(),
);
continue;
}
if ($currentElement instanceof \DOMElement) {
// If the tag isn't in the whitelist, remove it and continue with next iteration
if (!in_array(strtolower($currentElement->tagName), $this->allowedTags)) {
$currentElement->parentNode->removeChild($currentElement);
$this->xmlIssues[] = array(
'message' => 'Suspicious tag \'' . $currentElement->tagName . '\'',
'line' => $currentElement->getLineNo(),
);
continue;
}
$this->cleanHrefs( $currentElement );
$this->cleanXlinkHrefs( $currentElement );
$this->cleanAttributesOnWhitelist($currentElement);
if (strtolower($currentElement->tagName) === 'use') {
if ($this->isUseTagDirty($currentElement)
|| $this->isUseTagExceedingThreshold($currentElement)
) {
$currentElement->parentNode->removeChild($currentElement);
$this->xmlIssues[] = array(
'message' => 'Suspicious \'' . $currentElement->tagName . '\'',
'line' => $currentElement->getLineNo(),
);
continue;
}
}
// Strip out font elements that will break out of foreign content.
if (strtolower($currentElement->tagName) === 'font') {
$breaksOutOfForeignContent = false;
for ($x = $currentElement->attributes->length - 1; $x >= 0; $x--) {
// get attribute name
$attrName = $currentElement->attributes->item( $x )->nodeName;
if (in_array(strtolower($attrName), ['face', 'color', 'size'])) {
$breaksOutOfForeignContent = true;
}
}
if ($breaksOutOfForeignContent) {
$currentElement->parentNode->removeChild($currentElement);
$this->xmlIssues[] = array(
'message' => 'Suspicious tag \'' . $currentElement->tagName . '\'',
'line' => $currentElement->getLineNo(),
);
continue;
}
}
}
$this->cleanUnsafeNodes($currentElement);
if ($currentElement->hasChildNodes()) {
$this->startClean($currentElement->childNodes, $elementsToRemove);
}
}
}
/**
* Only allow attributes that are on the whitelist
*
* @param \DOMElement $element
*/
protected function cleanAttributesOnWhitelist(\DOMElement $element)
{
for ($x = $element->attributes->length - 1; $x >= 0; $x--) {
// get attribute name
$attrName = $element->attributes->item($x)->nodeName;
// Remove attribute if not in whitelist
if (!in_array(strtolower($attrName), $this->allowedAttrs) && !$this->isAriaAttribute(strtolower($attrName)) && !$this->isDataAttribute(strtolower($attrName))) {
$element->removeAttribute($attrName);
$this->xmlIssues[] = array(
'message' => 'Suspicious attribute \'' . $attrName . '\'',
'line' => $element->getLineNo(),
);
}
/**
* This is used for when a namespace isn't imported properly.
* Such as xlink:href when the xlink namespace isn't imported.
* We have to do this as the link is still ran in this case.
*/
if (false !== strpos($attrName, 'href')) {
$href = $element->getAttribute($attrName);
if (false === $this->isHrefSafeValue($href)) {
$element->removeAttribute($attrName);
$this->xmlIssues[] = array(
'message' => 'Suspicious attribute \'href\'',
'line' => $element->getLineNo(),
);
}
}
// Do we want to strip remote references?
if($this->removeRemoteReferences) {
// Remove attribute if it has a remote reference
if (isset($element->attributes->item($x)->value) && $this->hasRemoteReference($element->attributes->item($x)->value)) {
$element->removeAttribute($attrName);
$this->xmlIssues[] = array(
'message' => 'Suspicious attribute \'' . $attrName . '\'',
'line' => $element->getLineNo(),
);
}
}
}
}
/**
* Clean the xlink:hrefs of script and data embeds
*
* @param \DOMElement $element
*/
protected function cleanXlinkHrefs(\DOMElement $element)
{
$xlinks = $element->getAttributeNS('http://www.w3.org/1999/xlink', 'href');
if (false === $this->isHrefSafeValue($xlinks)) {
$element->removeAttributeNS( 'http://www.w3.org/1999/xlink', 'href' );
$this->xmlIssues[] = array(
'message' => 'Suspicious attribute \'href\'',
'line' => $element->getLineNo(),
);
}
}
/**
* Clean the hrefs of script and data embeds
*
* @param \DOMElement $element
*/
protected function cleanHrefs(\DOMElement $element)
{
$href = $element->getAttribute('href');
if (false === $this->isHrefSafeValue($href)) {
$element->removeAttribute('href');
$this->xmlIssues[] = array(
'message' => 'Suspicious attribute \'href\'',
'line' => $element->getLineNo(),
);
}
}
/**
* Only allow whitelisted starts to be within the href.
*
* This will stop scripts etc from being passed through, with or without attempting to hide bypasses.
* This stops the need for us to use a complicated script regex.
*
* @param $value
* @return bool
*/
protected function isHrefSafeValue($value) {
// Allow empty values
if (empty($value)) {
return true;
}
// Allow fragment identifiers.
if ('#' === substr($value, 0, 1)) {
return true;
}
// Allow relative URIs.
if ('/' === substr($value, 0, 1)) {
return true;
}
// Allow HTTPS domains.
if ('https://' === substr($value, 0, 8)) {
return true;
}
// Allow HTTP domains.
if ('http://' === substr($value, 0, 7)) {
return true;
}
// Allow known data URIs.
if (in_array(substr($value, 0, 14), array(
'data:image/png', // PNG
'data:image/gif', // GIF
'data:image/jpg', // JPG
'data:image/jpe', // JPEG
'data:image/pjp', // PJPEG
))) {
return true;
}
// Allow known short data URIs.
if (in_array(substr($value, 0, 12), array(
'data:img/png', // PNG
'data:img/gif', // GIF
'data:img/jpg', // JPG
'data:img/jpe', // JPEG
'data:img/pjp', // PJPEG
))) {
return true;
}
return false;
}
/**
* Removes non-printable ASCII characters from string & trims it
*
* @param string $value
* @return bool
*/
protected function removeNonPrintableCharacters($value)
{
return trim(preg_replace('/[^ -~]/xu','',$value));
}
/**
* Does this attribute value have a remote reference?
*
* @param $value
* @return bool
*/
protected function hasRemoteReference($value)
{
$value = $this->removeNonPrintableCharacters($value);
$wrapped_in_url = preg_match('~^url\(\s*[\'"]\s*(.*)\s*[\'"]\s*\)$~xi', $value, $match);
if (!$wrapped_in_url){
return false;
}
$value = trim($match[1], '\'"');
return preg_match('~^((https?|ftp|file):)?//~xi', $value);
}
/**
* Should we minify the output?
*
* @param bool $shouldMinify
*/
public function minify($shouldMinify = false)
{
$this->minifyXML = (bool) $shouldMinify;
}
/**
* Should we remove the XML tag in the header?
*
* @param bool $removeXMLTag
*/
public function removeXMLTag($removeXMLTag = false)
{
$this->removeXMLTag = (bool) $removeXMLTag;
}
/**
* Whether `<use ... xlink:href="#identifier">` elements shall be
* removed in case expansion would exceed this threshold.
*
* @param int $useThreshold
*/
public function useThreshold($useThreshold = 1000)
{
$this->useThreshold = (int)$useThreshold;
}
/**
* Check to see if an attribute is an aria attribute or not
*
* @param $attributeName
*
* @return bool
*/
protected function isAriaAttribute($attributeName)
{
return strpos($attributeName, 'aria-') === 0;
}
/**
* Check to see if an attribute is an data attribute or not
*
* @param $attributeName
*
* @return bool
*/
protected function isDataAttribute($attributeName)
{
return strpos($attributeName, 'data-') === 0;
}
/**
* Make sure our use tag is only referencing internal resources
*
* @param \DOMElement $element
* @return bool
*/
protected function isUseTagDirty(\DOMElement $element)
{
$href = Helper::getElementHref($element);
return $href && strpos($href, '#') !== 0;
}
/**
* Determines whether `<use ... xlink:href="#identifier">` is expanded
* recursively in order to create DoS scenarios. The amount of a actually
* used element needs to be below `$this->useThreshold`.
*
* @param \DOMElement $element
* @return bool
*/
protected function isUseTagExceedingThreshold(\DOMElement $element)
{
if ($this->useThreshold <= 0) {
return false;
}
$useId = Helper::extractIdReferenceFromHref(
Helper::getElementHref($element)
);
if ($useId === null) {
return false;
}
foreach ($this->elementReferenceResolver->findByElementId($useId) as $subject) {
if ($subject->countUse() >= $this->useThreshold) {
return true;
}
}
return false;
}
/**
* Set the nesting limit for <use> tags.
*
* @param $limit
*/
public function setUseNestingLimit($limit)
{
$this->useNestingLimit = (int) $limit;
}
/**
* Remove nodes that are either invalid or malformed.
*
* @param \DOMNode $currentElement The current element.
*/
protected function cleanUnsafeNodes(\DOMNode $currentElement) {
// Replace CDATA node with encoded text node
if ($currentElement instanceof \DOMCdataSection) {
$textNode = $currentElement->ownerDocument->createTextNode($currentElement->nodeValue);
$currentElement->parentNode->replaceChild($textNode, $currentElement);
// If the element doesn't have a tagname, remove it and continue with next iteration
} elseif (!$currentElement instanceof \DOMElement && !$currentElement instanceof \DOMText) {
$currentElement->parentNode->removeChild($currentElement);
$this->xmlIssues[] = array(
'message' => 'Suspicious node \'' . $currentElement->nodeName . '\'',
'line' => $currentElement->getLineNo(),
);
return;
}
if ( $currentElement->childNodes && $currentElement->childNodes->length > 0 ) {
for ($j = $currentElement->childNodes->length - 1; $j >= 0; $j--) {
/** @var \DOMElement $childElement */
$childElement = $currentElement->childNodes->item($j);
$this->cleanUnsafeNodes($childElement);
}
}
}
/**
* Retrieve array of errors
* @return array
*/
private static function getXmlErrors()
{
$errors = [];
foreach (libxml_get_errors() as $error) {
$errors[] = [
'message' => trim($error->message),
'line' => $error->line,
];
}
return $errors;
}
}