Make WordPress Core

Changeset 63216


Ignore:
Timestamp:
08/12/2026 12:32:34 PM (12 days ago)
Author:
johnbillion
Message:

Media: Prevent loading images into Imagick which might be PostScript.

Props ehtis, dmsnell, jeremyfelt, westonruter, SergeyBiryukov, aaroncampbell, jorbin, batmoo, johnbillion, lancewillett, tyxla, vortfu, xknown.

Merges [63210] into the 6.8 branch.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • branches/6.8/src/wp-includes/class-wp-image-editor-imagick.php

    r60047 r63216  
    2121         */
    2222        protected $image;
     23
     24        /**
     25         * Temporarily stores stream image data while processing internally.
     26         *
     27         * @see self::pdf_load_source()
     28         *
     29         * @since 7.0.4
     30         *
     31         * @var string|null
     32         */
     33        private $stream_file_data = null;
     34
     35        /**
     36         * Temporarily stores the parsed given name for an image while processing internally.
     37         *
     38         * @see self::pdf_load_source()
     39         *
     40         * @since 7.0.4
     41         *
     42         * @var string|null
     43         */
     44        private $image_given_name = null;
    2345
    2446        public function __destruct() {
     
    131153                }
    132154
    133                 if ( ! is_file( $this->file ) && ! wp_is_stream( $this->file ) ) {
     155                $is_stream = wp_is_stream( $this->file );
     156                $is_file   = ! $is_stream && is_file( $this->file );
     157
     158                // Only allow loading files or streams.
     159                if ( ! $is_file && ! $is_stream ) {
    134160                        return new WP_Error( 'error_loading_image', __( 'File does not exist?' ), $this->file );
    135161                }
     162
     163                // Establish the provided filename based on the kind of resource being loaded.
     164                $given_filename = $this->file;
     165                if ( 0 === strncasecmp( $given_filename, 'file://', 7 ) ) {
     166                        $given_filename = basename( substr( $given_filename, 7 ) ); // 7 is the strlen of 'file://'.
     167                } elseif ( 1 === preg_match( '~^https?://~i', $this->file ) ) {
     168                        /*
     169                         * For URLs, it will be the final path segment.
     170                         *
     171                         * Example:
     172                         *
     173                         *     https://wordpress.org/i/happy.png?size=40px
     174                         *                             ╰───────╯
     175                         *                                this is the given filename
     176                         *
     177                         * If the stream returns a `Content-Disposition` header it would
     178                         * provide an alternative name, but this is used as a reasonable
     179                         * proxy to avoid adding the additional complexity of reading and
     180                         * parsing the returned HTTP headers.
     181                         */
     182                        $url_path = wp_parse_url( $this->file, PHP_URL_PATH );
     183
     184                        // This URL can not be parsed, so it is not a valid image resource.
     185                        if ( false === $url_path ) {
     186                                return new WP_Error( 'error_loading_image', __( 'File is not an image.' ), $this->file );
     187                        }
     188
     189                        /**
     190                         * The URL has an empty path, so continue with an empty string.
     191                         *
     192                         * This is the case with a URL such as `https://example.com?file_id=123`
     193                         */
     194                        if ( null === $url_path ) {
     195                                $url_path = '';
     196                        }
     197
     198                        $last_path_at   = strrpos( $url_path, '/' );
     199                        $given_filename = is_int( $last_path_at ) ? substr( $url_path, $last_path_at + 1 ) : $url_path;
     200                        $given_filename = rawurldecode( $given_filename );
     201                }
     202
     203                /*
     204                 * Strip off any potential `Imagick` format specifiers.
     205                 *
     206                 * If a real file exists with the identified format specifier, then
     207                 * `Imagick` may not treat it as a format, but WordPress will reject
     208                 * it anyway to avoid adding more complexity into this detection.
     209                 *
     210                 * `Imagick` reads only the first `FORMAT:` specifier on a name, but
     211                 * stripping a segment would promote a second specifier to the front
     212                 * of the name handed to `Imagick`, which would then honor it.
     213                 *
     214                 * Loop to capture all format specifiers for comparison.
     215                 *
     216                 * Exclude Windows drive-letter prefixes from here.
     217                 */
     218                $imagick_formats = array();
     219                while (
     220                        false !== ( $format_ends_at = strpos( $given_filename, ':' ) ) &&
     221                        1 !== preg_match( '~^[a-z]:~i', $given_filename )
     222                ) {
     223                        $imagick_formats[] = strtoupper( substr( $given_filename, 0, $format_ends_at ) );
     224                        $given_filename    = substr( $given_filename, $format_ends_at + 1 );
     225                }
     226
     227                $file_extension = strtolower( pathinfo( $given_filename, PATHINFO_EXTENSION ) );
    136228
    137229                /*
     
    141233                wp_raise_memory_limit( 'image' );
    142234
     235                /**
     236                 * Read the resource header for MIME sniffing.
     237                 *
     238                 * For files, which will be passed into Imagick by their file names, avoid
     239                 * eagerly loading the entire contents into PHP memory. For streams, however,
     240                 * it’s more important to avoid validating a separate copy of the file data
     241                 * than is later fetched by Imagick, so go ahead and load the entire payload,
     242                 * then pass it to Imagick as the data blob itself.
     243                 *
     244                 * @link https://mimesniff.spec.whatwg.org/#reading-the-resource-header
     245                 */
    143246                try {
    144                         $this->image    = new Imagick();
    145                         $file_extension = strtolower( pathinfo( $this->file, PATHINFO_EXTENSION ) );
    146 
    147                         if ( 'pdf' === $file_extension ) {
    148                                 $pdf_loaded = $this->pdf_load_source();
     247                        if ( $is_file ) {
     248                                $file_data = file_get_contents( $this->file, false, null, 0, 1445 );
     249                        } else {
     250                                $file_data = file_get_contents( $this->file );
     251                        }
     252                } catch ( Exception $e ) {
     253                        $file_data = false;
     254                }
     255                if ( false === $file_data ) {
     256                        return new WP_Error( 'error_loading_image', __( 'File does not exist?' ), $this->file );
     257                }
     258
     259                $pdf_extensions = array(
     260                        'ai',
     261                        'epdf',
     262                        'pdf',
     263                        'pdfa',
     264                        'pocketmod',
     265                );
     266
     267                // Reject files claiming to be PDFs which lack the required signature.
     268                $has_pdf_extension = in_array( $file_extension, $pdf_extensions, true );
     269                $has_pdf_signature = str_starts_with( $file_data, '%PDF-' );
     270                if ( $has_pdf_extension && ! $has_pdf_signature ) {
     271                        return new WP_Error( 'invalid_image', __( 'File is not an image.' ), $this->file );
     272                }
     273
     274                $ps_formats = array(
     275                        'DPS',
     276                        'EPI',
     277                        'EPS',
     278                        'EPSF',
     279                        'EPSI',
     280                        'PS',
     281                        'WPG',
     282                );
     283
     284                $ps_extensions = array(
     285                        'dps',
     286                        'epi',
     287                        'eps',
     288                        'eps2',
     289                        'eps3',
     290                        'epsf',
     291                        'epsi',
     292                        'ept',
     293                        'ept2',
     294                        'ept3',
     295                        'ps',
     296                        'ps2',
     297                        'ps3',
     298                        'wpg',
     299                );
     300
     301                // Reject files which Imagick will parse as PostScript.
     302                if (
     303                        array() !== array_intersect( $imagick_formats, $ps_formats ) ||
     304                        in_array( $file_extension, $ps_extensions, true ) ||
     305                        str_starts_with( $file_data, '%!' ) ||
     306                        str_starts_with( $file_data, "\x04%!" ) ||
     307                        str_starts_with( $file_data, "\xC5\xD0\xD3\xC6" ) ||
     308                        str_starts_with( $file_data, "\xFFWPC" )
     309                ) {
     310                        return new WP_Error( 'invalid_image', __( 'File is not an image.' ), $this->file );
     311                }
     312
     313                $compressed_extensions = array(
     314                        'gz',
     315                        'bz2',
     316                        'svgz',
     317                        'z',
     318                        'wmz',
     319                );
     320
     321                /*
     322                 * Reject compressed archives that Imagick will transparently decompress.
     323                 * Unfortunately this rejects `.svgz` because there’s no intermediate step
     324                 * in the loading process. `Imagick` would decompress the file, then look
     325                 * to see what kind of content was decompressed instead of asserting SVG.
     326                 */
     327                if (
     328                        in_array( $file_extension, $compressed_extensions, true ) ||
     329                        str_starts_with( $file_data, "\x1F\x8B\x08" ) || // gzip
     330                        str_starts_with( $file_data, 'BZh' ) || // bzip2
     331                        str_starts_with( $file_data, "\x1F\x9D" ) // compress
     332                ) {
     333                        return new WP_Error( 'invalid_image', __( 'File is not an image.' ), $this->file );
     334                }
     335
     336                try {
     337                        $this->image = new Imagick();
     338
     339                        if ( $has_pdf_signature ) {
     340                                /*
     341                                 * Load these values for use in the helper method without forcing a change
     342                                 * of its expected arguments, but then free them after calling to prevent
     343                                 * keeping them around in memory and bloating the app.
     344                                 */
     345                                $this->stream_file_data = $is_stream ? $file_data : null;
     346                                $this->image_given_name = $given_filename;
     347                                $pdf_loaded             = $this->pdf_load_source();
     348                                $this->stream_file_data = null;
     349                                $this->image_given_name = null;
    149350
    150351                                if ( is_wp_error( $pdf_loaded ) ) {
     
    152353                                }
    153354                        } else {
    154                                 if ( wp_is_stream( $this->file ) ) {
    155                                         // Due to reports of issues with streams with `Imagick::readImageFile()`, uses `Imagick::readImageBlob()` instead.
    156                                         $this->image->readImageBlob( file_get_contents( $this->file ), $this->file );
     355                                if ( $is_stream ) {
     356                                        $this->image->readImageBlob( $file_data, $given_filename );
    157357                                } else {
    158358                                        $this->image->readImage( $this->file );
     
    169369                        }
    170370
    171                         if ( 'pdf' === $file_extension ) {
     371                        if ( $has_pdf_signature ) {
    172372                                $this->remove_pdf_alpha_channel();
    173373                        }
     
    11111311                }
    11121312
    1113                 try {
    1114                         /*
    1115                          * When generating thumbnails from cropped PDF pages, Imagemagick uses the uncropped
    1116                          * area (resulting in unnecessary whitespace) unless the following option is set.
    1117                          */
    1118                         $this->image->setOption( 'pdf:use-cropbox', true );
    1119 
    1120                         /*
    1121                          * Reading image after Imagick instantiation because `setResolution`
    1122                          * only applies correctly before the image is read.
    1123                          */
    1124                         $this->image->readImage( $filename );
    1125                 } catch ( Exception $e ) {
    1126                         // Attempt to run `gs` without the `use-cropbox` option. See #48853.
    1127                         $this->image->setOption( 'pdf:use-cropbox', false );
    1128 
    1129                         $this->image->readImage( $filename );
    1130                 }
    1131 
    1132                 return true;
     1313                foreach ( array( 'true', 'false' ) as $use_cropbox ) {
     1314                        try {
     1315                                /**
     1316                                 * When generating thumbnails from cropped PDF pages, Imagemagick uses the uncropped
     1317                                 * area (resulting in unnecessary whitespace) unless the following option is set.
     1318                                 *
     1319                                 * However, it sometimes fails, so if that happens, run it without the option.
     1320                                 *
     1321                                 * @ticket 48853
     1322                                 */
     1323                                $this->image->setOption( 'pdf:use-cropbox', $use_cropbox );
     1324
     1325                                /*
     1326                                 * Reading image after Imagick instantiation because `setResolution`
     1327                                 * only applies correctly before the image is read.
     1328                                 */
     1329                                if ( is_string( $this->stream_file_data ) ) {
     1330                                        $this->image->setFilename( 'PDF:unknown.pdf[0]' );
     1331                                        $this->image->readImageBlob( $this->stream_file_data, $this->image_given_name );
     1332                                } else {
     1333                                        $this->image->readImage( $filename );
     1334                                }
     1335
     1336                                return true;
     1337                        } catch ( Exception $e ) {
     1338                                continue;
     1339                        }
     1340                }
     1341
     1342                return new WP_Error( 'invalid_image', __( 'File is not an image.' ), $this->file );
    11331343        }
    11341344}
Note: See TracChangeset for help on using the changeset viewer.