SVG files on, the roles chosen there may upload SVG files through * WordPress's own upload paths (the Media Library, the block editor, the REST API and media_handle_sideload); each file * is cleaned with svg-sanitizer 1.1.1 (includes/vendor/svg-sanitizer, GPL-2.0-or-later) before WordPress moves it into * the uploads folder, and refused when it cannot be read safely. The cleaner keeps only the SVG tags and attributes on * its allowlist (no scripts, event attributes, script links or embedded web pages) and, as set here, removes * references to other sites. The width and height read from the cleaned file go into the attachment's metadata, so * WordPress shows the file like other pictures. Off at the start. While it is on, wp_upload_bits() (XML-RPC and * importers), which writes a file with no hook before it, refuses SVG files. * * @package AffStackDevImageManager */ namespace AffStackDev\ImageManager; if ( ! defined( 'ABSPATH' ) ) { exit; } /** Cleaned SVG uploads for the roles the owner chooses. */ final class SVG { const MIME = 'image/svg+xml'; /** The largest SVG file cleaned (8 MB); larger files are refused. */ const MAX_BYTES = 8388608; /** The library's files, interfaces before the classes that use them. */ const LIBRARY = array( 'data/AttributeInterface', 'data/TagInterface', 'data/AllowedAttributes', 'data/AllowedTags', 'data/XPath', 'Exceptions/NestingException', 'Exceptions/RegexException', 'Helper', 'ElementReference/Usage', 'ElementReference/Subject', 'ElementReference/Resolver', 'Sanitizer' ); /** CSS lengths in pixels (96 per inch), for an SVG's width and height. */ const UNITS = array( '' => 1, 'px' => 1, 'pt' => 96 / 72, 'pc' => 16, 'mm' => 96 / 25.4, 'cm' => 96 / 2.54, 'in' => 96, ); /** * Upload files cleaned in this request, by their temporary path. * * @var array */ private static array $cleaned = array(); /** * Cleaning needs PHP's DOM, XMLReader and libxml extensions. * * @return bool */ public static function available(): bool { return class_exists( '\DOMDocument' ) && class_exists( '\XMLReader' ) && function_exists( 'libxml_use_internal_errors' ); } /** * The roles that may upload files, for the Settings tab: slug => name. * * @return array */ public static function roles(): array { $out = array(); foreach ( wp_roles()->roles as $slug => $role ) { if ( ! empty( $role['capabilities']['upload_files'] ) ) { $out[ (string) $slug ] = translate_user_role( (string) $role['name'] ); } } return $out; } /** * The saved role list kept to roles that exist and may upload files, in their order on the site. * * @param mixed $roles Role slugs. * @return string[] */ public static function clean_roles( $roles ): array { $asked = is_array( $roles ) ? array_map( 'strval', array_filter( $roles, 'is_scalar' ) ) : array(); return array_values( array_intersect( array_keys( self::roles() ), $asked ) ); } /** * Whether this user may upload SVG files now. * * @param \WP_User|null $user The user (the current user when null). * @return bool */ public static function allowed( ?\WP_User $user = null ): bool { $s = Settings::get(); if ( empty( $s['svg_uploads'] ) || ! self::available() ) { return false; } $user = $user ?? wp_get_current_user(); if ( ! $user->exists() || ! user_can( $user, 'upload_files' ) ) { return false; } return (bool) array_intersect( (array) $user->roles, self::clean_roles( $s['svg_roles'] ?? array() ) ); } /** * A file name ending in .svg or .svgz. * * @param string $name File name. * @return bool */ private static function named( string $name ): bool { return (bool) preg_match( '/\.svgz?$/iD', $name ); } /** * The SVG type for the users chosen in Settings. * * @param mixed $mimes WordPress's allowed types. * @param mixed $user The user WordPress asks for (an ID, a user or null for the current user). * @return mixed */ public static function mimes( $mimes, $user = null ) { if ( null === $user || $user instanceof \WP_User ) { $who = $user; } else { $who = get_userdata( (int) $user ); if ( ! $who instanceof \WP_User ) { return $mimes; } } if ( is_array( $mimes ) && self::allowed( $who ) ) { $mimes['svg'] = self::MIME; } return $mimes; } /** * Clean an SVG upload in its temporary file before WordPress checks its type and moves it. * * @param mixed $file The upload ($_FILES entry or sideload array). * @return mixed */ public static function prefilter( $file ) { if ( ! is_array( $file ) || ! empty( $file['error'] ) || ! self::named( (string) ( $file['name'] ?? '' ) ) || ! self::allowed() ) { return $file; } $path = (string) ( $file['tmp_name'] ?? '' ); $done = self::clean_file( $path ); if ( is_string( $done ) ) { $file['error'] = self::message( $done ); return $file; } $file['size'] = $done; self::$cleaned[ $path ] = true; return $file; } /** * Clean one file in place. * * @param string $path The upload's temporary file. * @return int|string The cleaned size in bytes, or a reason code. */ public static function clean_file( string $path ) { if ( '' === $path || ! is_file( $path ) || ! is_readable( $path ) ) { return 'svg_unreadable'; } $size = filesize( $path ); if ( false === $size || $size > self::MAX_BYTES ) { return 'svg_too_large'; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- the upload's own temporary file, read once to clean it before WordPress saves it. $raw = file_get_contents( $path ); if ( ! is_string( $raw ) ) { return 'svg_unreadable'; } if ( "\x1f\x8b" === substr( $raw, 0, 2 ) ) { return 'svg_compressed'; } $clean = self::clean( $raw ); if ( null === $clean ) { return 'svg_unsafe'; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- the cleaned SVG replaces the upload's own temporary file before WordPress moves it; nothing in the uploads folder is written here. if ( strlen( $clean ) !== Reviewed_Store::file_put_contents( $path, $clean, LOCK_EX ) ) { return 'svg_unwritable'; } clearstatcache( true, $path ); return strlen( $clean ); } /** * The cleaned SVG, or null when it cannot be read safely. * * @param string $raw The SVG as uploaded. * @return string|null */ public static function clean( string $raw ): ?string { if ( ! self::available() || '' === trim( $raw ) ) { return null; } foreach ( self::LIBRARY as $file ) { require_once DIR . '/includes/vendor/svg-sanitizer/' . $file . '.php'; } $sanitizer = new Vendor\SvgSanitize\Sanitizer(); $sanitizer->removeRemoteReferences( true ); $clean = $sanitizer->sanitize( $raw ); if ( ! is_string( $clean ) || '' === trim( $clean ) || null === self::size( $clean, true ) ) { return null; } return $clean; } /** * The words for a refused upload. * * @param string $code Reason code. * @return string */ public static function message( string $code ): string { $words = array( 'svg_too_large' => __( 'This SVG file is larger than 8 MB, so it was not uploaded.', 'affstackdev-image-manager' ), 'svg_compressed' => __( 'Compressed SVG files are not accepted. Upload the uncompressed .svg file.', 'affstackdev-image-manager' ), 'svg_unsafe' => __( 'This SVG file could not be read safely, so it was not uploaded.', 'affstackdev-image-manager' ), 'svg_bits' => __( 'SVG files are accepted only through uploads Image Manager cleans first: the Media Library, the block editor and the REST API.', 'affstackdev-image-manager' ), ); return $words[ $code ] ?? __( 'This SVG file could not be cleaned, so it was not uploaded.', 'affstackdev-image-manager' ); } /** * Confirm the SVG type for a file this request cleaned (fileinfo can name an SVG text/xml or text/plain). * * @param mixed $data WordPress's answer: ext, type, proper_filename. * @param string $file The file's path. * @param string $filename The file's name. * @param mixed $mimes The allowed types WordPress checks against. * @return mixed */ public static function filetype( $data, $file, $filename, $mimes ) { if ( ! isset( self::$cleaned[ (string) $file ] ) || ! preg_match( '/\.svg$/iD', (string) $filename ) ) { return $data; } $allowed = is_array( $mimes ) && $mimes ? $mimes : get_allowed_mime_types(); if ( self::MIME !== ( $allowed['svg'] ?? '' ) ) { return $data; } return array( 'ext' => 'svg', 'type' => self::MIME, 'proper_filename' => is_array( $data ) ? ( $data['proper_filename'] ?? false ) : false, ); } /** * Refuse SVG files in wp_upload_bits(), which writes a file with no hook before it. * * @param mixed $upload The name, bits and time. * @return mixed */ public static function bits( $upload ) { if ( is_array( $upload ) && self::named( (string) ( $upload['name'] ?? '' ) ) ) { return self::message( 'svg_bits' ); } return $upload; } /** * Width and height in an SVG attachment's metadata, read from its cleaned file. * * @param mixed $metadata WordPress's metadata. * @param int $attachment_id The attachment. * @return mixed */ public static function metadata( $metadata, $attachment_id ) { if ( self::MIME !== get_post_mime_type( (int) $attachment_id ) ) { return $metadata; } $path = get_attached_file( (int) $attachment_id ); if ( ! is_string( $path ) || ! is_file( $path ) || filesize( $path ) > self::MAX_BYTES ) { return $metadata; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- one local attachment's SVG text, read to find its width and height. $size = self::size( (string) file_get_contents( $path ) ); if ( null === $size ) { return $metadata; } $metadata = is_array( $metadata ) ? $metadata : array(); $metadata['width'] = $size[0]; $metadata['height'] = $size[1]; $metadata['file'] = _wp_relative_upload_path( $path ); return $metadata; } /** * WordPress makes no sizes of an SVG file (its image editors read no SVG). * * @param mixed $missing The sizes WordPress would make. * @param mixed $image_meta The attachment's metadata. * @param int $attachment_id The attachment. * @return mixed */ public static function no_sizes( $missing, $image_meta = array(), $attachment_id = 0 ) { return self::MIME === get_post_mime_type( (int) $attachment_id ) ? array() : $missing; } /** * The size of an SVG in pixels: its width and height, or its viewBox for what they leave open. * * @param string $svg SVG text. * @param bool $root_only True to answer an empty array, not null, for an svg root whose size is not given. * @return int[]|null Width and height; null when the first element is not svg (or, without $root_only, when the * size is not given). */ public static function size( string $svg, bool $root_only = false ): ?array { if ( '' === $svg || ! class_exists( '\XMLReader' ) ) { return null; } $reader = new \XMLReader(); $prev = libxml_use_internal_errors( true ); try { if ( ! $reader->XML( $svg, null, LIBXML_NONET ) ) { return null; } while ( $reader->read() ) { if ( \XMLReader::ELEMENT === $reader->nodeType ) { break; } } if ( \XMLReader::ELEMENT !== $reader->nodeType || 'svg' !== $reader->localName ) { return null; } $box = preg_split( '/[\s,]+/', trim( (string) $reader->getAttribute( 'viewBox' ) ) ); $box = is_array( $box ) && 4 === count( $box ) && is_numeric( $box[2] ) && is_numeric( $box[3] ) && (float) $box[2] > 0 && (float) $box[3] > 0 ? array( (float) $box[2], (float) $box[3] ) : null; $w = self::length( (string) $reader->getAttribute( 'width' ) ); $h = self::length( (string) $reader->getAttribute( 'height' ) ); } finally { $reader->close(); libxml_clear_errors(); libxml_use_internal_errors( $prev ); } if ( null === $w && null !== $h && $box ) { $w = $h * $box[0] / $box[1]; } elseif ( null === $h && null !== $w && $box ) { $h = $w * $box[1] / $box[0]; } elseif ( null === $w && null === $h && $box ) { list( $w, $h ) = $box; } if ( null === $w || null === $h || $w > 100000 || $h > 100000 ) { return $root_only ? array() : null; } return array( max( 1, (int) round( $w ) ), max( 1, (int) round( $h ) ) ); } /** * A CSS length in pixels; null for percentages, em, ex and anything else. * * @param string $value Attribute value. * @return float|null */ private static function length( string $value ): ?float { if ( ! preg_match( '/^\s*([0-9]*\.?[0-9]+(?:e[+-]?[0-9]+)?)\s*(px|pt|pc|mm|cm|in)?\s*$/iD', $value, $m ) ) { return null; } $px = (float) $m[1] * self::UNITS[ strtolower( $m[2] ?? '' ) ]; return $px > 0 ? $px : null; } }