From 4a954db4e32d2bed797ae1b7a51d3e51b2a1eda9 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 3 Aug 2026 17:04:40 -0700 Subject: [PATCH 1/2] REST API: Bound the size of media sideloaded from a URL. The attachments controller's URL creation path, `create_item_from_url()`, only ran `check_upload_size()`, which returns early when `! is_multisite()`. A single site therefore had no ceiling at all on this path: `upload_max_filesize` and `post_max_size` bound a request body, not a fetch the server makes itself, so any URL could pull a file of any size into the media library. Apply `wp_max_upload_size()` to the download, so a URL cannot bring in a file larger than the same site would accept as a direct upload. The limit is passed to the HTTP request as `limit_response_size`, which stops the transfer once it is passed, so an oversized remote file is no longer written to disk in full before being rejected. Sites that need a different ceiling can adjust it with the existing `upload_size_limit` filter. The multisite checks are unchanged and still run first, so `rest_upload_file_too_big` and `rest_upload_limited_space` continue to be returned for the network file size limit and the site space quota. No limit is applied when `wp_max_upload_size()` cannot determine a size. Follow-up to [62659], [62841]. See #65517. --- .../class-wp-rest-attachments-controller.php | 43 +++++++++ .../rest-api/rest-attachments-controller.php | 92 +++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 6e06f1563c50c..8f4d48ab4703e 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -626,12 +626,42 @@ protected function create_item_from_url( $request ) { ); } + /* + * Cap the download at the same size the site would accept as a direct + * upload. check_upload_size() only applies on multisite, so without a + * ceiling here a single site has no limit at all on this path: the + * `upload_max_filesize` and `post_max_size` directives bound a request + * body, not a fetch the server makes itself. + * + * A limit of 0 means the size could not be determined, in which case + * no ceiling is applied. + */ + $max_size = (int) wp_max_upload_size(); + /* * Download the remote file with WordPress's HTTP API, which validates * the host and blocks requests to private or local addresses. This is * the same primitive core's media_sideload_image() relies on. + * + * `limit_response_size` stops the transfer once the limit is passed, + * so an oversized remote file is never written to disk in full. One + * byte over the ceiling is enough to fail the size check below. */ + $limit_response_size = static function ( $args ) use ( $max_size ) { + $args['limit_response_size'] = $max_size + 1; + return $args; + }; + + if ( $max_size > 0 ) { + add_filter( 'http_request_args', $limit_response_size ); + } + $tmp_file = download_url( $url ); + + if ( $max_size > 0 ) { + remove_filter( 'http_request_args', $limit_response_size ); + } + if ( is_wp_error( $tmp_file ) ) { return $tmp_file; } @@ -649,6 +679,19 @@ protected function create_item_from_url( $request ) { return $size_check; } + if ( $max_size > 0 && wp_filesize( $tmp_file ) > $max_size ) { + if ( file_exists( $tmp_file ) ) { + wp_delete_file( $tmp_file ); + } + + return new WP_Error( + 'rest_upload_file_too_big', + /* translators: %s: Maximum allowed file size in kilobytes. */ + sprintf( __( 'This file is too big. Files must be less than %s KB in size.' ), number_format( $max_size / KB_IN_BYTES ) ), + array( 'status' => 400 ) + ); + } + $attachment_id = media_handle_sideload( $file_array, $post_id ); if ( is_wp_error( $attachment_id ) ) { diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 90899df850d47..ab7aa34c785f6 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -5394,6 +5394,98 @@ public function test_create_item_from_url_exceeds_multisite_site_upload_space() $this->assertErrorResponse( 'rest_upload_limited_space', $response, 400 ); } + /** + * Verifies that the URL sideload path enforces the site's maximum upload + * size on single site as well as multisite. + * + * check_upload_size() returns early when ! is_multisite(), so before this + * check a single site had no ceiling at all on this path. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_exceeds_max_upload_size() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + // The fixture the download is mocked with is comfortably larger than this. + add_filter( 'upload_size_limit', array( $this, 'filter_small_upload_size_limit' ), 20 ); + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/too-big.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + $response = rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10 ); + + $this->assertErrorResponse( 'rest_upload_file_too_big', $response, 400 ); + } + + /** + * Verifies that the download itself is bounded, so an oversized remote file + * is not written to disk in full before the size check rejects it. + * + * @ticket 65517 + * + * @covers WP_REST_Attachments_Controller::create_item_from_url + */ + public function test_create_item_from_url_limits_the_download_size() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$superadmin_id ); + + $request_args = null; + + $capture_args = static function ( $response, $args, $url ) use ( &$request_args ) { + $request_args = $args; + + if ( ! empty( $args['filename'] ) ) { + copy( DIR_TESTDATA . '/images/canola.jpg', $args['filename'] ); + } + + return array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'headers' => array(), + 'cookies' => array(), + 'body' => '', + ); + }; + + add_filter( 'pre_http_request', $capture_args, 10, 3 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_param( 'url', 'https://example.com/photo.jpg' ); + $request->set_param( 'generate_sub_sizes', false ); + + rest_get_server()->dispatch( $request ); + + remove_filter( 'pre_http_request', $capture_args, 10 ); + + $this->assertIsArray( $request_args, 'The download request should have been made.' ); + $this->assertSame( + (int) wp_max_upload_size() + 1, + $request_args['limit_response_size'], + 'The download should be capped one byte past the maximum upload size.' + ); + } + + /** + * Filters the maximum upload size down to a value smaller than the image + * fixture used to mock the download. + * + * @return int A deliberately small upload size limit, in bytes. + */ + public function filter_small_upload_size_limit() { + return 1024; + } + /** * Verifies that a URL with no usable path bails with a 400 before any * download is attempted, rather than handing an empty filename to the From e47b7bb737d6f3e79274843ebc30ed17179cab5c Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Tue, 4 Aug 2026 09:49:47 -0700 Subject: [PATCH 2/2] Update src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php --- .../endpoints/class-wp-rest-attachments-controller.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 8f4d48ab4703e..5d00b3782368d 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -633,8 +633,7 @@ protected function create_item_from_url( $request ) { * `upload_max_filesize` and `post_max_size` directives bound a request * body, not a fetch the server makes itself. * - * A limit of 0 means the size could not be determined, in which case - * no ceiling is applied. + * When `wp_max_upload_size` returns 0, no ceiling is applied. */ $max_size = (int) wp_max_upload_size();