svg-handler.php (18332B)
1 <?php 2 namespace Elementor\Core\Files\Assets\Svg; 3 4 use Elementor\Core\Files\Assets\Files_Upload_Handler; 5 6 if ( ! defined( 'ABSPATH' ) ) { 7 exit; // Exit if accessed directly. 8 } 9 10 class Svg_Handler extends Files_Upload_Handler { 11 /** 12 * Inline svg attachment meta key 13 */ 14 const META_KEY = '_elementor_inline_svg'; 15 16 const SCRIPT_REGEX = '/(?:\w+script|data):/xi'; 17 18 /** 19 * @var \DOMDocument 20 */ 21 private $svg_dom = null; 22 23 /** 24 * Attachment ID. 25 * 26 * Holds the current attachment ID. 27 * 28 * @var int 29 */ 30 private $attachment_id; 31 32 public static function get_name() { 33 return 'svg-handler'; 34 } 35 36 /** 37 * get_meta 38 * @return mixed 39 */ 40 protected function get_meta() { 41 return get_post_meta( $this->attachment_id, self::META_KEY, true ); 42 } 43 44 /** 45 * update_meta 46 * @param $meta 47 */ 48 protected function update_meta( $meta ) { 49 update_post_meta( $this->attachment_id, self::META_KEY, $meta ); 50 } 51 52 /** 53 * delete_meta 54 */ 55 protected function delete_meta() { 56 delete_post_meta( $this->attachment_id, self::META_KEY ); 57 } 58 59 public function get_mime_type() { 60 return 'image/svg+xml'; 61 } 62 63 public function get_file_type() { 64 return 'svg'; 65 } 66 67 /** 68 * delete_meta_cache 69 */ 70 public function delete_meta_cache() { 71 delete_post_meta_by_key( self::META_KEY ); 72 } 73 74 /** 75 * read_from_file 76 * @return bool|string 77 */ 78 public function read_from_file() { 79 return file_get_contents( get_attached_file( $this->attachment_id ) ); 80 } 81 82 /** 83 * get_inline_svg 84 * @param $attachment_id 85 * 86 * @return bool|mixed|string 87 */ 88 public static function get_inline_svg( $attachment_id ) { 89 $svg = get_post_meta( $attachment_id, self::META_KEY, true ); 90 91 if ( ! empty( $svg ) ) { 92 return $svg; 93 } 94 95 $attachment_file = get_attached_file( $attachment_id ); 96 97 if ( ! $attachment_file ) { 98 return ''; 99 } 100 101 $svg = file_get_contents( $attachment_file ); 102 103 if ( ! empty( $svg ) ) { 104 update_post_meta( $attachment_id, self::META_KEY, $svg ); 105 } 106 107 return $svg; 108 } 109 110 /** 111 * decode_svg 112 * @param $content 113 * 114 * @return string 115 */ 116 private function decode_svg( $content ) { 117 return gzdecode( $content ); 118 } 119 120 /** 121 * encode_svg 122 * @param $content 123 * 124 * @return string 125 */ 126 private function encode_svg( $content ) { 127 return gzencode( $content ); 128 } 129 130 /** 131 * sanitize_svg 132 * @param $filename 133 * 134 * @return bool 135 */ 136 public function sanitize_svg( $filename ) { 137 $original_content = file_get_contents( $filename ); 138 $is_encoded = $this->is_encoded( $original_content ); 139 140 if ( $is_encoded ) { 141 $decoded = $this->decode_svg( $original_content ); 142 if ( false === $decoded ) { 143 return false; 144 } 145 $original_content = $decoded; 146 } 147 148 $valid_svg = $this->sanitizer( $original_content ); 149 150 if ( false === $valid_svg ) { 151 return false; 152 } 153 154 // If we were gzipped, we need to re-zip 155 if ( $is_encoded ) { 156 $valid_svg = $this->encode_svg( $valid_svg ); 157 } 158 file_put_contents( $filename, $valid_svg ); 159 160 return true; 161 } 162 163 /** 164 * Check if the contents are gzipped 165 * @see http://www.gzip.org/zlib/rfc-gzip.html#member-format 166 * 167 * @param $contents 168 * @return bool 169 */ 170 private function is_encoded( $contents ) { 171 $needle = "\x1f\x8b\x08"; 172 if ( function_exists( 'mb_strpos' ) ) { 173 return 0 === mb_strpos( $contents, $needle ); 174 } else { 175 return 0 === strpos( $contents, $needle ); 176 } 177 } 178 179 /** 180 * is_allowed_tag 181 * @param $element 182 * 183 * @return bool 184 */ 185 private function is_allowed_tag( $element ) { 186 static $allowed_tags = false; 187 if ( false === $allowed_tags ) { 188 $allowed_tags = $this->get_allowed_elements(); 189 } 190 191 $tag_name = $element->tagName; // phpcs:ignore -- php DomDocument 192 193 if ( ! in_array( strtolower( $tag_name ), $allowed_tags ) ) { 194 $this->remove_element( $element ); 195 return false; 196 } 197 198 return true; 199 } 200 201 private function remove_element( $element ) { 202 $element->parentNode->removeChild( $element ); // phpcs:ignore -- php DomDocument 203 } 204 205 /** 206 * is_a_attribute 207 * @param $name 208 * @param $check 209 * 210 * @return bool 211 */ 212 private function is_a_attribute( $name, $check ) { 213 return 0 === strpos( $name, $check . '-' ); 214 } 215 216 /** 217 * is_remote_value 218 * @param $value 219 * 220 * @return string 221 */ 222 private function is_remote_value( $value ) { 223 $value = trim( preg_replace( '/[^ -~]/xu', '', $value ) ); 224 $wrapped_in_url = preg_match( '~^url\(\s*[\'"]\s*(.*)\s*[\'"]\s*\)$~xi', $value, $match ); 225 if ( ! $wrapped_in_url ) { 226 return false; 227 } 228 229 $value = trim( $match[1], '\'"' ); 230 return preg_match( '~^((https?|ftp|file):)?//~xi', $value ); 231 } 232 233 /** 234 * has_js_value 235 * @param $value 236 * 237 * @return false|int 238 */ 239 private function has_js_value( $value ) { 240 return preg_match( '/base64|data|(?:java)?script|alert\(|window\.|document/i', $value ); 241 } 242 243 /** 244 * get_allowed_attributes 245 * @return array 246 */ 247 private function get_allowed_attributes() { 248 $allowed_attributes = [ 249 'class', 250 'clip-path', 251 'clip-rule', 252 'fill', 253 'fill-opacity', 254 'fill-rule', 255 'filter', 256 'id', 257 'mask', 258 'opacity', 259 'stroke', 260 'stroke-dasharray', 261 'stroke-dashoffset', 262 'stroke-linecap', 263 'stroke-linejoin', 264 'stroke-miterlimit', 265 'stroke-opacity', 266 'stroke-width', 267 'style', 268 'systemlanguage', 269 'transform', 270 'href', 271 'xlink:href', 272 'xlink:title', 273 'cx', 274 'cy', 275 'r', 276 'requiredfeatures', 277 'clippathunits', 278 'type', 279 'rx', 280 'ry', 281 'color-interpolation-filters', 282 'stddeviation', 283 'filterres', 284 'filterunits', 285 'height', 286 'primitiveunits', 287 'width', 288 'x', 289 'y', 290 'font-size', 291 'display', 292 'font-family', 293 'font-style', 294 'font-weight', 295 'text-anchor', 296 'marker-end', 297 'marker-mid', 298 'marker-start', 299 'x1', 300 'x2', 301 'y1', 302 'y2', 303 'gradienttransform', 304 'gradientunits', 305 'spreadmethod', 306 'markerheight', 307 'markerunits', 308 'markerwidth', 309 'orient', 310 'preserveaspectratio', 311 'refx', 312 'refy', 313 'viewbox', 314 'maskcontentunits', 315 'maskunits', 316 'd', 317 'patterncontentunits', 318 'patterntransform', 319 'patternunits', 320 'points', 321 'fx', 322 'fy', 323 'offset', 324 'stop-color', 325 'stop-opacity', 326 'xmlns', 327 'xmlns:se', 328 'xmlns:xlink', 329 'xml:space', 330 'method', 331 'spacing', 332 'startoffset', 333 'dx', 334 'dy', 335 'rotate', 336 'textlength', 337 ]; 338 339 /** 340 * Allowed attributes in SVG file. 341 * 342 * Filters the list of allowed attributes in SVG files. 343 * 344 * Since SVG files can run JS code that may inject malicious code, all attributes 345 * are removed except the allowed attributes. 346 * 347 * This hook can be used to manage allowed SVG attributes. To either add new 348 * attributes or delete existing attributes. To strengthen or weaken site security. 349 * 350 * @param array $allowed_attributes A list of allowed attributes. 351 */ 352 $allowed_attributes = apply_filters( 'elementor/files/svg/allowed_attributes', $allowed_attributes ); 353 354 return $allowed_attributes; 355 } 356 357 /** 358 * get_allowed_elements 359 * @return array 360 */ 361 private function get_allowed_elements() { 362 $allowed_elements = [ 363 'a', 364 'circle', 365 'clippath', 366 'defs', 367 'style', 368 'desc', 369 'ellipse', 370 'fegaussianblur', 371 'filter', 372 'foreignobject', 373 'g', 374 'image', 375 'line', 376 'lineargradient', 377 'marker', 378 'mask', 379 'metadata', 380 'path', 381 'pattern', 382 'polygon', 383 'polyline', 384 'radialgradient', 385 'rect', 386 'stop', 387 'svg', 388 'switch', 389 'symbol', 390 'text', 391 'textpath', 392 'title', 393 'tspan', 394 'use', 395 ]; 396 397 /** 398 * Allowed elements in SVG file. 399 * 400 * Filters the list of allowed elements in SVG files. 401 * 402 * Since SVG files can run JS code that may inject malicious code, all elements 403 * are removed except the allowed elements. 404 * 405 * This hook can be used to manage SVG elements. To either add new elements or 406 * delete existing elements. To strengthen or weaken site security. 407 * 408 * @param array $allowed_elements A list of allowed elements. 409 */ 410 $allowed_elements = apply_filters( 'elementor/files/svg/allowed_elements', $allowed_elements ); 411 412 return $allowed_elements; 413 } 414 415 /** 416 * validate_allowed_attributes 417 * @param \DOMElement $element 418 */ 419 private function validate_allowed_attributes( $element ) { 420 static $allowed_attributes = false; 421 if ( false === $allowed_attributes ) { 422 $allowed_attributes = $this->get_allowed_attributes(); 423 } 424 425 for ( $index = $element->attributes->length - 1; $index >= 0; $index-- ) { 426 // get attribute name 427 $attr_name = $element->attributes->item( $index )->name; 428 $attr_name_lowercase = strtolower( $attr_name ); 429 // Remove attribute if not in whitelist 430 if ( ! in_array( $attr_name_lowercase, $allowed_attributes ) && ! $this->is_a_attribute( $attr_name_lowercase, 'aria' ) && ! $this->is_a_attribute( $attr_name_lowercase, 'data' ) ) { 431 $element->removeAttribute( $attr_name ); 432 continue; 433 } 434 435 $attr_value = $element->attributes->item( $index )->value; 436 437 // Remove attribute if it has a remote reference or js or data-URI/base64 438 if ( ! empty( $attr_value ) && ( $this->is_remote_value( $attr_value ) || $this->has_js_value( $attr_value ) ) ) { 439 $element->removeAttribute( $attr_name ); 440 continue; 441 } 442 } 443 } 444 445 /** 446 * strip_xlinks 447 * @param \DOMElement $element 448 */ 449 private function strip_xlinks( $element ) { 450 $xlinks = $element->getAttributeNS( 'http://www.w3.org/1999/xlink', 'href' ); 451 452 if ( ! $xlinks ) { 453 return; 454 } 455 456 $allowed_links = [ 457 'data:image/png', // PNG 458 'data:image/gif', // GIF 459 'data:image/jpg', // JPG 460 'data:image/jpe', // JPEG 461 'data:image/pjp', // PJPEG 462 ]; 463 if ( 1 === preg_match( self::SCRIPT_REGEX, $xlinks ) ) { 464 if ( ! in_array( substr( $xlinks, 0, 14 ), $allowed_links ) ) { 465 $element->removeAttributeNS( 'http://www.w3.org/1999/xlink', 'href' ); 466 } 467 } 468 } 469 470 /** 471 * validate_use_tag 472 * @param $element 473 */ 474 private function validate_use_tag( $element ) { 475 $xlinks = $element->getAttributeNS( 'http://www.w3.org/1999/xlink', 'href' ); 476 if ( $xlinks && '#' !== substr( $xlinks, 0, 1 ) ) { 477 $element->parentNode->removeChild( $element ); // phpcs:ignore -- php DomNode 478 } 479 } 480 481 /** 482 * strip_docktype 483 */ 484 private function strip_doctype() { 485 foreach ( $this->svg_dom->childNodes as $child ) { 486 if ( XML_DOCUMENT_TYPE_NODE === $child->nodeType ) { // phpcs:ignore -- php DomDocument 487 $child->parentNode->removeChild( $child ); // phpcs:ignore -- php DomDocument 488 } 489 } 490 } 491 492 /** 493 * sanitize_elements 494 */ 495 private function sanitize_elements() { 496 $elements = $this->svg_dom->getElementsByTagName( '*' ); 497 // loop through all elements 498 // we do this backwards so we don't skip anything if we delete a node 499 // see comments at: http://php.net/manual/en/class.domnamednodemap.php 500 for ( $index = $elements->length - 1; $index >= 0; $index-- ) { 501 /** 502 * @var \DOMElement $current_element 503 */ 504 $current_element = $elements->item( $index ); 505 // If the tag isn't in the whitelist, remove it and continue with next iteration 506 if ( ! $this->is_allowed_tag( $current_element ) ) { 507 continue; 508 } 509 510 //validate element attributes 511 $this->validate_allowed_attributes( $current_element ); 512 513 $this->strip_xlinks( $current_element ); 514 515 if ( 'use' === strtolower( $current_element->tagName ) ) { // phpcs:ignore -- php DomDocument 516 $this->validate_use_tag( $current_element ); 517 } 518 } 519 } 520 521 /** 522 * sanitizer 523 * @param $content 524 * 525 * @return bool|string 526 */ 527 public function sanitizer( $content ) { 528 // Strip php tags 529 $content = $this->strip_comments( $content ); 530 $content = $this->strip_php_tags( $content ); 531 532 // Find the start and end tags so we can cut out miscellaneous garbage. 533 $start = strpos( $content, '<svg' ); 534 $end = strrpos( $content, '</svg>' ); 535 if ( false === $start || false === $end ) { 536 return false; 537 } 538 539 $content = substr( $content, $start, ( $end - $start + 6 ) ); 540 541 // If the server's PHP version is 8 or up, make sure to Disable the ability to load external entities 542 $php_version_under_eight = version_compare( PHP_VERSION, '8.0.0', '<' ); 543 if ( $php_version_under_eight ) { 544 $libxml_disable_entity_loader = libxml_disable_entity_loader( true ); // phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated 545 } 546 // Suppress the errors 547 $libxml_use_internal_errors = libxml_use_internal_errors( true ); 548 549 // Create DomDocument instance 550 $this->svg_dom = new \DOMDocument(); 551 $this->svg_dom->formatOutput = false; 552 $this->svg_dom->preserveWhiteSpace = false; 553 $this->svg_dom->strictErrorChecking = false; 554 555 $open_svg = $this->svg_dom->loadXML( $content ); 556 if ( ! $open_svg ) { 557 return false; 558 } 559 560 $this->strip_doctype(); 561 $this->sanitize_elements(); 562 563 // Export sanitized svg to string 564 // Using documentElement to strip out <?xml version="1.0" encoding="UTF-8"... 565 $sanitized = $this->svg_dom->saveXML( $this->svg_dom->documentElement, LIBXML_NOEMPTYTAG ); 566 567 // Restore defaults 568 if ( $php_version_under_eight ) { 569 libxml_disable_entity_loader( $libxml_disable_entity_loader ); // phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated 570 } 571 libxml_use_internal_errors( $libxml_use_internal_errors ); 572 573 return $sanitized; 574 } 575 576 /** 577 * strip_php_tags 578 * @param $string 579 * 580 * @return string 581 */ 582 private function strip_php_tags( $string ) { 583 $string = preg_replace( '/<\?(=|php)(.+?)\?>/i', '', $string ); 584 // Remove XML, ASP, etc. 585 $string = preg_replace( '/<\?(.*)\?>/Us', '', $string ); 586 $string = preg_replace( '/<\%(.*)\%>/Us', '', $string ); 587 588 if ( ( false !== strpos( $string, '<?' ) ) || ( false !== strpos( $string, '<%' ) ) ) { 589 return ''; 590 } 591 return $string; 592 } 593 594 /** 595 * strip_comments 596 * @param $string 597 * 598 * @return string 599 */ 600 private function strip_comments( $string ) { 601 // Remove comments. 602 $string = preg_replace( '/<!--(.*)-->/Us', '', $string ); 603 $string = preg_replace( '/\/\*(.*)\*\//Us', '', $string ); 604 if ( ( false !== strpos( $string, '<!--' ) ) || ( false !== strpos( $string, '/*' ) ) ) { 605 return ''; 606 } 607 return $string; 608 } 609 610 /** 611 * wp_prepare_attachment_for_js 612 * @param $attachment_data 613 * @param $attachment 614 * @param $meta 615 * 616 * @return mixed 617 */ 618 public function wp_prepare_attachment_for_js( $attachment_data, $attachment, $meta ) { 619 if ( 'image' !== $attachment_data['type'] || 'svg+xml' !== $attachment_data['subtype'] || ! class_exists( 'SimpleXMLElement' ) ) { 620 return $attachment_data; 621 } 622 623 $svg = self::get_inline_svg( $attachment->ID ); 624 625 if ( ! $svg ) { 626 return $attachment_data; 627 } 628 629 try { 630 $svg = new \SimpleXMLElement( $svg ); 631 } catch ( \Exception $e ) { 632 return $attachment_data; 633 } 634 635 $src = $attachment_data['url']; 636 $width = (int) $svg['width']; 637 $height = (int) $svg['height']; 638 639 // Media Gallery 640 $attachment_data['image'] = compact( 'src', 'width', 'height' ); 641 $attachment_data['thumb'] = compact( 'src', 'width', 'height' ); 642 643 // Single Details of Image 644 $attachment_data['sizes']['full'] = [ 645 'height' => $height, 646 'width' => $width, 647 'url' => $src, 648 'orientation' => $height > $width ? 'portrait' : 'landscape', 649 ]; 650 return $attachment_data; 651 } 652 653 /** 654 * set_attachment_id 655 * @param $attachment_id 656 * 657 * @return int 658 */ 659 public function set_attachment_id( $attachment_id ) { 660 $this->attachment_id = $attachment_id; 661 return $this->attachment_id; 662 } 663 664 /** 665 * get_attachment_id 666 * @return int 667 */ 668 public function get_attachment_id() { 669 return $this->attachment_id; 670 } 671 672 /** 673 * set_svg_meta_data 674 * @return mixed 675 */ 676 public function set_svg_meta_data( $data, $id ) { 677 $attachment = get_post( $id ); // Filter makes sure that the post is an attachment. 678 $mime_type = $attachment->post_mime_type; 679 680 // If the attachment is an svg 681 if ( 'image/svg+xml' === $mime_type ) { 682 // If the svg metadata are empty or the width is empty or the height is empty. 683 // then get the attributes from xml. 684 if ( empty( $data ) || empty( $data['width'] ) || empty( $data['height'] ) ) { 685 $xml = simplexml_load_file( get_attached_file( $id ) ); 686 $attr = $xml->attributes(); 687 $view_box = explode( ' ', $attr->viewBox );// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 688 $data['width'] = isset( $attr->width ) && preg_match( '/\d+/', $attr->width, $value ) ? (int) $value[0] : ( 4 === count( $view_box ) ? (int) $view_box[2] : null ); 689 $data['height'] = isset( $attr->height ) && preg_match( '/\d+/', $attr->height, $value ) ? (int) $value[0] : ( 4 === count( $view_box ) ? (int) $view_box[3] : null ); 690 } 691 } 692 693 return $data; 694 } 695 696 /** 697 * handle_upload_prefilter 698 * @param $file 699 * 700 * @return mixed 701 */ 702 public function handle_upload_prefilter( $file ) { 703 if ( ! $this->is_file_should_handled( $file ) ) { 704 return $file; 705 } 706 707 $file = parent::handle_upload_prefilter( $file ); 708 709 if ( ! $file['error'] && self::file_sanitizer_can_run() && ! $this->sanitize_svg( $file['tmp_name'] ) ) { 710 $display_type = strtoupper( $this->get_file_type() ); 711 712 $file['error'] = sprintf( esc_html__( 'Invalid %1$s Format, file not uploaded for security reasons', 'elementor' ), $display_type ); 713 } 714 715 return $file; 716 } 717 718 /** 719 * @since 3.0.0 720 * @deprecated 3.0.0 Use Files_Upload_Handler::file_sanitizer_can_run() instead. 721 */ 722 public function svg_sanitizer_can_run() { 723 _deprecated_function( __METHOD__, '3.0.0', 'Files_Upload_Handler::file_sanitizer_can_run()' ); 724 725 return Files_Upload_Handler::file_sanitizer_can_run(); 726 } 727 728 /** 729 * @since 3.0.0 730 * @deprecated 3.0.0 731 */ 732 public function upload_mimes() { 733 _deprecated_function( __METHOD__, '3.0.0' ); 734 } 735 736 /** 737 * @since 3.0.0 738 * @deprecated 3.0.0 739 */ 740 public function wp_handle_upload_prefilter() { 741 _deprecated_function( __METHOD__, '3.0.0' ); 742 } 743 744 /** 745 * @since 3.0.0 746 * @deprecated 3.0.0 Use Files_Upload_Handler::is_enabled() instead. 747 * @see is_enabled() 748 */ 749 public function is_svg_uploads_enabled() { 750 _deprecated_function( __METHOD__, '3.0.0', 'Files_Upload_Handler::is_enabled()' ); 751 752 return Files_Upload_Handler::is_enabled(); 753 } 754 755 /** 756 * Svg_Handler constructor. 757 */ 758 public function __construct() { 759 parent::__construct(); 760 761 add_filter( 'wp_update_attachment_metadata', [ $this, 'set_svg_meta_data' ], 10, 2 ); 762 add_filter( 'wp_prepare_attachment_for_js', [ $this, 'wp_prepare_attachment_for_js' ], 10, 3 ); 763 add_action( 'elementor/core/files/clear_cache', [ $this, 'delete_meta_cache' ] ); 764 } 765 }