Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/wp-includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -7905,6 +7905,16 @@ function wp_delete_file_from_directory( $file, $directory ) {

if ( false !== $real_file ) {
$real_file = wp_normalize_path( $real_file );

/*
* realpath() resolves `..` segments for real filesystem paths, but it does
* not support stream wrappers, so the stream branch above leaves them in
* place. A surviving `..` segment lets the prefix check below pass while the
* wrapper walks back out of $directory on delete, so reject it here.
*/
if ( preg_match( '#(?:^|/)\.\.(?:/|$)#', $real_file ) ) {
return false;
}
}

if ( false !== $real_directory ) {
Expand Down
98 changes: 98 additions & 0 deletions tests/phpunit/tests/functions/wpDeleteFileFromDirectory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

/**
* Tests for wp_delete_file_from_directory().
*
* @group functions
*
* @covers ::wp_delete_file_from_directory
*/
class Tests_Functions_WpDeleteFileFromDirectory extends WP_UnitTestCase {

/**
* Paths passed to the test stream wrapper's unlink() handler.
*
* @var string[]
*/
public static $unlinked = array();

public function set_up() {
parent::set_up();
self::$unlinked = array();
stream_wrapper_register( 'wpdeletetest', WpDeleteFileFromDirectory_Stream::class );
}

public function tear_down() {
stream_wrapper_unregister( 'wpdeletetest' );
parent::tear_down();
}

/**
* A stream-wrapped path inside the directory is deleted.
*/
public function test_deletes_contained_stream_path() {
$directory = 'wpdeletetest://bucket/uploads';
$file = 'wpdeletetest://bucket/uploads/2024/image.jpg';

$this->assertTrue( wp_delete_file_from_directory( $file, $directory ) );
$this->assertSame( array( $file ), self::$unlinked );
}

/**
* A `..` segment must not let a stream-wrapped path escape the directory.
*
* realpath() resolves `..` for real filesystem paths, but is skipped for
* stream wrappers, so the containment check has to reject the traversal
* itself rather than delete a file outside the directory.
*/
public function test_rejects_stream_path_traversal() {
$directory = 'wpdeletetest://bucket/uploads';
$file = 'wpdeletetest://bucket/uploads/../../secret/keys.json';

$this->assertFalse( wp_delete_file_from_directory( $file, $directory ) );
$this->assertSame( array(), self::$unlinked );
}

/**
* A trailing `..` segment is also rejected.
*/
public function test_rejects_trailing_stream_path_traversal() {
$directory = 'wpdeletetest://bucket/uploads';
$file = 'wpdeletetest://bucket/uploads/subdir/..';

$this->assertFalse( wp_delete_file_from_directory( $file, $directory ) );
$this->assertSame( array(), self::$unlinked );
}

/**
* Dots inside a filename are not treated as a traversal.
*/
public function test_allows_dots_within_stream_filename() {
$directory = 'wpdeletetest://bucket/uploads';
$file = 'wpdeletetest://bucket/uploads/my..archive.zip';

$this->assertTrue( wp_delete_file_from_directory( $file, $directory ) );
$this->assertSame( array( $file ), self::$unlinked );
}
}

/**
* Minimal stream wrapper that records the paths passed to unlink().
*/
class WpDeleteFileFromDirectory_Stream {

public $context;

public function unlink( $path ) {
Tests_Functions_WpDeleteFileFromDirectory::$unlinked[] = $path;
return true;
}

public function url_stat( $path, $flags ) {
return array();
}

public function stream_open( $path, $mode, $options, &$opened_path ) {
return true;
}
}
Loading