lative to the uploads folder. * @param string $format Image format. "original" or "webp". * @param integer $original_file_mtime Original file mtime. * * @return true|\WP_Error True if the image was successfully backed up. WP_Error object in case something failed. * * @since 3.12.6.1_1.1-1 */ private function move_original_image_to_backup_folder( $url, $relative_url, $format, $original_file_mtime ) { $backup_file_path = $this->backup_path . $relative_url; if ( ! $this->is_path_contained( $this->backup_path, $backup_file_path ) ) { return Sentry::enrich_wp_error( new WP_Error( 'image_minification_unsafe_relative_url', esc_html__( 'Refusing to back up image: target escapes the backup folder.', 'rocket' ), [ 'url' => $url, ] ) ); } $file_path_directory = dirname( $backup_file_path ); if ( ! $this->filesystem->is_dir( $file_path_directory ) ) { if ( ! rocket_mkdir_p( $file_path_directory ) ) { return Sentry::enrich_wp_error( new WP_Error( 'image_minification_backup_folder_creation_failure', esc_html__( 'The backup folder could not be created.', 'rocket' ), [ 'dir' => $file_path_directory, ] ) ); } } $original_file_path = $this->get_original_file_path( $relative_url ); if ( '' === $original_file_path ) { return Sentry::enrich_wp_error( new WP_Error( 'image_minification_unsafe_relative_url', esc_html__( 'Refusing to back up image: source escapes WP_CONTENT_DIR.', 'rocket' ), [ 'url' => $url, ] ) ); } if ( 'original' !== $format ) { $original_file_path = $this->maybe_append_format_extension( $original_file_path, $format ); $backup_file_path = $this->maybe_append_format_extension( $backup_file_path, $format ); } if ( $this->filesystem->exists( $original_file_path ) ) { $result = $this->filesystem->move( $original_file_path, $backup_file_path, true ); if ( true === $result ) { // We must leave mtime unchanged for the scanner to correctly search for new files. $this->filesystem->touch( $backup_file_path, $original_file_mtime ); } return $result; // @phpstan-ignore-line } return true; } /** * Replaces original image in the uploads folder using the minified image stored in a temporary folder. * * It takes care of creating the target folder in uploads folder if needed. * * @param string $url The URL of the original image. * @param string $relative_url URL of the image relative to the uploads folder. * @param string $format Image format. "original" or "webp". * @param integer $original_file_mtime Original file mtime. * * @return true|\WP_Error True if the image was successfully downloaded and saved. WP_Error object in case something failed. * * @since 3.12.6.1_1.1-1 */ private function replace_original_image( $url, $relative_url, $format, $original_file_mtime ) { $original_file_path = $this->get_original_file_path( $relative_url ); if ( '' === $original_file_path ) { return Sentry::enrich_wp_error( new WP_Error( 'image_minification_unsafe_relative_url', esc_html__( 'Refusing to replace image: target escapes WP_CONTENT_DIR.', 'rocket' ), [ 'url' => $url, ] ) ); } $file_path_directory = dirname( $original_file_path ); if ( ! $this->filesystem->is_dir( $file_path_directory ) ) { if ( ! rocket_mkdir_p( $file_path_directory ) ) { return Sentry::enrich_wp_error( new WP_Error( 'image_minification_uploads_folder_creation_failure', esc_html__( 'The uploads folder could not be created.', 'rocket' ), [ 'dir' => $file_path_directory, ] ) ); } } $downloaded_file_path = $this->download_path . $relative_url; if ( 'original' !== $format ) { $original_file_path = $this->maybe_append_format_extension( $original_file_path, $format ); $downloaded_file_path = $this->maybe_append_format_extension( $downloaded_file_path, $format ); } if ( $this->filesystem->exists( $downloaded_file_path ) ) { $result = $this->filesystem->move( $downloaded_file_path, $original_file_path, true ); if ( true === $result ) { // We must leave mtime unchanged for the scanner to correctly search for new files. $this->filesystem->touch( $original_file_path, $original_file_mtime ); } return $result; // @phpstan-ignore-line } return true; } /** * Extracts the path relative to the wp-content folder from $url. * * Rejects any value that, after stripping the WP_CONTENT_URL prefix, still * carries a parent-directory segment ('..') or is otherwise absolute. The * returned string is therefore safe to concatenate with a trusted root such * as WP_CONTENT_DIR, $this->download_path or $this->backup_path without * escaping that root via path traversal. An empty string signals refusal. * * @param string $url Full image URL. * * @return string Path relative to the wp-content folder, or '' if the URL * cannot be reduced to a safe wp-content-relative path. */ private function extract_relative_url( string $url ) { // Refuse inputs that do not parse as a URL with at least a scheme or // host component. parse_url() is permissive and will happily return // the entire raw string as 'path' for inputs like 'not a url', // letting non-URLs reach get_original_file_path() and produce paths // such as WP_CONTENT_DIR/not a url that exist nowhere but would // otherwise pass containment. $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || ( empty( $parts['scheme'] ) && empty( $parts['host'] ) ) ) { return ''; } $relative_url = isset( $parts['path'] ) ? $parts['path'] : ''; $content_url = wp_parse_url( WP_CONTENT_URL, PHP_URL_PATH ); // @phpstan-ignore-line if ( ! is_string( $relative_url ) || '' === $relative_url ) { return ''; } if ( is_string( $content_url ) && '' !== $content_url && 0 === strpos( $relative_url, $content_url ) ) { $relative_url = substr( $relative_url, strlen( $content_url ) ); } $relative_url = ltrim( $relative_url, '/' ); if ( '' === $relative_url ) { return ''; } // Reject traversal segments — defence-in-depth so $relative_url cannot // escape WP_CONTENT_DIR / download_path / backup_path when concatenated. // Note: percent-encoded variants ('%2e%2e' etc.) are intentionally not // normalised here — PHP filesystem APIs treat them as literal directory // names rather than URL-decoding them, so a literal '%2e%2e' segment // cannot traverse out of a contained base. is_path_contained() is the // authoritative second layer for any normalisation gap. foreach ( explode( '/', $relative_url ) as $segment ) { if ( '..' === $segment ) { return ''; } } return $relative_url; } /** * Builds the path to the original image. * * Verifies that the resolved location is contained inside WP_CONTENT_DIR. * * @param string $relative_url Image UP relative to the wp-content folder. * * @return string Absolute path to the original image, or '' if the path * would escape WP_CONTENT_DIR. */ private function get_original_file_path( string $relative_url ) { $path = WP_CONTENT_DIR . DIRECTORY_SEPARATOR . $relative_url; if ( ! $this->is_path_contained( WP_CONTENT_DIR, $path ) ) { return ''; } return $path; } /** * Checks that $candidate, once canonicalised, stays under $base. * * Uses realpath() on the candidate's deepest existing ancestor so the check * works for files that have not been created yet (download/backup targets). * * @param string $base Trusted root directory. * @param string $candidate Absolute path that must stay inside $base. * * @return bool True when $candidate resolves inside $base. */ private function is_path_contained( string $base, string $candidate ) { if ( '' === $base || '' === $candidate ) { return false; } $real_base = realpath( $base ); if ( false === $real_base ) { return false; } $real_base = rtrim( $real_base, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR; // Walk up until we find an ancestor that exists, then resolve it. $ancestor = $candidate; while ( '' !== $ancestor && ! file_exists( $ancestor ) ) { $parent = dirname( $ancestor ); if ( $parent === $ancestor ) { return false; } $ancestor = $parent; } $real_ancestor = realpath( $ancestor ); if ( false === $real_ancestor ) { return false; } $real_ancestor = rtrim( $real_ancestor, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR; return 0 === strpos( $real_ancestor, $real_base ); } /** * Appends the image format extension to the path if needed. * * @param string $path Path to the image. * @param string $format Image format. "original" or "webp". * * @return string Path to the image with the format extension appended. */ private function maybe_append_format_extension( string $path, string $format = 'original' ) { if ( 'original' !== $format ) { $path .= '.' . strtolower( $format ); } return $path; } /** * Checks whether the downloaded payload begins with a recognised image magic header. * * Defence-in-depth against an attacker-substituted body slipping through and being * written to the uploads tree as e.g. a polyglot PHP webshell. * * @param string $image_data Raw bytes returned by the SaaS download endpoint. * * @return bool True when the leading bytes match JPEG / PNG / GIF / WebP / AVIF. */ private function is_supported_image_payload( $image_data ) { if ( ! is_string( $image_data ) || strlen( $image_data ) < 12 ) { return false; } // JPEG: FF D8 FF. if ( "\xFF\xD8\xFF" === substr( $image_data, 0, 3 ) ) { return true; } // PNG: 89 50 4E 47 0D 0A 1A 0A. if ( "\x89PNG\r\n\x1A\n" === substr( $image_data, 0, 8 ) ) { return true; } // GIF: "GIF87a" or "GIF89a". if ( 'GIF87a' === substr( $image_data, 0, 6 ) || 'GIF89a' === substr( $image_data, 0, 6 ) ) { return true; } // RIFF container (WebP): "RIFF" .... "WEBP". if ( 'RIFF' === substr( $image_data, 0, 4 ) && 'WEBP' === substr( $image_data, 8, 4 ) ) { return true; } // ISO BMFF container (AVIF): bytes 4-11 are "ftypavif" / "ftypavis". $ftyp = substr( $image_data, 4, 8 ); if ( 'ftypavif' === $ftyp || 'ftypavis' === $ftyp ) { return true; } return false; } /** * Checks if the original file still exists. * * @param string $url Full image URL. * * @return bool True if the original file exists. False otherwise. */ public function original_file_exists( $url ) { $relative_url = $this->extract_relative_url( $url ); // Guard against extract_relative_url() refusal: an empty relative path // would resolve get_original_file_path() to WP_CONTENT_DIR itself, and // filesystem->exists() on that base would always return true — turning // this validation into a no-op for malformed / out-of-tree URLs. if ( '' === $relative_url ) { return false; } $file_path = $this->get_original_file_path( $relative_url ); if ( '' === $file_path ) { return false; } return $this->filesystem->exists( $file_path ); } }