Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/class-tiny-helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
}
22 changes: 20 additions & 2 deletions src/class-tiny-image-size.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
}

Expand Down
40 changes: 40 additions & 0 deletions test/unit/TinyHelpersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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/'));
}
}
94 changes: 94 additions & 0 deletions test/unit/TinyImageSizeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading