Make WordPress Core

Changeset 63006


Ignore:
Timestamp:
08/04/2026 07:25:22 AM (5 weeks ago)
Author:
westonruter
Message:

XML-RPC: Validate the attachment data in mw_newMediaObject().

Passing anything other than a struct as the fourth argument caused a fatal error, and because the struct was read before the login was attempted, an unauthenticated request was enough to trigger it.

Read and validate the struct only once the request is authenticated and the upload_files capability is confirmed, as every other method on the server does, and reject a call with too few arguments using minimum_args(). The name, type and bits members must all be strings: a struct sent for bits reached fwrite() by way of wp_upload_bits() and threw a TypeError, while one sent for type survived sanitize_mime_type() to reach the database as the attachment's post MIME type. A name left empty by sanitize_file_name() is now reported as a malformed request too, rather than as the server failure wp_upload_bits() produced for it.

The fourth argument is expanded into a nested hash in the documentation, covering the previously undocumented post_id member. Tests cover each rejected shape, the optional members that remain tolerated when absent, and the ordering of the login and capability checks ahead of the validation.

Developed in https://github.com/WordPress/wordpress-develop/pull/12482.
Follow-up to r32579, r53881.

Props josephscott, westonruter, mukesh27.
See #65600.
Fixes #65611.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/class-wp-xmlrpc-server.php

    r62933 r63006  
    64416441         *
    64426442         * @param array $args {
    6443          *     Method arguments. Note: arguments must be ordered as documented.
     6443         *     Method arguments. Note: top-level arguments must be ordered as documented.
    64446444         *
    64456445         *     @type int    $0 Blog ID (unused).
    64466446         *     @type string $1 Username.
    64476447         *     @type string $2 Password.
    6448          *     @type array  $3 Data.
     6448         *     @type array  $3 {
     6449         *         Data for the file to upload.
     6450         *
     6451         *         @type string $name    File name. Sanitized with sanitize_file_name().
     6452         *         @type string $type    Optional. File MIME type, stored as the attachment's
     6453         *                               post MIME type. Default empty string.
     6454         *         @type string $bits    Optional. File contents. Default empty string.
     6455         *         @type int    $post_id Optional. ID of the post to attach the file to.
     6456         *                               Default 0.
     6457         *     }
    64496458         * }
    64506459         * @return array|IXR_Error
    64516460         */
    64526461        public function mw_newMediaObject( $args ) {
     6462                if ( ! $this->minimum_args( $args, 4 ) ) {
     6463                        return $this->error;
     6464                }
     6465
    64536466                $username = $this->escape( $args[1] );
    64546467                $password = $this->escape( $args[2] );
    64556468                $data     = $args[3];
    64566469
    6457                 $name = sanitize_file_name( $data['name'] );
    6458                 $type = $data['type'];
    6459                 $bits = $data['bits'];
    6460 
    64616470                $user = $this->login( $username, $password );
    64626471                if ( ! $user ) {
     
    64716480                        return $this->error;
    64726481                }
     6482
     6483                if (
     6484                        ! is_array( $data ) ||
     6485                        ! is_string( $data['name'] ?? null ) ||
     6486                        ! is_string( $data['type'] ?? '' ) ||
     6487                        ! is_string( $data['bits'] ?? '' )
     6488                ) {
     6489                        return new IXR_Error( 400, __( 'Invalid attachment data.' ) );
     6490                }
     6491
     6492                $name = sanitize_file_name( $data['name'] );
     6493
     6494                // A name consisting only of characters the sanitizer strips leaves nothing to write to.
     6495                if ( '' === $name ) {
     6496                        return new IXR_Error( 400, __( 'Invalid attachment data.' ) );
     6497                }
     6498
     6499                $type = $data['type'] ?? '';
     6500                $bits = $data['bits'] ?? '';
    64736501
    64746502                if ( is_multisite() && upload_is_user_over_quota( false ) ) {
  • trunk/tests/phpunit/tests/xmlrpc/wp/uploadFile.php

    r52010 r63006  
    3535                $this->assertIsString( $result['type'] );
    3636        }
     37
     38        /**
     39         * Tests that a non-array data argument returns an error instead of
     40         * triggering a fatal error.
     41         *
     42         * The data argument (the fourth parameter) is expected to be a struct,
     43         * which is passed to the method as an array. When it is any other type,
     44         * the method must return an IXR_Error rather than attempting to access
     45         * array offsets on a non-array value.
     46         *
     47         * @ticket 65611
     48         *
     49         * @covers wp_xmlrpc_server::mw_newMediaObject
     50         */
     51        public function test_invalid_attachment_data_should_return_error() {
     52                $this->make_user_by_role( 'editor' );
     53
     54                $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', 'not-a-struct' ) );
     55                $this->assertIXRError( $result, 'A non-array data argument should return an IXR_Error.' );
     56                $this->assertSame( 400, $result->code, 'The error code should be 400.' );
     57        }
     58
     59        /**
     60         * Tests that an anonymous request with a non-array data argument returns
     61         * the login error rather than triggering a fatal error.
     62         *
     63         * The reported fatal error was reached without credentials because the
     64         * data struct was read before the login was attempted. The struct must
     65         * only be read once the request is authenticated.
     66         *
     67         * @ticket 65611
     68         *
     69         * @covers wp_xmlrpc_server::mw_newMediaObject
     70         */
     71        public function test_anonymous_request_with_invalid_attachment_data_should_return_login_error() {
     72                $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'not-a-user', 'not-a-password', 'not-a-struct' ) );
     73                $this->assertIXRError( $result, 'An anonymous request should return an IXR_Error.' );
     74                $this->assertSame( 403, $result->code, 'The error code should be the 403 returned for a failed login.' );
     75        }
     76
     77        /**
     78         * Tests that a user who cannot upload files is rejected before the data is
     79         * read.
     80         *
     81         * The capability is checked ahead of the attachment data, so a user who is
     82         * not allowed to upload is told that rather than being told the data is
     83         * malformed. Sending unusable data must not change which error comes back.
     84         *
     85         * @ticket 65611
     86         *
     87         * @covers wp_xmlrpc_server::mw_newMediaObject
     88         */
     89        public function test_incapable_user() {
     90                $this->make_user_by_role( 'subscriber' );
     91
     92                $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'subscriber', 'subscriber', 'not-a-struct' ) );
     93                $this->assertIXRError( $result, 'A user who cannot upload files should return an IXR_Error.' );
     94                $this->assertSame( 401, $result->code, 'The error code should be the 401 returned for a missing capability.' );
     95        }
     96
     97        /**
     98         * Tests that too few arguments return an error instead of emitting a PHP
     99         * notice for the undefined arguments.
     100         *
     101         * @ticket 65611
     102         *
     103         * @covers wp_xmlrpc_server::mw_newMediaObject
     104         *
     105         * @dataProvider data_insufficient_arguments
     106         *
     107         * @param list<mixed> $args The arguments to pass to the method.
     108         */
     109        public function test_insufficient_arguments_should_return_error( array $args ) {
     110                $this->make_user_by_role( 'editor' );
     111
     112                $result = $this->myxmlrpcserver->mw_newMediaObject( $args );
     113                $this->assertIXRError( $result, 'Insufficient arguments should return an IXR_Error.' );
     114                $this->assertSame( 400, $result->code, 'The error code should be 400.' );
     115        }
     116
     117        /**
     118         * Data provider.
     119         *
     120         * @return array<non-falsy-string, array{args: list<mixed>}>
     121         */
     122        public function data_insufficient_arguments(): array {
     123                return array(
     124                        'no arguments'     => array(
     125                                'args' => array(),
     126                        ),
     127                        'only the blog ID' => array(
     128                                'args' => array( 0 ),
     129                        ),
     130                        'missing the data' => array(
     131                                'args' => array( 0, 'editor', 'editor' ),
     132                        ),
     133                );
     134        }
     135
     136        /**
     137         * Tests that a data struct without a usable file name returns an error
     138         * instead of emitting a PHP notice for the undefined array key.
     139         *
     140         * A file name is required to write the upload, so the request cannot
     141         * succeed. It must fail with an IXR_Error rather than by reading an
     142         * undefined array offset.
     143         *
     144         * @ticket 65611
     145         *
     146         * @covers wp_xmlrpc_server::mw_newMediaObject
     147         *
     148         * @dataProvider data_attachment_data_without_name
     149         *
     150         * @param array<string, mixed> $data The data argument to pass to the method.
     151         */
     152        public function test_attachment_data_without_name_should_return_error( array $data ) {
     153                $this->make_user_by_role( 'editor' );
     154
     155                $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) );
     156                $this->assertIXRError( $result, 'A data argument without a name should return an IXR_Error.' );
     157                $this->assertSame( 400, $result->code, 'The error code should be 400.' );
     158        }
     159
     160        /**
     161         * Data provider.
     162         *
     163         * @return array<non-falsy-string, array{data: array<string, mixed>}>
     164         */
     165        public function data_attachment_data_without_name(): array {
     166                return array(
     167                        'empty struct'       => array(
     168                                'data' => array(),
     169                        ),
     170                        'only type and bits' => array(
     171                                'data' => array(
     172                                        'type' => 'image/jpeg',
     173                                        'bits' => 'contents',
     174                                ),
     175                        ),
     176                        'non-string name'    => array(
     177                                'data' => array(
     178                                        'name' => array( 'a2-small.jpg' ),
     179                                        'type' => 'image/jpeg',
     180                                        'bits' => 'contents',
     181                                ),
     182                        ),
     183                );
     184        }
     185
     186        /**
     187         * Tests that a file name left empty by sanitization returns the same error
     188         * as an absent one.
     189         *
     190         * sanitize_file_name() strips special characters and then trims the
     191         * remaining leading and trailing '.', '-' and '_' characters, so a name
     192         * built only from those is reduced to an empty string. That leaves nothing
     193         * to write, which is a malformed request rather than a server failure, so
     194         * it must be reported as a 400 like any other unusable name instead of
     195         * reaching wp_upload_bits() and surfacing as a 500.
     196         *
     197         * @ticket 65611
     198         *
     199         * @covers wp_xmlrpc_server::mw_newMediaObject
     200         *
     201         * @dataProvider data_attachment_data_with_unusable_name
     202         *
     203         * @param string $name The file name to pass to the method.
     204         */
     205        public function test_attachment_data_with_unusable_name_should_return_error( string $name ) {
     206                $this->make_user_by_role( 'editor' );
     207
     208                $data = array(
     209                        'name' => $name,
     210                        'type' => 'image/jpeg',
     211                        'bits' => 'contents',
     212                );
     213
     214                $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) );
     215                $this->assertIXRError( $result, 'A name left empty by sanitization should return an IXR_Error.' );
     216                $this->assertSame( 400, $result->code, 'The error code should be 400.' );
     217        }
     218
     219        /**
     220         * Data provider.
     221         *
     222         * @return array<non-falsy-string, array{name: string}>
     223         */
     224        public function data_attachment_data_with_unusable_name(): array {
     225                return array(
     226                        'empty name'           => array(
     227                                'name' => '',
     228                        ),
     229                        'only dots'            => array(
     230                                'name' => '...',
     231                        ),
     232                        'only dashes'          => array(
     233                                'name' => '---',
     234                        ),
     235                        'only underscores'     => array(
     236                                'name' => '___',
     237                        ),
     238                        'only a space'         => array(
     239                                'name' => ' ',
     240                        ),
     241                        'only special chars'   => array(
     242                                'name' => '///',
     243                        ),
     244                        'only a question mark' => array(
     245                                'name' => '?',
     246                        ),
     247                );
     248        }
     249
     250        /**
     251         * Tests that a data struct with a non-string type or bits member returns an
     252         * error instead of triggering a fatal error.
     253         *
     254         * A struct sent for either member arrives as an array. An array reaches
     255         * fwrite() by way of wp_upload_bits(), which throws a TypeError, and it
     256         * survives sanitize_mime_type() to reach the database as the attachment's
     257         * post MIME type. Both members must be rejected before that point.
     258         *
     259         * @ticket 65611
     260         *
     261         * @covers wp_xmlrpc_server::mw_newMediaObject
     262         *
     263         * @dataProvider data_attachment_data_with_invalid_members
     264         *
     265         * @param array<string, mixed> $data The data argument to pass to the method.
     266         */
     267        public function test_attachment_data_with_invalid_members_should_return_error( array $data ) {
     268                $this->make_user_by_role( 'editor' );
     269
     270                $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) );
     271                $this->assertIXRError( $result, 'A data argument with a non-string member should return an IXR_Error.' );
     272                $this->assertSame( 400, $result->code, 'The error code should be 400.' );
     273        }
     274
     275        /**
     276         * Data provider.
     277         *
     278         * @return array<non-falsy-string, array{data: array<string, mixed>}>
     279         */
     280        public function data_attachment_data_with_invalid_members(): array {
     281                return array(
     282                        'non-string bits' => array(
     283                                'data' => array(
     284                                        'name' => 'a2-small.jpg',
     285                                        'type' => 'image/jpeg',
     286                                        'bits' => array( 'contents' ),
     287                                ),
     288                        ),
     289                        'non-string type' => array(
     290                                'data' => array(
     291                                        'name' => 'a2-small.jpg',
     292                                        'type' => array( 'image/jpeg' ),
     293                                        'bits' => 'contents',
     294                                ),
     295                        ),
     296                );
     297        }
     298
     299        /**
     300         * Tests that a data struct without the optional members is still accepted.
     301         *
     302         * Only the name is required. The type and bits members are tolerated when
     303         * absent, and must not emit a PHP notice for the undefined array keys.
     304         *
     305         * @ticket 65611
     306         *
     307         * @covers wp_xmlrpc_server::mw_newMediaObject
     308         *
     309         * @dataProvider data_attachment_data_with_optional_members_omitted
     310         *
     311         * @param array<string, mixed> $data The data argument to pass to the method.
     312         */
     313        public function test_attachment_data_with_optional_members_omitted_should_be_accepted( array $data ) {
     314                $this->make_user_by_role( 'editor' );
     315
     316                $result = $this->myxmlrpcserver->mw_newMediaObject( array( 0, 'editor', 'editor', $data ) );
     317                $this->assertNotIXRError( $result );
     318                $this->assertIsString( $result['id'] );
     319                $this->assertStringMatchesFormat( '%d', $result['id'] );
     320        }
     321
     322        /**
     323         * Data provider.
     324         *
     325         * @return array<non-falsy-string, array{data: array<string, mixed>}>
     326         */
     327        public function data_attachment_data_with_optional_members_omitted(): array {
     328                return array(
     329                        'missing type' => array(
     330                                'data' => array(
     331                                        'name' => 'a2-small.jpg',
     332                                        'bits' => file_get_contents( DIR_TESTDATA . '/images/a2-small.jpg' ),
     333                                ),
     334                        ),
     335                        'missing bits' => array(
     336                                'data' => array(
     337                                        'name' => 'a2-small.jpg',
     338                                        'type' => 'image/jpeg',
     339                                ),
     340                        ),
     341                );
     342        }
    37343}
Note: See TracChangeset for help on using the changeset viewer.