Make WordPress Core

Changeset 62619


Ignore:
Timestamp:
07/01/2026 09:32:34 PM (2 months ago)
Author:
adamsilverstein
Message:

REST API: Add dimension validation to sideload endpoint.

Validate uploaded image dimensions in the wp/v2/media/<id>/sideload endpoint before metadata is written, ensuring sideloaded files match the requested image_size. A new private validate_image_dimensions() helper on WP_REST_Attachments_Controller enforces:

  • The original size must match the original attachment dimensions exactly.
  • The full and scaled sizes require positive dimensions only.
  • Regular registered sizes must not exceed the registered sub-size maximums, with a 1px tolerance for rounding differences.

Follow-up to [62428].

Props apermo, westonruter.
Fixes #64798.

Location:
trunk
Files:
3 edited

Legend:

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

    r62615 r62619  
    1717 * @since 4.7.0
    1818 *
     19 * @see add_image_size()
     20 *
    1921 * @global array $_wp_additional_image_sizes
    2022 *
    2123 * @return array Additional images size data.
     24 *
     25 * @phpstan-return array<string, array{
     26 *                     width: non-negative-int,
     27 *                     height: non-negative-int,
     28 *                     crop: array{ 'left'|'center'|'right', 'top'|'center'|'bottom' }|bool,
     29 *                 }>
    2230 */
    2331function wp_get_additional_image_sizes() {
     
    297305 *     @type string $1 The y crop position. Accepts 'top', 'center', or 'bottom'.
    298306 * }
     307 *
     308 * @phpstan-param non-negative-int $width
     309 * @phpstan-param non-negative-int $height
     310 * @phpstan-param array{ 'left'|'center'|'right', 'top'|'center'|'bottom' }|bool $crop
    299311 */
    300312function add_image_size( $name, $width = 0, $height = 0, $crop = false ) {
     
    909921 * @return array[] Associative array of arrays of image sub-size information,
    910922 *                 keyed by image size name.
    911  */
    912 function wp_get_registered_image_subsizes() {
     923 *
     924 * @phpstan-return array<string, array{
     925 *                     width: non-negative-int,
     926 *                     height: non-negative-int,
     927 *                     crop: array{ 'left'|'center'|'right', 'top'|'center'|'bottom' }|bool,
     928 *                 }>
     929 */
     930function wp_get_registered_image_subsizes(): array {
    913931        $additional_sizes = wp_get_additional_image_sizes();
    914932        $all_sizes        = array();
  • trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php

    r62617 r62619  
    21682168
    21692169        /**
     2170         * Validates that uploaded image dimensions are appropriate for the specified image size.
     2171         *
     2172         * @since 7.1.0
     2173         *
     2174         * @param int    $width         Uploaded image width.
     2175         * @param int    $height        Uploaded image height.
     2176         * @param string $image_size    The target image size name.
     2177         * @param int    $attachment_id The attachment ID.
     2178         * @return true|WP_Error True if valid, WP_Error if invalid.
     2179         */
     2180        private function validate_image_dimensions( int $width, int $height, string $image_size, int $attachment_id ) {
     2181                // All image sizes require positive dimensions.
     2182                if ( $width <= 0 || $height <= 0 ) {
     2183                        return new WP_Error(
     2184                                'rest_upload_invalid_dimensions',
     2185                                __( 'Uploaded image must have positive dimensions.' ),
     2186                                array( 'status' => 400 )
     2187                        );
     2188                }
     2189
     2190                // 'original' size: should match original attachment dimensions.
     2191                if ( 'original' === $image_size ) {
     2192                        $metadata = wp_get_attachment_metadata( $attachment_id, true );
     2193                        if ( is_array( $metadata ) && isset( $metadata['width'], $metadata['height'] ) ) {
     2194                                $expected_width  = (int) $metadata['width'];
     2195                                $expected_height = (int) $metadata['height'];
     2196
     2197                                if ( $width !== $expected_width || $height !== $expected_height ) {
     2198                                        return new WP_Error(
     2199                                                'rest_upload_dimension_mismatch',
     2200                                                sprintf(
     2201                                                        /* translators: 1: Actual width, 2: actual height, 3: expected width, 4: expected height. */
     2202                                                        __( 'Uploaded image dimensions (%1$dx%2$d) do not match original image dimensions (%3$dx%4$d).' ),
     2203                                                        $width,
     2204                                                        $height,
     2205                                                        $expected_width,
     2206                                                        $expected_height
     2207                                                ),
     2208                                                array( 'status' => 400 )
     2209                                        );
     2210                                }
     2211                        }
     2212                        return true;
     2213                }
     2214
     2215                // 'full' size (PDF thumbnails) and 'scaled': no further constraints.
     2216                if ( in_array( $image_size, array( 'full', 'scaled' ), true ) ) {
     2217                        return true;
     2218                }
     2219
     2220                // Regular image sizes: validate against registered size constraints.
     2221                $registered_sizes = wp_get_registered_image_subsizes();
     2222
     2223                if ( ! isset( $registered_sizes[ $image_size ] ) ) {
     2224                        return new WP_Error(
     2225                                'rest_upload_unknown_size',
     2226                                __( 'Unknown image size.' ),
     2227                                array( 'status' => 400 )
     2228                        );
     2229                }
     2230
     2231                $size_data  = $registered_sizes[ $image_size ];
     2232                $max_width  = (int) $size_data['width'];
     2233                $max_height = (int) $size_data['height'];
     2234
     2235                // Validate dimensions don't exceed the registered size maximums.
     2236                // Allow 1px tolerance for rounding differences.
     2237                $tolerance = 1;
     2238
     2239                if ( $this->dimension_exceeds_max( $width, $max_width, $tolerance ) ) {
     2240                        return new WP_Error(
     2241                                'rest_upload_dimension_mismatch',
     2242                                sprintf(
     2243                                        /* translators: 1: Image size name, 2: maximum width, 3: actual width. */
     2244                                        __( 'Uploaded image width (%3$d) exceeds maximum for "%1$s" size (%2$d).' ),
     2245                                        $image_size,
     2246                                        $max_width,
     2247                                        $width
     2248                                ),
     2249                                array( 'status' => 400 )
     2250                        );
     2251                }
     2252
     2253                if ( $this->dimension_exceeds_max( $height, $max_height, $tolerance ) ) {
     2254                        return new WP_Error(
     2255                                'rest_upload_dimension_mismatch',
     2256                                sprintf(
     2257                                        /* translators: 1: Image size name, 2: maximum height, 3: actual height. */
     2258                                        __( 'Uploaded image height (%3$d) exceeds maximum for "%1$s" size (%2$d).' ),
     2259                                        $image_size,
     2260                                        $max_height,
     2261                                        $height
     2262                                ),
     2263                                array( 'status' => 400 )
     2264                        );
     2265                }
     2266
     2267                return true;
     2268        }
     2269
     2270        /**
     2271         * Checks whether a dimension exceeds the maximum allowed value.
     2272         *
     2273         * A maximum of zero means the dimension is unconstrained.
     2274         *
     2275         * @since 7.1.0
     2276         *
     2277         * @param int $value     The actual dimension in pixels.
     2278         * @param int $max       The maximum allowed dimension in pixels. Zero means no constraint.
     2279         * @param int $tolerance Pixel tolerance allowed for rounding differences.
     2280         * @return bool True if the value exceeds the maximum plus tolerance.
     2281         */
     2282        private function dimension_exceeds_max( int $value, int $max, int $tolerance ): bool {
     2283                return $max > 0 && $value > $max + $tolerance;
     2284        }
     2285
     2286        /**
    21702287         * Side-loads a media file without creating a new attachment.
    21712288         *
     
    22442361                $path = $file['file'];
    22452362
     2363                /** @var non-empty-string $image_size */
    22462364                $image_size = $request['image_size'];
     2365
     2366                /*
     2367                 * Validate raster sub-sizes before storing them. Source-format companion
     2368                 * originals (e.g. a HEIC or JXL kept next to its JPEG derivative) are
     2369                 * exempt: their dimensions are neither validated nor recorded, and the
     2370                 * source format may not be readable by wp_getimagesize() at all.
     2371                 */
     2372                if ( self::IMAGE_SIZE_SOURCE_ORIGINAL !== $image_size ) {
     2373                        /*
     2374                         * Read the dimensions up front. A file whose dimensions cannot be
     2375                         * read is corrupted or an unsupported format and must be rejected
     2376                         * rather than silently stored with zero dimensions.
     2377                         */
     2378                        $size = wp_getimagesize( $path );
     2379
     2380                        if ( ! $size ) {
     2381                                // Clean up the uploaded file.
     2382                                wp_delete_file( $path );
     2383                                return new WP_Error(
     2384                                        'rest_upload_invalid_image',
     2385                                        __( 'Could not read image dimensions. The file may be corrupted or an unsupported format.' ),
     2386                                        array( 'status' => 400 )
     2387                                );
     2388                        }
     2389
     2390                        /*
     2391                         * Validate the dimensions match the expected size. An array
     2392                         * $image_size represents multiple registered sizes sharing a single
     2393                         * file; those are handled by the per-size branch below, so only
     2394                         * scalar sizes are validated here.
     2395                         */
     2396                        if ( ! is_array( $image_size ) ) {
     2397                                $validation = $this->validate_image_dimensions( $size[0], $size[1], $image_size, $attachment_id );
     2398                                if ( is_wp_error( $validation ) ) {
     2399                                        // Clean up the uploaded file.
     2400                                        wp_delete_file( $path );
     2401                                        return $validation;
     2402                                }
     2403                        }
     2404                }
    22472405
    22482406                // Build sub-size data to return to the client.
  • trunk/tests/phpunit/tests/rest-api/rest-attachments-controller.php

    r62617 r62619  
    38253825
    38263826        /**
     3827         * Tests that sideloading rejects an image whose dimensions exceed the
     3828         * registered maximum for the target image size.
     3829         *
     3830         * @ticket 64798
     3831         * @covers WP_REST_Attachments_Controller::sideload_item
     3832         * @covers WP_REST_Attachments_Controller::validate_image_dimensions
     3833         * @requires function imagejpeg
     3834         */
     3835        public function test_sideload_item_rejects_oversized_dimensions() {
     3836                $this->enable_client_side_media_processing();
     3837
     3838                wp_set_current_user( self::$author_id );
     3839
     3840                // Create an attachment from canola.jpg (640x480).
     3841                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3842                $request->set_header( 'Content-Type', 'image/jpeg' );
     3843                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3844                $request->set_body( file_get_contents( self::$test_file ) );
     3845                $response      = rest_get_server()->dispatch( $request );
     3846                $attachment_id = $response->get_data()['id'];
     3847
     3848                // Sideload the 640x480 image claiming it is a thumbnail (150x150 max).
     3849                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     3850                $request->set_header( 'Content-Type', 'image/jpeg' );
     3851                $request->set_header( 'Content-Disposition', 'attachment; filename=canola-150x150.jpg' );
     3852                $request->set_param( 'image_size', 'thumbnail' );
     3853                $request->set_body( file_get_contents( self::$test_file ) );
     3854                $response = rest_get_server()->dispatch( $request );
     3855
     3856                $this->assertSame( 400, $response->get_status(), 'Oversized sideload should be rejected.' );
     3857                $this->assertSame( 'rest_upload_dimension_mismatch', $response->get_data()['code'] );
     3858        }
     3859
     3860        /**
     3861         * Tests that sideloading accepts an image whose dimensions fit within the
     3862         * registered maximum for the target image size.
     3863         *
     3864         * @ticket 64798
     3865         * @covers WP_REST_Attachments_Controller::sideload_item
     3866         * @covers WP_REST_Attachments_Controller::validate_image_dimensions
     3867         * @requires function imagejpeg
     3868         */
     3869        public function test_sideload_item_accepts_valid_dimensions() {
     3870                $this->enable_client_side_media_processing();
     3871
     3872                wp_set_current_user( self::$author_id );
     3873
     3874                // Create an attachment from canola.jpg (640x480).
     3875                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3876                $request->set_header( 'Content-Type', 'image/jpeg' );
     3877                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3878                $request->set_body( file_get_contents( self::$test_file ) );
     3879                $response      = rest_get_server()->dispatch( $request );
     3880                $attachment_id = $response->get_data()['id'];
     3881
     3882                // test-image.jpg is 50x50, well within the thumbnail maximum (150x150).
     3883                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     3884                $request->set_header( 'Content-Type', 'image/jpeg' );
     3885                $request->set_header( 'Content-Disposition', 'attachment; filename=test-thumbnail.jpg' );
     3886                $request->set_param( 'image_size', 'thumbnail' );
     3887                $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) );
     3888                $response = rest_get_server()->dispatch( $request );
     3889
     3890                $this->assertSame( 200, $response->get_status(), 'Valid thumbnail sideload should succeed.' );
     3891        }
     3892
     3893        /**
     3894         * Tests that sideloading the 'original' size rejects an image whose
     3895         * dimensions do not match the original attachment dimensions.
     3896         *
     3897         * @ticket 64798
     3898         * @covers WP_REST_Attachments_Controller::sideload_item
     3899         * @covers WP_REST_Attachments_Controller::validate_image_dimensions
     3900         * @requires function imagejpeg
     3901         */
     3902        public function test_sideload_item_rejects_original_dimension_mismatch() {
     3903                $this->enable_client_side_media_processing();
     3904
     3905                wp_set_current_user( self::$author_id );
     3906
     3907                // Create an attachment from canola.jpg (640x480).
     3908                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3909                $request->set_header( 'Content-Type', 'image/jpeg' );
     3910                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3911                $request->set_body( file_get_contents( self::$test_file ) );
     3912                $response      = rest_get_server()->dispatch( $request );
     3913                $attachment_id = $response->get_data()['id'];
     3914
     3915                // Sideload a 50x50 image as the original; it does not match 640x480.
     3916                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     3917                $request->set_header( 'Content-Type', 'image/jpeg' );
     3918                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3919                $request->set_param( 'image_size', 'original' );
     3920                $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) );
     3921                $response = rest_get_server()->dispatch( $request );
     3922
     3923                $this->assertSame( 400, $response->get_status(), 'Mismatched original sideload should be rejected.' );
     3924                $this->assertSame( 'rest_upload_dimension_mismatch', $response->get_data()['code'] );
     3925        }
     3926
     3927        /**
     3928         * Tests that sideloading the 'original' size accepts an image whose
     3929         * dimensions match the original attachment dimensions.
     3930         *
     3931         * @ticket 64798
     3932         * @covers WP_REST_Attachments_Controller::sideload_item
     3933         * @covers WP_REST_Attachments_Controller::validate_image_dimensions
     3934         * @requires function imagejpeg
     3935         */
     3936        public function test_sideload_item_accepts_matching_original_dimensions() {
     3937                $this->enable_client_side_media_processing();
     3938
     3939                wp_set_current_user( self::$author_id );
     3940
     3941                // Create an attachment from canola.jpg (640x480).
     3942                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3943                $request->set_header( 'Content-Type', 'image/jpeg' );
     3944                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3945                $request->set_body( file_get_contents( self::$test_file ) );
     3946                $response      = rest_get_server()->dispatch( $request );
     3947                $attachment_id = $response->get_data()['id'];
     3948
     3949                // Sideload the same 640x480 image as the original; dimensions match.
     3950                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     3951                $request->set_header( 'Content-Type', 'image/jpeg' );
     3952                $request->set_header( 'Content-Disposition', 'attachment; filename=canola-original.jpg' );
     3953                $request->set_param( 'image_size', 'original' );
     3954                $request->set_body( file_get_contents( self::$test_file ) );
     3955                $response = rest_get_server()->dispatch( $request );
     3956
     3957                $this->assertSame( 200, $response->get_status(), 'Matching original sideload should succeed.' );
     3958        }
     3959
     3960        /**
     3961         * Tests that sideloading a file whose dimensions cannot be read is rejected
     3962         * rather than stored with zero dimensions.
     3963         *
     3964         * The body is a JFIF header with no frame data: its magic bytes identify it
     3965         * as a JPEG so the upload itself succeeds, but wp_getimagesize() cannot
     3966         * determine dimensions, which is the corrupted/unsupported-format case.
     3967         *
     3968         * @ticket 64798
     3969         * @covers WP_REST_Attachments_Controller::sideload_item
     3970         */
     3971        public function test_sideload_item_rejects_unreadable_image() {
     3972                $this->enable_client_side_media_processing();
     3973
     3974                wp_set_current_user( self::$author_id );
     3975
     3976                // Create an attachment from canola.jpg (640x480).
     3977                $request = new WP_REST_Request( 'POST', '/wp/v2/media' );
     3978                $request->set_header( 'Content-Type', 'image/jpeg' );
     3979                $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' );
     3980                $request->set_body( file_get_contents( self::$test_file ) );
     3981                $response      = rest_get_server()->dispatch( $request );
     3982                $attachment_id = $response->get_data()['id'];
     3983
     3984                // A JPEG SOI + JFIF APP0 marker followed immediately by EOI: valid magic
     3985                // bytes, but no SOF marker, so wp_getimagesize() returns false.
     3986                $unreadable = "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xFF\xD9";
     3987
     3988                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
     3989                $request->set_header( 'Content-Type', 'image/jpeg' );
     3990                $request->set_header( 'Content-Disposition', 'attachment; filename=canola-thumbnail.jpg' );
     3991                $request->set_param( 'image_size', 'thumbnail' );
     3992                $request->set_body( $unreadable );
     3993                $response = rest_get_server()->dispatch( $request );
     3994
     3995                $this->assertSame( 400, $response->get_status(), 'Unreadable image sideload should be rejected.' );
     3996                $this->assertSame( 'rest_upload_invalid_image', $response->get_data()['code'] );
     3997        }
     3998
     3999        /**
    38274000         * Tests that the finalize endpoint triggers wp_generate_attachment_metadata.
    38284001         *
     
    39514124
    39524125                // Sideload a thumbnail sub-size; the response carries its metadata.
     4126                // test-image.jpg is 50x50, within the registered thumbnail maximum
     4127                // (150x150), so it passes sideload dimension validation.
    39534128                $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" );
    39544129                $request->set_header( 'Content-Type', 'image/jpeg' );
    39554130                $request->set_header( 'Content-Disposition', 'attachment; filename=canola-thumb.jpg' );
    39564131                $request->set_param( 'image_size', 'thumbnail' );
    3957                 $request->set_body( (string) file_get_contents( self::$test_file ) );
     4132                $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.jpg' ) );
    39584133                $response = rest_get_server()->dispatch( $request );
    39594134
Note: See TracChangeset for help on using the changeset viewer.