diff --git a/src/class-tiny-helpers.php b/src/class-tiny-helpers.php index 68b4688..f2140d3 100644 --- a/src/class-tiny-helpers.php +++ b/src/class-tiny-helpers.php @@ -171,4 +171,28 @@ public static function get_wp_filesystem() { return $wp_filesystem; } + + /** + * Polyfill for `str_starts_with()` function added in PHP 8.0. + * + * Performs a case-sensitive check indicating if + * the haystack begins with needle. + * + * @since 5.9.0 + * + * @param string $haystack The string to search in. + * @param string $needle The substring to search for in the `$haystack`. + * @return bool True if `$haystack` starts with `$needle`, otherwise false. + */ + public static function str_starts_with( $haystack, $needle ) { + if ( function_exists( 'str_starts_with' ) ) { + return str_starts_with( $haystack, $needle ); + } + + if ( '' === $needle ) { + return true; + } + + return 0 === strpos( $haystack, $needle ); + } } diff --git a/src/class-tiny-image-size.php b/src/class-tiny-image-size.php index ca3fd27..e174cf9 100644 --- a/src/class-tiny-image-size.php +++ b/src/class-tiny-image-size.php @@ -240,9 +240,27 @@ public function duplicate_of_size() { return $this->_duplicate_of_size; } + /** + * Deletes the converted image file for this image size. + * + * @return void + */ public function delete_converted_image_size() { - if ( $this->converted_image_exists() ) { - unlink( $this->meta['convert']['path'] ); + if ( ! $this->converted_image_exists() ) { + return; + } + $upload_dir = wp_upload_dir(); + $convert_real_path = realpath( $this->meta['convert']['path'] ); + $real_basedir = realpath( $upload_dir['basedir'] ); + + if ( + $convert_real_path && + Tiny_Helpers::str_starts_with( + $convert_real_path, + trailingslashit( $real_basedir ) + ) + ) { + unlink( $convert_real_path ); } } diff --git a/test/unit/TinyHelpersTest.php b/test/unit/TinyHelpersTest.php index 6bf283b..349a1af 100644 --- a/test/unit/TinyHelpersTest.php +++ b/test/unit/TinyHelpersTest.php @@ -130,4 +130,44 @@ public function test_is_pagebuilder_request_returns_false_for_non_pagebuilder_ke $this->assertFalse(Tiny_Helpers::is_pagebuilder_request()); $_GET = array(); } + +public function test_str_starts_with_returns_true_when_haystack_starts_with_needle() +{ + $this->assertTrue(Tiny_Helpers::str_starts_with('hello world', 'hello')); +} + +public function test_str_starts_with_returns_false_when_needle_is_not_at_start() +{ + $this->assertFalse(Tiny_Helpers::str_starts_with('hello world', 'world')); +} + +public function test_str_starts_with_returns_true_for_empty_needle() +{ + $this->assertTrue(Tiny_Helpers::str_starts_with('hello', '')); +} + +public function test_str_starts_with_returns_true_when_both_are_empty() +{ + $this->assertTrue(Tiny_Helpers::str_starts_with('', '')); +} + +public function test_str_starts_with_returns_false_when_needle_is_longer_than_haystack() +{ + $this->assertFalse(Tiny_Helpers::str_starts_with('hi', 'hello')); +} + +public function test_str_starts_with_is_case_sensitive() +{ + $this->assertFalse(Tiny_Helpers::str_starts_with('Hello', 'hello')); +} + +public function test_str_starts_with_returns_true_for_path_within_upload_dir() +{ + $this->assertTrue(Tiny_Helpers::str_starts_with('/var/www/uploads/image.webp', '/var/www/uploads/')); +} + +public function test_str_starts_with_returns_false_for_sibling_directory_with_shared_prefix() +{ + $this->assertFalse(Tiny_Helpers::str_starts_with('/var/www/uploads-evil/shell.webp', '/var/www/uploads/')); +} } diff --git a/test/unit/TinyImageSizeTest.php b/test/unit/TinyImageSizeTest.php index 9d61db7..c77213a 100644 --- a/test/unit/TinyImageSizeTest.php +++ b/test/unit/TinyImageSizeTest.php @@ -241,6 +241,100 @@ public function test_when_not_compressed_will_mark_as_compressed() $this->assertTrue($image_size->has_been_compressed()); } + /** + * Deletes the converted file when it exists within the upload directory. + * sidenote: we cannot use vfs as realpath prevents virtual file paths. + */ + public function test_delete_converted_image_size_deletes_file_within_upload_dir() { + $tmp_dir = sys_get_temp_dir() . '/tiny-test-' . uniqid(); + $upload_dir = $tmp_dir . '/uploads'; + $file_path = $upload_dir . '/image.webp'; + mkdir( $upload_dir, 0755, true ); + file_put_contents( $file_path, 'webp content' ); + + $this->wp->stub( 'wp_upload_dir', function () use ( $upload_dir ) { + return array( 'basedir' => $upload_dir ); + } ); + + $image_size = new Tiny_Image_Size( $file_path ); + $image_size->meta['convert'] = array( + 'path' => $file_path, + 'type' => 'image/webp', + 'size' => 100, + ); + + $image_size->delete_converted_image_size(); + + $this->assertFalse( file_exists( $file_path ) ); + rmdir( $upload_dir ); + rmdir( $tmp_dir ); + } + + /** + * Does not delete a file that lives outside the upload directory. + */ + public function test_delete_converted_image_size_does_not_delete_file_outside_upload_dir() { + $tmp_dir = sys_get_temp_dir() . '/tiny-test-' . uniqid(); + $upload_dir = $tmp_dir . '/uploads'; + $outside_dir = $tmp_dir . '/outside'; + $file_path = $outside_dir . '/wp-config.php'; + mkdir( $upload_dir, 0755, true ); + mkdir( $outside_dir, 0755, true ); + file_put_contents( $file_path, 'my wp config' ); + + $this->wp->stub( 'wp_upload_dir', function () use ( $upload_dir ) { + return array( 'basedir' => $upload_dir ); + } ); + + $image_size = new Tiny_Image_Size( $file_path ); + $image_size->meta['convert'] = array( + 'path' => $file_path, + 'type' => 'image/webp', + 'size' => 100, + ); + + $image_size->delete_converted_image_size(); + + $this->assertTrue( file_exists( $file_path ), 'file outside upload dir should not be deleted'); + unlink( $file_path ); + rmdir( $outside_dir ); + rmdir( $upload_dir ); + rmdir( $tmp_dir ); + } + + /** + * Does not delete a file in a sibling directory whose name starts with the upload dir name. + * This validates the trailingslashit() protection against sibling-directory bypass. + */ + public function test_delete_converted_image_size_does_not_delete_file_in_sibling_directory() { + $tmp_dir = sys_get_temp_dir() . '/tiny-test-' . uniqid(); + $upload_dir = $tmp_dir . '/uploads'; + $sibling_dir = $tmp_dir . '/uploads-evil'; + $file_path = $sibling_dir . '/shell.webp'; + mkdir( $upload_dir, 0755, true ); + mkdir( $sibling_dir, 0755, true ); + file_put_contents( $file_path, 'malicious content' ); + + $this->wp->stub( 'wp_upload_dir', function () use ( $upload_dir ) { + return array( 'basedir' => $upload_dir ); + } ); + + $image_size = new Tiny_Image_Size( $file_path ); + $image_size->meta['convert'] = array( + 'path' => $file_path, + 'type' => 'image/webp', + 'size' => 100, + ); + + $image_size->delete_converted_image_size(); + + $this->assertTrue( file_exists( $file_path ) ); + unlink( $file_path ); + rmdir( $sibling_dir ); + rmdir( $upload_dir ); + rmdir( $tmp_dir ); + } + /** * Users can still mark an image as converted when compression has already been done. */