is_svg( $mime, $extension ) ) { if ( ! wp_is_writable( $path ) ) { // Cannot rewrite it, so cannot vouch for it. wp_delete_file( $path ); return false; } return $this->sanitize_svg( $path ); } if ( in_array( $mime, self::STRIPPABLE, true ) ) { return wp_is_writable( $path ) && $this->strip_metadata( $path, $mime ); } // Nothing about this type needs rewriting, so there is nothing to fail at. return true; } /** * Determines whether a stored file is an SVG. * * @param string $mime Mime type reported for the file. * @param string $extension Verified extension, where one is known. */ private function is_svg( string $mime, string $extension ): bool { return in_array( strtolower( trim( $mime ) ), self::SVG_MIMES, true ) || 'svg' === strtolower( trim( $extension ) ); } /** * Removes the metadata block from an image. * * A photo taken on a phone carries the coordinates it was taken at. On a * public form that turns a file upload into a location disclosure, so the * metadata goes before the file is reachable. * * @param string $path Absolute path of the image. * @param string $mime Mime type of the image. * * @return bool True when the image was rewritten. */ private function strip_metadata( string $path, string $mime ): bool { /** * Filters whether image metadata is removed from an upload. * * Answering false keeps the metadata. It does not skip the rewrite: a * decoded-and-re-encoded image is what drops anything appended past the * end of the picture, so turning that off on a public upload form would * leave a valid JPEG with a PHP payload stapled to it stored verbatim. * * @since 1.0.0 * * @param bool $strip Whether to strip the metadata. * @param string $path Absolute path of the image. * @param string $mime Mime type of the image. */ $strip = (bool) apply_filters( 'formpilot_strip_image_metadata', true, $path, $mime ); /* * Imagick is handled directly because its WordPress editor only strips * metadata while generating thumbnails, not on a plain re-save. * * Imagick first, the editor as a fallback. The rewrite is what removes a * payload appended past the end of the image, so one backend throwing is a * reason to try the other rather than a reason to give up and keep the * original bytes. */ if ( class_exists( 'Imagick' ) && $this->rewrite_with_imagick( $path, $strip ) ) { return true; } if ( $this->strip_with_editor( $path ) ) { return true; } /* * Refusing is the right answer - an image that cannot be re-encoded cannot be * vouched for - but refusing in silence is not. GD cannot read TIFF at all, * and older builds cannot read WebP, so a site that deliberately enabled * either and runs without Imagick loses every such upload with a message * that explains nothing to the visitor and nothing to the administrator. * This is the only place that knows why. */ do_action( 'formpilot_log', 'An image could not be re-encoded, so it was refused. No image editor on this server can write this type.', 'error', 'uploads', array( 'mime' => $mime, 'imagick' => class_exists( 'Imagick' ) ? 'available' : 'missing', ) ); return false; } /** * Rewrites an image using Imagick, optionally stripping its metadata. * * The rewrite always happens. Imagick decodes the picture and encodes it * again from the pixels, so whatever a polyglot had appended past the end of * the image data does not survive the round trip. * * @param string $path Absolute path of the image. * @param bool $strip Whether to drop the metadata as well. * * @return bool True when the image was rewritten. */ private function rewrite_with_imagick( string $path, bool $strip = true ): bool { try { $image = new Imagick( $path ); if ( $strip ) { // Keep the colour profile: dropping it shifts the colours visibly. $profiles = $image->getImageProfiles( 'icc', true ); $image->stripImage(); if ( ! empty( $profiles['icc'] ) ) { $image->profileImage( 'icc', $profiles['icc'] ); } } $image->writeImage( $path ); $image->clear(); $image->destroy(); return true; } catch ( \Throwable $exception ) { return false; } } /** * Strips metadata by re-encoding through the WordPress image editor. * * The GD backend cannot carry metadata across a decode and encode cycle, so * saving the image again is enough to drop it. * * @param string $path Absolute path of the image. * * @return bool True when the image was rewritten. */ private function strip_with_editor( string $path ): bool { $editor = wp_get_image_editor( $path ); if ( is_wp_error( $editor ) ) { return false; } return ! is_wp_error( $editor->save( $path ) ); } /** * Removes executable markup from an SVG. * * An SVG is a document, not a picture: a browser opening one runs the script * elements and event handlers inside it, in the context of the site serving * the file. Everything that can execute is removed here. * * @param string $path Absolute path of the SVG. * * @return bool True when the file was rewritten. */ private function sanitize_svg( string $path ): bool { if ( ! class_exists( 'DOMDocument' ) ) { // Cannot parse it, so cannot vouch for it. wp_delete_file( $path ); return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a file the plugin just wrote. $markup = file_get_contents( $path ); if ( ! is_string( $markup ) || '' === trim( $markup ) ) { return false; } /* * Entity declarations are how an SVG reads files off the server, and how * it smuggles markup past the element walk below: an internal entity is * expanded by the browser, not by us. Stripped until nothing changes, * because one pass let `DOCTYPE …` reassemble itself into * a working declaration. The parsed document is checked afterwards as * well, since a text pattern is not a parser. */ do { $before = $markup; $markup = (string) preg_replace( '/]*>/i', '', $markup ); } while ( $markup !== $before ); $document = new DOMDocument(); $previous = libxml_use_internal_errors( true ); // LIBXML_NONET refuses to fetch anything the document points at. $loaded = $document->loadXML( $markup, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); /* * Refused rather than repaired: * * - A DOCTYPE or entity reference that survived the strip carries markup * the browser will expand and this walk never sees. * - A root that is not `` in the SVG namespace is some other document * — an XHTML page saved as .svg renders as a page, forms and all. */ if ( false === $loaded || ! $this->is_plain_svg( $document ) ) { // Unparseable content claiming to be an SVG is not worth keeping. wp_delete_file( $path ); return false; } $this->clean_node( $document ); $clean = $document->saveXML(); if ( ! is_string( $clean ) ) { return false; } return $this->write( $path, $clean ); } /** * Writes the sanitised markup back over the stored file. * * `$wp_filesystem` is preferred, but it is not always there: a site with * `FS_METHOD` set to `ftpext` or `ssh2` has no credentials during a front-end * request, and the abstraction is unavailable. That used to end the sanitise * quietly and leave the original, unsanitised SVG in place. Writing directly * is the correct fallback — the file was created by this request, it sits in * the plugin's own directory, and the alternative is keeping markup nobody has * vouched for. * * @param string $path Absolute path of the stored file. * @param string $markup Sanitised markup. * * @return bool True when the file was rewritten. */ private function write( string $path, string $markup ): bool { if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); global $wp_filesystem; if ( $wp_filesystem instanceof \WP_Filesystem_Base ) { return (bool) $wp_filesystem->put_contents( $path, $markup, FS_CHMOD_FILE ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- The abstraction is unavailable; see the docblock. return false !== file_put_contents( $path, $markup ); } /** * Strips dangerous elements and attributes from a node and its children. * * @param DOMNode $node Node to clean. */ private function clean_node( DOMNode $node ): void { // Walked backwards because removing a child renumbers the list. for ( $index = $node->childNodes->length - 1; $index >= 0; $index-- ) { $child = $node->childNodes->item( $index ); if ( ! $child instanceof DOMNode ) { continue; } /* * `` attaches a stylesheet without being * an element, so an element-only walk never saw it. */ if ( $child instanceof DOMProcessingInstruction ) { $node->removeChild( $child ); continue; } if ( $child instanceof DOMElement && $this->is_blocked_element( $child ) ) { $node->removeChild( $child ); continue; } if ( $child instanceof DOMElement ) { $this->clean_attributes( $child ); } if ( $child->hasChildNodes() ) { $this->clean_node( $child ); } } } /** * Determines whether a parsed document is an SVG with nothing to expand. * * @param DOMDocument $document Parsed upload. * * @return bool True when the root is `` in the SVG namespace and the * document carries no DTD and no entity reference. */ private function is_plain_svg( DOMDocument $document ): bool { $root = $document->documentElement; if ( null !== $document->doctype || ! $root instanceof DOMElement ) { return false; } /* * No namespace is allowed through: without one a browser treats the file * as inert XML, which is what an existing namespace-less upload already * was. Any other namespace is a different kind of document. */ if ( 'svg' !== $root->localName || ! in_array( $root->namespaceURI, array( null, 'http://www.w3.org/2000/svg' ), true ) ) { return false; } $xpath = new \DOMXPath( $document ); $found = $xpath->query( '//node()' ); foreach ( false === $found ? array() : $found as $node ) { if ( XML_ENTITY_REF_NODE === $node->nodeType || XML_DOCUMENT_TYPE_NODE === $node->nodeType ) { return false; } } return true; } /** * Determines whether an element must be removed. * * Matched on the local name rather than the node name. `nodeName` carries * the namespace prefix, so `` reads as `svg:script` and misses * the list — while a browser, which resolves the prefix to the SVG * namespace, runs it exactly like a bare `