Make WordPress Core

Changeset 51939


Ignore:
Timestamp:
10/27/2021 02:58:24 PM (3 years ago)
Author:
johnjamesjacoby
Message:

Admin/HTTP API: add suggested filename support to download_url().

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.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/includes/file.php

    r51899 r51939  
    11131113 * @since 2.5.0
    11141114 * @since 5.2.0 Signature Verification with SoftFail was added.
     1115 * @since 5.9.0 Support for Content-Disposition filename was added.
    11151116 *
    11161117 * @param string $url                    The URL of the file to download.
     
    11811182
    11821183        return new WP_Error( 'http_404', trim( wp_remote_retrieve_response_message( $response ) ), $data );
     1184    }
     1185
     1186    $content_disposition = wp_remote_retrieve_header( $response, 'content-disposition' );
     1187
     1188    if ( $content_disposition ) {
     1189        $content_disposition = strtolower( $content_disposition );
     1190
     1191        if ( 0 === strpos( $content_disposition, 'attachment; filename=' ) ) {
     1192            $tmpfname_disposition = sanitize_file_name( substr( $content_disposition, 21 ) );
     1193        } else {
     1194            $tmpfname_disposition = '';
     1195        }
     1196
     1197        // Potential file name must be valid string
     1198        if ( $tmpfname_disposition && is_string( $tmpfname_disposition ) && ( 0 === validate_file( $tmpfname_disposition ) ) ) {
     1199            if ( rename( $tmpfname, $tmpfname_disposition ) ) {
     1200                $tmpfname = $tmpfname_disposition;
     1201            }
     1202
     1203            if ( ( $tmpfname !== $tmpfname_disposition ) && file_exists( $tmpfname_disposition ) ) {
     1204                unlink( $tmpfname_disposition );
     1205            }
     1206        }
    11831207    }
    11841208
  • trunk/tests/phpunit/tests/admin/includesFile.php

    r51639 r51939  
    7777    public function __return_5() {
    7878        return 5;
     79    }
     80
     81    /**
     82     * @ticket 38231
     83     * @dataProvider data_download_url_should_respect_filename_from_content_disposition_header
     84     *
     85     * @covers ::download_url
     86     *
     87     * @param $filter  A callback containing a fake Content-Disposition header.
     88     */
     89    public function test_download_url_should_respect_filename_from_content_disposition_header( $filter ) {
     90        add_filter( 'pre_http_request', array( $this, $filter ), 10, 3 );
     91
     92        $filename = download_url( 'url_with_content_disposition_header' );
     93        $this->assertStringContainsString( 'filename-from-content-disposition-header', $filename );
     94        $this->assertFileExists( $filename );
     95        $this->unlink( $filename );
     96
     97        remove_filter( 'pre_http_request', array( $this, $filter ) );
     98    }
     99
     100    /**
     101     * Data provider for test_download_url_should_respect_filename_from_content_disposition_header.
     102     *
     103     * @return array
     104     */
     105    public function data_download_url_should_respect_filename_from_content_disposition_header() {
     106        return array(
     107            'valid parameters' => array( 'filter_content_disposition_header_with_filename' ),
     108            'path traversal'   => array( 'filter_content_disposition_header_with_filename_with_path_traversal' ),
     109            'no quotes'        => array( 'filter_content_disposition_header_with_filename_without_quotes' ),
     110        );
     111    }
     112
     113    /**
     114     * Filter callback for data_download_url_should_respect_filename_from_content_disposition_header.
     115     *
     116     * @since 5.9.0
     117     *
     118     * @return array
     119     */
     120    public function filter_content_disposition_header_with_filename( $response, $args, $url ) {
     121        return array(
     122            'response' => array(
     123                'code' => 200,
     124            ),
     125            'headers'  => array(
     126                'content-disposition' => 'attachment; filename="filename-from-content-disposition-header.txt"',
     127            ),
     128        );
     129    }
     130
     131    /**
     132     * Filter callback for data_download_url_should_respect_filename_from_content_disposition_header.
     133     *
     134     * @since 5.9.0
     135     *
     136     * @return array
     137     */
     138    public function filter_content_disposition_header_with_filename_with_path_traversal( $response, $args, $url ) {
     139        return array(
     140            'response' => array(
     141                'code' => 200,
     142            ),
     143            'headers'  => array(
     144                'content-disposition' => 'attachment; filename="../../filename-from-content-disposition-header.txt"',
     145            ),
     146        );
     147    }
     148
     149    /**
     150     * Filter callback for data_download_url_should_respect_filename_from_content_disposition_header.
     151     *
     152     * @since 5.9.0
     153     *
     154     * @return array
     155     */
     156    public function filter_content_disposition_header_with_filename_without_quotes( $response, $args, $url ) {
     157        return array(
     158            'response' => array(
     159                'code' => 200,
     160            ),
     161            'headers'  => array(
     162                'content-disposition' => 'attachment; filename=filename-from-content-disposition-header.txt',
     163            ),
     164        );
     165    }
     166
     167    /**
     168     * @ticket 38231
     169     * @dataProvider data_download_url_should_reject_filename_from_invalid_content_disposition_header
     170     *
     171     * @covers ::download_url
     172     *
     173     * @param $filter  A callback containing a fake Content-Disposition header.
     174     */
     175    public function test_download_url_should_reject_filename_from_invalid_content_disposition_header( $filter ) {
     176        add_filter( 'pre_http_request', array( $this, $filter ), 10, 3 );
     177
     178        $filename = download_url( 'url_with_content_disposition_header' );
     179        $this->assertStringContainsString( 'url_with_content_disposition_header', $filename );
     180        $this->unlink( $filename );
     181
     182        remove_filter( 'pre_http_request', array( $this, $filter ) );
     183    }
     184
     185    /**
     186     * Data provider for test_download_url_should_reject_filename_from_invalid_content_disposition_header.
     187     *
     188     * @return array
     189     */
     190    public function data_download_url_should_reject_filename_from_invalid_content_disposition_header() {
     191        return array(
     192            'no context'        => array( 'filter_content_disposition_header_with_filename_without_context' ),
     193            'inline context'    => array( 'filter_content_disposition_header_with_filename_with_inline_context' ),
     194            'form-data context' => array( 'filter_content_disposition_header_with_filename_with_form_data_context' ),
     195        );
     196    }
     197
     198    /**
     199     * Filter callback for data_download_url_should_reject_filename_from_invalid_content_disposition_header.
     200     *
     201     * @since 5.9.0
     202     *
     203     * @return array
     204     */
     205    public function filter_content_disposition_header_with_filename_without_context( $response, $args, $url ) {
     206        return array(
     207            'response' => array(
     208                'code' => 200,
     209            ),
     210            'headers'  => array(
     211                'content-disposition' => 'filename="filename-from-content-disposition-header.txt"',
     212            ),
     213        );
     214    }
     215
     216    /**
     217     * Filter callback for data_download_url_should_reject_filename_from_invalid_content_disposition_header.
     218     *
     219     * @since 5.9.0
     220     *
     221     * @return array
     222     */
     223    public function filter_content_disposition_header_with_filename_with_inline_context( $response, $args, $url ) {
     224        return array(
     225            'response' => array(
     226                'code' => 200,
     227            ),
     228            'headers'  => array(
     229                'content-disposition' => 'inline; filename="filename-from-content-disposition-header.txt"',
     230            ),
     231        );
     232    }
     233
     234    /**
     235     * Filter callback for data_download_url_should_reject_filename_from_invalid_content_disposition_header.
     236     *
     237     * @since 5.9.0
     238     *
     239     * @return array
     240     */
     241    public function filter_content_disposition_header_with_filename_with_form_data_context( $response, $args, $url ) {
     242        return array(
     243            'response' => array(
     244                'code' => 200,
     245            ),
     246            'headers'  => array(
     247                'content-disposition' => 'form-data; name="file"; filename="filename-from-content-disposition-header.txt"',
     248            ),
     249        );
    79250    }
    80251
Note: See TracChangeset for help on using the changeset viewer.