Skip to content

Commit

Permalink
Admin/HTTP API: add suggested filename support to download_url().
Browse files Browse the repository at this point in the history
This change allows for external clients to supply a suggested filename via a `Content-Disposition` response header. This filename is processed through `sanitize_file_name()` to ensure it is allowable (on the server, MIME's, etc...) and `validate_file()` to prevent directory traversal.

If the suggested filename fails the above processing/checks, that suggestion is discarded and the standard temporary filename (generated by WordPress) is used.

If no `Content-Disposition` header is found in the response headers, the standard temporary filename continues to be used as per normal.

Included in this change are 6 additional PHPUnit tests with 9 assertions. These tests confirm that valid filename values are correctly saved, and invalid filename values are correctly rejected.

Props cklosows, costdev, dd32, johnjamesjacoby, ocean90, psrpinto.

Fixes #38231.

git-svn-id: https://develop.svn.wordpress.org/trunk@51939 602fd350-edb4-49c9-b593-d223f7449a82
  • Loading branch information
JJJ committed Oct 27, 2021
1 parent 98bf67e commit 9b6c18b
Show file tree
Hide file tree
Showing 2 changed files with 195 additions and 0 deletions.
24 changes: 24 additions & 0 deletions src/wp-admin/includes/file.php
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,7 @@ function wp_handle_sideload( &$file, $overrides = false, $time = null ) {
*
* @since 2.5.0
* @since 5.2.0 Signature Verification with SoftFail was added.
* @since 5.9.0 Support for Content-Disposition filename was added.
*
* @param string $url The URL of the file to download.
* @param int $timeout The timeout for the request to download the file.
Expand Down Expand Up @@ -1182,6 +1183,29 @@ function download_url( $url, $timeout = 300, $signature_verification = false ) {
return new WP_Error( 'http_404', trim( wp_remote_retrieve_response_message( $response ) ), $data );
}

$content_disposition = wp_remote_retrieve_header( $response, 'content-disposition' );

if ( $content_disposition ) {
$content_disposition = strtolower( $content_disposition );

if ( 0 === strpos( $content_disposition, 'attachment; filename=' ) ) {
$tmpfname_disposition = sanitize_file_name( substr( $content_disposition, 21 ) );
} else {
$tmpfname_disposition = '';
}

// Potential file name must be valid string
if ( $tmpfname_disposition && is_string( $tmpfname_disposition ) && ( 0 === validate_file( $tmpfname_disposition ) ) ) {
if ( rename( $tmpfname, $tmpfname_disposition ) ) {
$tmpfname = $tmpfname_disposition;
}

if ( ( $tmpfname !== $tmpfname_disposition ) && file_exists( $tmpfname_disposition ) ) {
unlink( $tmpfname_disposition );
}
}
}

$content_md5 = wp_remote_retrieve_header( $response, 'content-md5' );

if ( $content_md5 ) {
Expand Down
171 changes: 171 additions & 0 deletions tests/phpunit/tests/admin/includesFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,177 @@ public function __return_5() {
return 5;
}

/**
* @ticket 38231
* @dataProvider data_download_url_should_respect_filename_from_content_disposition_header
*
* @covers ::download_url
*
* @param $filter A callback containing a fake Content-Disposition header.
*/
public function test_download_url_should_respect_filename_from_content_disposition_header( $filter ) {
add_filter( 'pre_http_request', array( $this, $filter ), 10, 3 );

$filename = download_url( 'url_with_content_disposition_header' );
$this->assertStringContainsString( 'filename-from-content-disposition-header', $filename );
$this->assertFileExists( $filename );
$this->unlink( $filename );

remove_filter( 'pre_http_request', array( $this, $filter ) );
}

/**
* Data provider for test_download_url_should_respect_filename_from_content_disposition_header.
*
* @return array
*/
public function data_download_url_should_respect_filename_from_content_disposition_header() {
return array(
'valid parameters' => array( 'filter_content_disposition_header_with_filename' ),
'path traversal' => array( 'filter_content_disposition_header_with_filename_with_path_traversal' ),
'no quotes' => array( 'filter_content_disposition_header_with_filename_without_quotes' ),
);
}

/**
* Filter callback for data_download_url_should_respect_filename_from_content_disposition_header.
*
* @since 5.9.0
*
* @return array
*/
public function filter_content_disposition_header_with_filename( $response, $args, $url ) {
return array(
'response' => array(
'code' => 200,
),
'headers' => array(
'content-disposition' => 'attachment; filename="filename-from-content-disposition-header.txt"',
),
);
}

/**
* Filter callback for data_download_url_should_respect_filename_from_content_disposition_header.
*
* @since 5.9.0
*
* @return array
*/
public function filter_content_disposition_header_with_filename_with_path_traversal( $response, $args, $url ) {
return array(
'response' => array(
'code' => 200,
),
'headers' => array(
'content-disposition' => 'attachment; filename="../../filename-from-content-disposition-header.txt"',
),
);
}

/**
* Filter callback for data_download_url_should_respect_filename_from_content_disposition_header.
*
* @since 5.9.0
*
* @return array
*/
public function filter_content_disposition_header_with_filename_without_quotes( $response, $args, $url ) {
return array(
'response' => array(
'code' => 200,
),
'headers' => array(
'content-disposition' => 'attachment; filename=filename-from-content-disposition-header.txt',
),
);
}

/**
* @ticket 38231
* @dataProvider data_download_url_should_reject_filename_from_invalid_content_disposition_header
*
* @covers ::download_url
*
* @param $filter A callback containing a fake Content-Disposition header.
*/
public function test_download_url_should_reject_filename_from_invalid_content_disposition_header( $filter ) {
add_filter( 'pre_http_request', array( $this, $filter ), 10, 3 );

$filename = download_url( 'url_with_content_disposition_header' );
$this->assertStringContainsString( 'url_with_content_disposition_header', $filename );
$this->unlink( $filename );

remove_filter( 'pre_http_request', array( $this, $filter ) );
}

/**
* Data provider for test_download_url_should_reject_filename_from_invalid_content_disposition_header.
*
* @return array
*/
public function data_download_url_should_reject_filename_from_invalid_content_disposition_header() {
return array(
'no context' => array( 'filter_content_disposition_header_with_filename_without_context' ),
'inline context' => array( 'filter_content_disposition_header_with_filename_with_inline_context' ),
'form-data context' => array( 'filter_content_disposition_header_with_filename_with_form_data_context' ),
);
}

/**
* Filter callback for data_download_url_should_reject_filename_from_invalid_content_disposition_header.
*
* @since 5.9.0
*
* @return array
*/
public function filter_content_disposition_header_with_filename_without_context( $response, $args, $url ) {
return array(
'response' => array(
'code' => 200,
),
'headers' => array(
'content-disposition' => 'filename="filename-from-content-disposition-header.txt"',
),
);
}

/**
* Filter callback for data_download_url_should_reject_filename_from_invalid_content_disposition_header.
*
* @since 5.9.0
*
* @return array
*/
public function filter_content_disposition_header_with_filename_with_inline_context( $response, $args, $url ) {
return array(
'response' => array(
'code' => 200,
),
'headers' => array(
'content-disposition' => 'inline; filename="filename-from-content-disposition-header.txt"',
),
);
}

/**
* Filter callback for data_download_url_should_reject_filename_from_invalid_content_disposition_header.
*
* @since 5.9.0
*
* @return array
*/
public function filter_content_disposition_header_with_filename_with_form_data_context( $response, $args, $url ) {
return array(
'response' => array(
'code' => 200,
),
'headers' => array(
'content-disposition' => 'form-data; name="file"; filename="filename-from-content-disposition-header.txt"',
),
);
}

/**
* Verify that a WP_Error object is returned when invalid input is passed as the `$url` parameter.
*
Expand Down

0 comments on commit 9b6c18b

Please sign in to comment.