Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/reusable-performance-test-v2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ jobs:

- name: Install object cache drop-in
if: ${{ inputs.memcached }}
run: cp src/wp-content/object-cache.php build/wp-content/object-cache.php
run: cp tests/phpunit/includes/object-cache.php build/wp-content/object-cache.php

- name: Log running Docker containers
run: docker ps -a
Expand Down
2 changes: 1 addition & 1 deletion src/wp-admin/includes/file.php
Original file line number Diff line number Diff line change
Expand Up @@ -1041,7 +1041,7 @@ function wp_handle_upload_error( &$file, $message ) {
}

// Set correct file permissions.
$stat = stat( dirname( $new_file ) );
$stat = stat( _wp_get_dir_perms_stat_path( $new_file ) );
$perms = $stat['mode'] & 0000666;
chmod( $new_file, $perms );

Expand Down
5 changes: 3 additions & 2 deletions src/wp-includes/class-wp-image-editor-gd.php
Original file line number Diff line number Diff line change
Expand Up @@ -573,8 +573,9 @@ protected function _save( $image, $filename = null, $mime_type = null ) {
}

// Set correct file permissions.
$stat = stat( dirname( $filename ) );
$perms = $stat['mode'] & 0000666; // Same permissions as parent folder, strip off the executable bits.
$stat = stat( _wp_get_dir_perms_stat_path( $filename ) );
$perms = $stat['mode'] & 0000666;
// Same permissions as parent folder, strip off the executable bits.
chmod( $filename, $perms );

return array(
Expand Down
5 changes: 3 additions & 2 deletions src/wp-includes/class-wp-image-editor-imagick.php
Original file line number Diff line number Diff line change
Expand Up @@ -964,8 +964,9 @@ protected function _save( $image, $filename = null, $mime_type = null ) {
}

// Set correct file permissions.
$stat = stat( dirname( $filename ) );
$perms = $stat['mode'] & 0000666; // Same permissions as parent folder, strip off the executable bits.
$stat = stat( _wp_get_dir_perms_stat_path( $filename ) );
$perms = $stat['mode'] & 0000666;
// Same permissions as parent folder, strip off the executable bits.
chmod( $filename, $perms );

return array(
Expand Down
52 changes: 44 additions & 8 deletions src/wp-includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -2070,23 +2070,24 @@ function wp_mkdir_p( $target ) {
$target = '/';
}

if ( file_exists( $target ) ) {
return @is_dir( $target );
}
$stat_target = _wp_normalize_directory_stat_path( $target );

if ( file_exists( $stat_target ) ) {
return @is_dir( $stat_target );
}
// Do not allow path traversals.
if ( str_contains( $target, '../' ) || str_contains( $target, '..' . DIRECTORY_SEPARATOR ) ) {
return false;
}

// We need to find the permissions of the parent folder that exists and inherit that.
$target_parent = dirname( $target );
while ( '.' !== $target_parent && ! is_dir( $target_parent ) && dirname( $target_parent ) !== $target_parent ) {
while ( '.' !== $target_parent && ! is_dir( _wp_normalize_directory_stat_path( $target_parent ) ) && dirname( $target_parent ) !== $target_parent ) {
$target_parent = dirname( $target_parent );
}

// Get the permission bits.
$stat = @stat( $target_parent );
$stat = @stat( _wp_normalize_directory_stat_path( $target_parent ) );
if ( $stat ) {
$dir_perms = $stat['mode'] & 0007777;
} else {
Expand Down Expand Up @@ -2977,7 +2978,7 @@ function wp_upload_bits( $name, $deprecated, $bits, $time = null ) {
clearstatcache();

// Set correct file permissions.
$stat = @ stat( dirname( $new_file ) );
$stat = @ stat( _wp_get_dir_perms_stat_path( $new_file ) );
$perms = $stat['mode'] & 0007777;
$perms = $perms & 0000666;
chmod( $new_file, $perms );
Expand Down Expand Up @@ -7414,6 +7415,7 @@ function _validate_cache_id( $object_id ) {
* @return bool Whether the device is able to upload files.
*/
function _device_can_upload() {

if ( ! wp_is_mobile() ) {
return true;
}
Expand All @@ -7423,7 +7425,7 @@ function _device_can_upload() {
if ( str_contains( $ua, 'iPhone' )
|| str_contains( $ua, 'iPad' )
|| str_contains( $ua, 'iPod' ) ) {
return preg_match( '#OS ([\d_]+) like Mac OS X#', $ua, $version ) && version_compare( $version[1], '6', '>=' );
return preg_match( '#OS ([\d_]+) like Mac OS X#', $ua, $version ) && version_compare( $version[1], '6', '>=' );
}

return true;
Expand All @@ -7439,7 +7441,6 @@ function _device_can_upload() {
*/
function wp_is_stream( $path ) {
$scheme_separator = strpos( $path, '://' );

if ( false === $scheme_separator ) {
// $path isn't a stream.
return false;
Expand All @@ -7450,6 +7451,41 @@ function wp_is_stream( $path ) {
return in_array( $stream, stream_get_wrappers(), true );
}

/**
* Normalizes a directory path for stat-style filesystem checks.
*
* Stream wrappers that model directories as paths ending in a slash can require
* the trailing slash for existence and metadata checks to resolve correctly.
*
* @since 6.9.0
*
* @param string $path Directory path.
* @return string Directory path to use with stat-style checks.
*/
function _wp_normalize_directory_stat_path( $path ) {

if ( wp_is_stream( $path ) ) {
$path = trailingslashit( $path );
}

return $path;
}

/**
* Gets the directory path used to inherit permissions for a file path.
*
* Stream wrappers that model directories as paths ending in a slash can require
* the trailing slash for `stat()` to resolve the parent directory.
*
* @since 6.9.0
*
* @param string $path File path.
* @return string Directory path to use with `stat()`.
*/
function _wp_get_dir_perms_stat_path( $path ) {

return _wp_normalize_directory_stat_path( dirname( $path ) );
}
/**
* Tests if the supplied date is valid for the Gregorian calendar.
*
Expand Down
115 changes: 115 additions & 0 deletions tests/phpunit/includes/class-wp-test-strict-dir-stream.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

/**
* Class WP_Test_Strict_Dir_Stream.
*
* A WP_Test_Stream variant that only treats stream-wrapper directories as
* directories when the path ends in a trailing slash.
*/
class WP_Test_Strict_Dir_Stream extends WP_Test_Stream {

/**
* Initializes internal state for the provided URL.
*
* @param string $url A URL of the form "protocol://bucket/path".
*/
private function open_strict( $url ) {
$components = array_merge(
array(
'host' => '',
'path' => '',
),
parse_url( $url )
);

$this->bucket = $components['host'];
$this->file = $components['path'] ? $components['path'] : '/';

if ( empty( $this->bucket ) ) {
throw new Exception( 'Cannot use an empty bucket name' );
}

if ( ! isset( WP_Test_Stream::$data[ $this->bucket ] ) ) {
WP_Test_Stream::$data[ $this->bucket ] = array();
}

$this->data_ref = null;
if ( array_key_exists( $this->file, WP_Test_Stream::$data[ $this->bucket ] ) ) {
$this->data_ref =& WP_Test_Stream::$data[ $this->bucket ][ $this->file ];
}

$this->position = 0;
}

/**
* Creates a file metadata object, with defaults.
*
* @param array $stats Partial file metadata.
* @return array Complete file metadata.
*/
private function make_strict_stat( $stats ) {
$defaults = array(
'dev' => 0,
'ino' => 0,
'mode' => 0,
'nlink' => 0,
'uid' => 0,
'gid' => 0,
'rdev' => 0,
'size' => 0,
'atime' => 0,
'mtime' => 0,
'ctime' => 0,
'blksize' => 0,
'blocks' => 0,
);

return array_merge( $defaults, $stats );
}

/**
* Retrieves information about a file.
*
* @see WP_Test_Stream::stream_stat()
*
* @return array|false File stats on success, false on failure.
*/
public function stream_stat() {
if ( '/' === substr( $this->file, -1 ) ) {
if ( ! isset( WP_Test_Stream::$data[ $this->bucket ][ $this->file ] ) ) {
return false;
}

return $this->make_strict_stat(
array(
'mode' => WP_Test_Stream::DIRECTORY_MODE,
)
);
}

if ( ! isset( $this->data_ref ) ) {
return false;
}

return $this->make_strict_stat(
array(
'size' => strlen( $this->data_ref ),
'mode' => WP_Test_Stream::FILE_MODE,
)
);
}

/**
* Retrieves information about a file.
*
* @see WP_Test_Stream::url_stat()
*
* @param string $path Path to get information about.
* @param int $flags Bitmask of STREAM_URL_STAT_* constants.
* @return array|false File stats on success, false on failure.
*/
public function url_stat( $path, $flags ) {
$this->open_strict( $path );
return $this->stream_stat();
}
}
31 changes: 31 additions & 0 deletions tests/phpunit/tests/image/editorGd.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* @group wp-image-editor-gd
*/
require_once __DIR__ . '/base.php';
require_once DIR_TESTROOT . '/includes/class-wp-test-stream.php';
require_once DIR_TESTROOT . '/includes/class-wp-test-strict-dir-stream.php';

class Tests_Image_Editor_GD extends WP_Image_UnitTestCase {

Expand Down Expand Up @@ -51,6 +53,35 @@ public function test_supports_mime_type_gif() {
$this->assertSame( $expected, $gd_image_editor->supports_mime_type( 'image/gif' ) );
}

/**
* @ticket 42838
* @requires function imagejpeg
*/
public function test_save_to_nested_stream_path() {
stream_wrapper_register( 'wptestgddir', 'WP_Test_Strict_Dir_Stream' );
WP_Test_Stream::$data = array(
'Tests_Image_Editor_GD' => array(
'/read.jpg' => file_get_contents( DIR_TESTDATA . '/images/waffles.jpg' ),
'/nested-path/' => 'DIRECTORY',
),
);

$file = 'wptestgddir://Tests_Image_Editor_GD/read.jpg';
$destination = 'wptestgddir://Tests_Image_Editor_GD/nested-path/write.jpg';
$gd_image_editor = new WP_Image_Editor_GD( $file );

$loaded = $gd_image_editor->load();
$this->assertNotWPError( $loaded );

$saved = $gd_image_editor->save( $destination );

stream_wrapper_unregister( 'wptestgddir' );

$this->assertNotWPError( $saved );
$this->assertSame( $destination, $saved['path'] );
$this->assertArrayHasKey( '/nested-path/write.jpg', WP_Test_Stream::$data['Tests_Image_Editor_GD'] );
}

/**
* Tests resizing an image, not using crop.
*
Expand Down
34 changes: 33 additions & 1 deletion tests/phpunit/tests/image/editorImagick.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ class Tests_Image_Editor_Imagick extends WP_Image_UnitTestCase {
public $editor_engine = 'WP_Image_Editor_Imagick';

public function set_up() {

require_once ABSPATH . WPINC . '/class-wp-image-editor.php';
require_once ABSPATH . WPINC . '/class-wp-image-editor-imagick.php';
require_once DIR_TESTROOT . '/includes/class-wp-test-stream.php';

require_once DIR_TESTROOT . '/includes/class-wp-test-strict-dir-stream.php';
// This needs to come after the mock image editor class is loaded.
parent::set_up();
}
Expand Down Expand Up @@ -621,6 +622,37 @@ public function test_streams() {
$this->assertSame( $temp_file, $saved['path'] );
}

/**
* @ticket 42838
*/
public function test_nested_streams() {
stream_wrapper_register( 'wptestdir', 'WP_Test_Strict_Dir_Stream' );
WP_Test_Stream::$data = array(
'Tests_Image_Editor_Imagick' => array(
'/read.jpg' => file_get_contents( DIR_TESTDATA . '/images/waffles.jpg' ),
'/nested-path/' => 'DIRECTORY',
),
);

$file = 'wptestdir://Tests_Image_Editor_Imagick/read.jpg';
$imagick_image_editor = new WP_Image_Editor_Imagick( $file );

$loaded = $imagick_image_editor->load();
$this->assertNotWPError( $loaded );

$temp_file = 'wptestdir://Tests_Image_Editor_Imagick/nested-path/write.jpg';
$saved = $imagick_image_editor->save( $temp_file );

if ( $temp_file !== $saved['path'] ) {
unlink( $saved['path'] );
}

stream_wrapper_unregister( 'wptestdir' );

$this->assertNotWPError( $saved );
$this->assertSame( $temp_file, $saved['path'] );
$this->assertArrayHasKey( '/nested-path/write.jpg', WP_Test_Stream::$data['Tests_Image_Editor_Imagick'] );
}
/**
* @ticket 51665
*/
Expand Down
Loading
Loading