<?php
namespace Elementor\Core\Utils\Svg;
use Elementor\Utils;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Elementor SVG Sanitizer.
*
* A class that is responsible for sanitizing SVG files.
*
* @since 3.16.0
*/
class Svg_Sanitizer {
/**
* @var \DOMDocument
*/
private $svg_dom = null;
/**
* Sanitize File
*
* @since 3.16.0
* @access public
*
* @param $filename
* @return bool
*/
public function sanitize_file( $filename ) {
$original_content = Utils::file_get_contents( $filename );
$is_encoded = $this->is_encoded( $original_content );
if ( $is_encoded ) {
$decoded = $this->decode_svg( $original_content );
if ( false === $decoded ) {
return false;
}
$original_content = $decoded;
}
$valid_svg = $this->sanitize( $original_content );
if ( false === $valid_svg ) {
return false;
}
// If we were gzipped, we need to re-zip
if ( $is_encoded ) {
$valid_svg = $this->encode_svg( $valid_svg );
}
file_put_contents( $filename, $valid_svg );
return true;
}
/**
* Sanitize
*
* @since 3.16.0
* @access public
*
* @param $content
* @return bool|string
*/
public function sanitize( $content ) {
// Strip php tags
$content = $this->strip_comments( $content );
$content = $this->strip_php_tags( $content );
$content = $this->strip_line_breaks( $content );
// Find the start and end tags so we can cut out miscellaneous garbage.
$start = strpos( $content, '<svg' );
$end = strrpos( $content, '</svg>' );
if ( false === $start || false === $end ) {
return false;
}
$content = substr( $content, $start, ( $end - $start + 6 ) );
// If the server's PHP version is 8 or up, make sure to Disable the ability to load external entities
$php_version_under_eight = version_compare( PHP_VERSION, '8.0.0', '<' );
if ( $php_version_under_eight ) {
$libxml_disable_entity_loader = libxml_disable_entity_loader( true ); // phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated
}
// Suppress the errors
$libxml_use_internal_errors = libxml_use_internal_errors( true );
// Create DomDocument instance
$this->svg_dom = new \DOMDocument();
$this->svg_dom->formatOutput = false;
$this->svg_dom->preserveWhiteSpace = false;
$this->svg_dom->strictErrorChecking = false;
$open_svg = $this->svg_dom->loadXML( $content );
if ( ! $open_svg ) {
return false;
}
$this->strip_doctype();
$this->sanitize_elements();
// Export sanitized svg to string
// Using documentElement to strip out <?xml version="1.0" encoding="UTF-8"...
$sanitized = $this->svg_dom->saveXML( $this->svg_dom->documentElement, LIBXML_NOEMPTYTAG );
// Restore defaults
if ( $php_version_under_eight ) {
libxml_disable_entity_loader( $libxml_disable_entity_loader ); // phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated
}
libxml_use_internal_errors( $libxml_use_internal_errors );
return $sanitized;
}
/**
* Is Encoded
*
* Check if the contents of the SVG file are gzipped
* @see http://www.gzip.org/zlib/rfc-gzip.html#member-format
*
* @since 3.16.0
* @access private
*
* @param $contents
*
* @return bool
*/
private function is_encoded( $contents ) {
$needle = "\x1f\x8b\x08";
if ( function_exists( 'mb_strpos' ) ) {
return 0 === mb_strpos( $contents, $needle );
} else {
return 0 === strpos( $contents, $needle );
}
}
/**
* Encode SVG
*
* @since 3.16.0
* @access private
*
* @param $content
* @return string
*/
private function encode_svg( $content ) {
return gzencode( $content );
}
/**
* Decode SVG
*
* @since 3.16.0
* @access private
*
* @param $content
*
* @return string
*/
private function decode_svg( $content ) {
return gzdecode( $content );
}
/**
* Is Allowed Tag
*
* @since 3.16.0
* @access private
*
* @param $element
* @return bool
*/
private function is_allowed_tag( $element ) {
static $allowed_tags = false;
if ( false === $allowed_tags ) {
$allowed_tags = $this->get_allowed_elements();
}
$tag_name = $element->tagName; // phpcs:ignore -- php DomDocument
if ( ! in_array( strtolower( $tag_name ), $allowed_tags ) ) {
$this->remove_element( $element );
return false;
}
return true;
}
/**
* Remove Element
*
* Removes the passed element from its DomDocument tree
*
* @since 3.16.0
* @access private
*
* @param $element
*/
private function remove_element( $element ) {
$element->parentNode->removeChild( $element ); // phpcs:ignore -- php DomDocument
}
/**
* Is It An Attribute
*
* @since 3.16.0
* @access private
*
* @param $name
* @param $check
* @return bool
*/
private function is_a_attribute( $name, $check ) {
return 0 === strpos( $name, $check . '-' );
}
/**
* Is Remote Value
*
* @since 3.16.0
* @access private
*
* @param $value
* @return string
*/
private function is_remote_value( $value ) {
$value = trim( preg_replace( '/[^ -~]/xu', '', $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 );
}
/**
* Has JS Value
*
* @since 3.16.0
* @access private
*
* @param $value
* @return false|int
*/
private function has_js_value( $value ) {
return preg_match( '/base64|data|(?:java)?script|alert\(|window\.|document/i', $value );
}
/**
* Get Allowed Attributes
*
* Returns an array of allowed tag attributes in SVG files.
*
* @since 3.16.0
* @access private
*
* @return array
*/
private function get_allowed_attributes() {
$allowed_attributes = [
'class',
'clip-path',
'clip-rule',
'fill',
'fill-opacity',
'fill-rule',
'filter',
'id',
'mask',
'opacity',
'stroke',
'stroke-dasharray',
'stroke-dashoffset',
'stroke-linecap',
'stroke-linejoin',
'stroke-miterlimit',
'stroke-opacity',
'stroke-width',
'style',
'systemlanguage',
'transform',
'href',
'xlink:href',
'xlink:title',
'cx',
'cy',
'r',
'requiredfeatures',
'clippathunits',
'type',
'rx',
'ry',
'color-interpolation-filters',
'stddeviation',
'filterres',
'filterunits',
'height',
'primitiveunits',
'width',
'x',
'y',
'font-size',
'display',
'font-family',
'font-style',
'font-weight',
'text-anchor',
'marker-end',
'marker-mid',
'marker-start',
'x1',
'x2',
'y1',
'y2',
'gradienttransform',
'gradientunits',
'spreadmethod',
'markerheight',
'markerunits',
'markerwidth',
'orient',
'preserveaspectratio',
'refx',
'refy',
'viewbox',
'maskcontentunits',
'maskunits',
'd',
'patterncontentunits',
'patterntransform',
'patternunits',
'points',
'fx',
'fy',
'offset',
'stop-color',
'stop-opacity',
'xmlns',
'xmlns:se',
'xmlns:xlink',
'xml:space',
'method',
'spacing',
'startoffset',
'dx',
'dy',
'rotate',
'textlength',
];
/**
* Allowed attributes in SVG file.
*
* Filters the list of allowed attributes in SVG files.
*
* Since SVG files can run JS code that may inject malicious code, all attributes
* are removed except the allowed attributes.
*
* This hook can be used to manage allowed SVG attributes. To either add new
* attributes or delete existing attributes. To strengthen or weaken site security.
*
* @param array $allowed_attributes A list of allowed attributes.
*/
$allowed_attributes = apply_filters( 'elementor/files/svg/allowed_attributes', $allowed_attributes );
return $allowed_attributes;
}
/**
* Get Allowed Elements
*
* Returns an array of allowed element tags to be in SVG files.
*
* @since 3.16.0
* @access private
*
* @return array
*/
private function get_allowed_elements() {
$allowed_elements = [
'a',
'circle',
'clippath',
'defs',
'style',
'desc',
'ellipse',
'fegaussianblur',
'filter',
'foreignobject',
'g',
'image',
'line',
'lineargradient',
'marker',
'mask',
'metadata',
'path',
'pattern',
'polygon',
'polyline',
'radialgradient',
'rect',
'stop',
'svg',
'switch',
'symbol',
'text',
'textpath',
'title',
'tspan',
'use',
];
/**
* Allowed elements in SVG file.
*
* Filters the list of allowed elements in SVG files.
*
* Since SVG files can run JS code that may inject malicious code, all elements
* are removed except the allowed elements.
*
* This hook can be used to manage SVG elements. To either add new elements or
* delete existing elements. To strengthen or weaken site security.
*
* @param array $allowed_elements A list of allowed elements.
*/
$allowed_elements = apply_filters( 'elementor/files/svg/allowed_elements', $allowed_elements );
return $allowed_elements;
}
/**
* Validate Allowed Attributes
*
* @since 3.16.0
* @access private
*
* @param \DOMElement $element
*/
private function validate_allowed_attributes( $element ) {
static $allowed_attributes = false;
if ( false === $allowed_attributes ) {
$allowed_attributes = $this->get_allowed_attributes();
}
for ( $index = $element->attributes->length - 1; $index >= 0; $index-- ) {
// get attribute name
$attr_name = $element->attributes->item( $index )->name;
$attr_name_lowercase = strtolower( $attr_name );
// Remove attribute if not in whitelist
if ( ! in_array( $attr_name_lowercase, $allowed_attributes ) && ! $this->is_a_attribute( $attr_name_lowercase, 'aria' ) && ! $this->is_a_attribute( $attr_name_lowercase, 'data' ) ) {
$element->removeAttribute( $attr_name );
continue;
}
$attr_value = $element->attributes->item( $index )->value;
// Remove attribute if it has a remote reference or js or data-URI/base64
if ( ! empty( $attr_value ) && ( $this->is_remote_value( $attr_value ) || $this->has_js_value( $attr_value ) ) ) {
$element->removeAttribute( $attr_name );
continue;
}
}
}
/**
* Strip xlinks
*
* @since 3.16.0
* @access private
*
* @param \DOMElement $element
*/
private function strip_xlinks( $element ) {
$xlinks = $element->getAttributeNS( 'http://www.w3.org/1999/xlink', 'href' );
if ( ! $xlinks ) {
return;
}
if ( ! $this->is_safe_href( $xlinks ) ) {
$element->removeAttributeNS( 'http://www.w3.org/1999/xlink', 'href' );
}
}
/**
* @see https://github.com/darylldoyle/svg-sanitizer/blob/2321a914e/src/Sanitizer.php#L454
*/
private function is_safe_href( $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 ), [
'data:image/png', // PNG
'data:image/gif', // GIF
'data:image/jpg', // JPG
'data:image/jpe', // JPEG
'data:image/pjp', // PJPEG
], true ) ) {
return true;
}
// Allow known short data URIs.
if ( in_array( substr( $value, 0, 12 ), [
'data:img/png', // PNG
'data:img/gif', // GIF
'data:img/jpg', // JPG
'data:img/jpe', // JPEG
'data:img/pjp', // PJPEG
], true ) ) {
return true;
}
return false;
}
/**
* Validate Use Tag
*
* @since 3.16.0
* @access private
*
* @param $element
*/
private function validate_use_tag( $element ) {
$xlinks = $element->getAttributeNS( 'http://www.w3.org/1999/xlink', 'href' );
if ( $xlinks && '#' !== substr( $xlinks, 0, 1 ) ) {
$element->parentNode->removeChild( $element ); // phpcs:ignore -- php DomNode
}
}
/**
* Strip Doctype
*
* @since 3.16.0
* @access private
*
*/
private function strip_doctype() {
foreach ( $this->svg_dom->childNodes as $child ) {
if ( XML_DOCUMENT_TYPE_NODE === $child->nodeType ) { // phpcs:ignore -- php DomDocument
$child->parentNode->removeChild( $child ); // phpcs:ignore -- php DomDocument
}
}
}
/**
* Sanitize Elements
*
* @since 3.16.0
* @access private
*/
private function sanitize_elements() {
$elements = $this->svg_dom->getElementsByTagName( '*' );
// 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 ( $index = $elements->length - 1; $index >= 0; $index-- ) {
/**
* @var \DOMElement $current_element
*/
$current_element = $elements->item( $index );
// If the tag isn't in the whitelist, remove it and continue with next iteration
if ( ! $this->is_allowed_tag( $current_element ) ) {
continue;
}
//validate element attributes
$this->validate_allowed_attributes( $current_element );
$this->strip_xlinks( $current_element );
if ( 'use' === strtolower( $current_element->tagName ) ) { // phpcs:ignore -- php DomDocument
$this->validate_use_tag( $current_element );
}
}
}
/**
* Strip PHP Tags
*
* @since 3.16.0
* @access private
*
* @param $string
* @return string
*/
private function strip_php_tags( $string ) {
$string = preg_replace( '/<\?(=|php)(.+?)\?>/i', '', $string );
// Remove XML, ASP, etc.
$string = preg_replace( '/<\?(.*)\?>/Us', '', $string );
$string = preg_replace( '/<\%(.*)\%>/Us', '', $string );
if ( ( false !== strpos( $string, '<?' ) ) || ( false !== strpos( $string, '<%' ) ) ) {
return '';
}
return $string;
}
/**
* Strip Comments
*
* @since 3.16.0
* @access private
*
* @param $string
* @return string
*/
private function strip_comments( $string ) {
// Remove comments.
$string = preg_replace( '/<!--(.*)-->/Us', '', $string );
$string = preg_replace( '/\/\*(.*)\*\//Us', '', $string );
if ( ( false !== strpos( $string, '<!--' ) ) || ( false !== strpos( $string, '/*' ) ) ) {
return '';
}
return $string;
}
/**
* Strip Line Breaks
*
* @since 3.16.0
* @access private
*
* @param $string
* @return string
*/
private function strip_line_breaks( $string ) {
// Remove line breaks.
return preg_replace( '/\r|\n/', '', $string );
}
}