diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index 50345c6331..8a28c63adc 100644 --- a/src/wp-admin/includes/file.php +++ b/src/wp-admin/includes/file.php @@ -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. @@ -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 ) { diff --git a/tests/phpunit/tests/admin/includesFile.php b/tests/phpunit/tests/admin/includesFile.php index 5929940955..db17f834b5 100644 --- a/tests/phpunit/tests/admin/includesFile.php +++ b/tests/phpunit/tests/admin/includesFile.php @@ -78,6 +78,177 @@ class Tests_Admin_IncludesFile extends WP_UnitTestCase { 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. *