Make WordPress Core

Changeset 62283 for branches/7.0


Ignore:
Timestamp:
04/29/2026 12:26:54 PM (3 months ago)
Author:
audrasjb
Message:

REST API: Harden Real Time Collaboration endpoint.

Adds additional validation and permission checks the the Real Time Collaboration endpoint to ensure only input in the expected format is supported.

Reviewed by peterwilsoncc, audrasjb.
Merges [62198] to the 7.0 branch.
Props czarate, westonruter, joefusco, peterwilsoncc.
Fixes #64890.

Location:
branches/7.0
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • branches/7.0

  • branches/7.0/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php

    r61839 r62283  
    3939
    4040        /**
     41         * Maximum total size (in bytes) of the request body.
     42         *
     43         * @since 7.0.0
     44         * @var int
     45         */
     46        const MAX_BODY_SIZE = 16 * MB_IN_BYTES;
     47
     48        /**
     49         * Maximum number of rooms allowed per request.
     50         *
     51         * @since 7.0.0
     52         * @var int
     53         */
     54        const MAX_ROOMS_PER_REQUEST = 50;
     55
     56        /**
     57         * Maximum length of a single update data string.
     58         *
     59         * @since 7.0.0
     60         * @var int
     61         */
     62        const MAX_UPDATE_DATA_SIZE = MB_IN_BYTES;
     63
     64        /**
    4165         * Sync update type: compaction.
    4266         *
     
    97121                        'properties' => array(
    98122                                'data' => array(
    99                                         'type'     => 'string',
    100                                         'required' => true,
     123                                        'type'      => 'string',
     124                                        'required'  => true,
     125                                        'maxLength' => self::MAX_UPDATE_DATA_SIZE,
    101126                                ),
    102127                                'type' => array(
     
    150175                                'callback'            => array( $this, 'handle_request' ),
    151176                                'permission_callback' => array( $this, 'check_permissions' ),
     177                                'validate_callback'   => array( $this, 'validate_request' ),
    152178                                'args'                => array(
    153179                                        'rooms' => array(
     
    156182                                                        'type'       => 'object',
    157183                                                ),
     184                                                'maxItems' => self::MAX_ROOMS_PER_REQUEST,
    158185                                                'required' => true,
    159186                                                'type'     => 'array',
     
    225252
    226253        /**
     254         * Validates that the request body does not exceed the maximum allowed size.
     255         *
     256         * Runs as the route-level validate_callback, after per-arg schema
     257         * validation has already passed.
     258         *
     259         * @since 7.0.0
     260         *
     261         * @param WP_REST_Request $request The REST request.
     262         * @return true|WP_Error True if valid, WP_Error if the body is too large.
     263         */
     264        public function validate_request( WP_REST_Request $request ) {
     265                $body = $request->get_body();
     266                if ( is_string( $body ) && strlen( $body ) > self::MAX_BODY_SIZE ) {
     267                        return new WP_Error(
     268                                'rest_sync_body_too_large',
     269                                __( 'Request body is too large.' ),
     270                                array( 'status' => 413 )
     271                        );
     272                }
     273
     274                return true;
     275        }
     276
     277        /**
    227278         * Handles request: stores sync updates and awareness data, and returns
    228279         * updates the client is missing.
     
    279330         * @param string      $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'.
    280331         * @param string      $entity_name The entity name, e.g. 'post', 'category', 'site'.
    281          * @param string|null $object_id   The object ID / entity key for single entities, null for collections.
     332         * @param string|null $object_id   The numeric object ID / entity key for single entities, null for collections.
    282333         * @return bool True if user has permission, otherwise false.
    283334         */
    284335        private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool {
    285                 // Handle single post type entities with a defined object ID.
    286                 if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) {
    287                         return current_user_can( 'edit_post', (int) $object_id );
    288                 }
    289 
    290                 // Handle single taxonomy term entities with a defined object ID.
    291                 if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) {
    292                         $taxonomy = get_taxonomy( $entity_name );
    293                         return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms );
    294                 }
    295 
    296                 // Handle single comment entities with a defined object ID.
    297                 if ( 'root' === $entity_kind && 'comment' === $entity_name && is_numeric( $object_id ) ) {
    298                         return current_user_can( 'edit_comment', (int) $object_id );
     336                if ( is_string( $object_id ) ) {
     337                        if ( ! ctype_digit( $object_id ) ) {
     338                                return false;
     339                        }
     340                        $object_id = (int) $object_id;
     341                }
     342                if ( null !== $object_id && $object_id <= 0 ) {
     343                        // Object ID must be numeric if provided.
     344                        return false;
     345                }
     346
     347                // Validate permissions for the provided object ID.
     348                if ( is_int( $object_id ) ) {
     349                        // Handle single post type entities with a defined object ID.
     350                        if ( 'postType' === $entity_kind ) {
     351                                if ( get_post_type( $object_id ) !== $entity_name ) {
     352                                        // Post is not of the specified post type.
     353                                        return false;
     354                                }
     355                                return current_user_can( 'edit_post', $object_id );
     356                        }
     357
     358                        // Handle single taxonomy term entities with a defined object ID.
     359                        if ( 'taxonomy' === $entity_kind ) {
     360                                $term_exists = term_exists( $object_id, $entity_name );
     361                                if ( ! is_array( $term_exists ) || ! isset( $term_exists['term_id'] ) ) {
     362                                        // Either term doesn't exist OR term is not in specified taxonomy.
     363                                        return false;
     364                                }
     365
     366                                return current_user_can( 'edit_term', $object_id );
     367                        }
     368
     369                        // Handle single comment entities with a defined object ID.
     370                        if ( 'root' === $entity_kind && 'comment' === $entity_name ) {
     371                                return current_user_can( 'edit_comment', $object_id );
     372                        }
    299373                }
    300374
  • branches/7.0/tests/phpunit/tests/rest-api/rest-sync-server.php

    r62099 r62283  
    1010class WP_Test_REST_Sync_Server extends WP_Test_REST_Controller_Testcase {
    1111
    12         protected static $editor_id;
    13         protected static $subscriber_id;
    14         protected static $post_id;
     12        protected static int $editor_id;
     13        protected static int $subscriber_id;
     14        protected static int $post_id;
     15        protected static int $category_id;
     16        protected static int $tag_id;
     17        protected static int $comment_id;
    1518
    1619        public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
     
    1821                self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) );
    1922                self::$post_id       = $factory->post->create( array( 'post_author' => self::$editor_id ) );
     23                self::$category_id   = $factory->category->create();
     24                self::$tag_id        = $factory->tag->create();
     25                self::$comment_id    = $factory->comment->create( array( 'comment_post_ID' => self::$post_id ) );
    2026
    2127                // Enable option in setUpBeforeClass to ensure REST routes are registered.
     
    2834                delete_option( 'wp_collaboration_enabled' );
    2935                wp_delete_post( self::$post_id, true );
     36                wp_delete_term( self::$category_id, 'category' );
     37                wp_delete_term( self::$tag_id, 'post_tag' );
     38                wp_delete_comment( self::$comment_id, true );
    3039        }
    3140
     
    278287        }
    279288
     289        /**
     290         * @ticket 64890
     291         */
     292        public function test_sync_malformed_object_id_rejected() {
     293                wp_set_current_user( self::$editor_id );
     294
     295                $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:1abc' ) ) );
     296
     297                $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
     298        }
     299
     300        /**
     301         * @ticket 64890
     302         */
     303        public function test_sync_zero_object_id_rejected(): void {
     304                wp_set_current_user( self::$editor_id );
     305
     306                $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:0' ) ) );
     307
     308                $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
     309        }
     310
     311        /**
     312         * @ticket 64890
     313         */
     314        public function test_sync_post_type_mismatch_rejected(): void {
     315                wp_set_current_user( self::$editor_id );
     316
     317                // The test post is of type 'post', not 'page'.
     318                $response = $this->dispatch_sync( array( $this->build_room( 'postType/page:' . self::$post_id ) ) );
     319
     320                $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
     321        }
     322
     323        /**
     324         * @ticket 64890
     325         */
     326        public function test_sync_taxonomy_term_allowed(): void {
     327                wp_set_current_user( self::$editor_id );
     328
     329                $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:' . self::$category_id ) ) );
     330
     331                $this->assertSame( 200, $response->get_status() );
     332        }
     333
     334        /**
     335         * @ticket 64890
     336         */
     337        public function test_sync_nonexistent_taxonomy_term_rejected(): void {
     338                wp_set_current_user( self::$editor_id );
     339
     340                $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:999999' ) ) );
     341
     342                $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
     343        }
     344
     345        /**
     346         * @ticket 64890
     347         */
     348        public function test_sync_taxonomy_term_wrong_taxonomy_rejected(): void {
     349                wp_set_current_user( self::$editor_id );
     350
     351                // The tag term exists in 'post_tag', not 'category'.
     352                $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:' . self::$tag_id ) ) );
     353
     354                $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
     355        }
     356
     357        /**
     358         * @ticket 64890
     359         */
     360        public function test_sync_comment_allowed(): void {
     361                wp_set_current_user( self::$editor_id );
     362
     363                $response = $this->dispatch_sync( array( $this->build_room( 'root/comment:' . self::$comment_id ) ) );
     364
     365                $this->assertSame( 200, $response->get_status() );
     366        }
     367
     368        /**
     369         * @ticket 64890
     370         */
     371        public function test_sync_nonexistent_comment_rejected(): void {
     372                wp_set_current_user( self::$editor_id );
     373
     374                $response = $this->dispatch_sync( array( $this->build_room( 'root/comment:999999' ) ) );
     375
     376                $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
     377        }
     378
     379        /**
     380         * @ticket 64890
     381         */
     382        public function test_sync_nonexistent_post_type_collection_rejected(): void {
     383                wp_set_current_user( self::$editor_id );
     384
     385                $response = $this->dispatch_sync( array( $this->build_room( 'postType/nonexistent_type' ) ) );
     386
     387                $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 );
     388        }
     389
    280390        /*
    281391         * Validation tests.
     
    292402
    293403                $this->assertSame( 400, $response->get_status() );
     404        }
     405
     406        /**
     407         * Verifies that schema type validation rejects a non-string value for the
     408         * update 'data' field, confirming that per-arg schema validation still runs
     409         * with a route-level validate_callback registered.
     410         *
     411         * @ticket 64890
     412         */
     413        public function test_sync_rejects_non_string_update_data(): void {
     414                wp_set_current_user( self::$editor_id );
     415
     416                $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' );
     417                $request->set_body_params(
     418                        array(
     419                                'rooms' => array(
     420                                        array(
     421                                                'after'     => 0,
     422                                                'awareness' => array( 'user' => 'test' ),
     423                                                'client_id' => 1,
     424                                                'room'      => $this->get_post_room(),
     425                                                'updates'   => array(
     426                                                        array(
     427                                                                'data' => 12345,
     428                                                                'type' => 'update',
     429                                                        ),
     430                                                ),
     431                                        ),
     432                                ),
     433                        )
     434                );
     435
     436                $response = rest_get_server()->dispatch( $request );
     437                $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
     438        }
     439
     440        /**
     441         * Verifies that schema enum validation rejects an invalid update type,
     442         * confirming that per-arg schema validation still runs with a route-level
     443         * validate_callback registered.
     444         *
     445         * @ticket 64890
     446         */
     447        public function test_sync_rejects_invalid_update_type_enum(): void {
     448                wp_set_current_user( self::$editor_id );
     449
     450                $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' );
     451                $request->set_body_params(
     452                        array(
     453                                'rooms' => array(
     454                                        array(
     455                                                'after'     => 0,
     456                                                'awareness' => array( 'user' => 'test' ),
     457                                                'client_id' => 1,
     458                                                'room'      => $this->get_post_room(),
     459                                                'updates'   => array(
     460                                                        array(
     461                                                                'data' => 'dGVzdA==',
     462                                                                'type' => 'invalid_type',
     463                                                        ),
     464                                                ),
     465                                        ),
     466                                ),
     467                        )
     468                );
     469
     470                $response = rest_get_server()->dispatch( $request );
     471                $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
     472        }
     473
     474        /**
     475         * Verifies that schema required-field validation rejects a room missing
     476         * the 'client_id' field, confirming that per-arg schema validation still
     477         * runs with a route-level validate_callback registered.
     478         *
     479         * @ticket 64890
     480         */
     481        public function test_sync_rejects_missing_required_room_field(): void {
     482                wp_set_current_user( self::$editor_id );
     483
     484                $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' );
     485                $request->set_body_params(
     486                        array(
     487                                'rooms' => array(
     488                                        array(
     489                                                'after'     => 0,
     490                                                'awareness' => array( 'user' => 'test' ),
     491                                                // 'client_id' deliberately omitted.
     492                                                'room'      => $this->get_post_room(),
     493                                                'updates'   => array(),
     494                                        ),
     495                                ),
     496                        )
     497                );
     498
     499                $response = rest_get_server()->dispatch( $request );
     500                $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
     501        }
     502
     503        /**
     504         * Verifies that the maxItems constraint rejects a request with more rooms
     505         * than MAX_ROOMS_PER_REQUEST.
     506         *
     507         * @ticket 64890
     508         */
     509        public function test_sync_rejects_rooms_exceeding_max_items(): void {
     510                wp_set_current_user( self::$editor_id );
     511
     512                $rooms = array();
     513                for ( $i = 0; $i < WP_HTTP_Polling_Sync_Server::MAX_ROOMS_PER_REQUEST + 1; $i++ ) {
     514                        $rooms[] = $this->build_room( 'root/site', $i + 1 );
     515                }
     516
     517                $response = $this->dispatch_sync( $rooms );
     518                $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
     519        }
     520
     521        /**
     522         * Verifies that the maxLength constraint rejects update data exceeding
     523         * MAX_UPDATE_DATA_SIZE.
     524         *
     525         * @ticket 64890
     526         */
     527        public function test_sync_rejects_update_data_exceeding_max_length(): void {
     528                wp_set_current_user( self::$editor_id );
     529
     530                $oversized_data = str_repeat( 'a', WP_HTTP_Polling_Sync_Server::MAX_UPDATE_DATA_SIZE + 1 );
     531
     532                $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' );
     533                $request->set_body_params(
     534                        array(
     535                                'rooms' => array(
     536                                        array(
     537                                                'after'     => 0,
     538                                                'awareness' => array( 'user' => 'test' ),
     539                                                'client_id' => 1,
     540                                                'room'      => $this->get_post_room(),
     541                                                'updates'   => array(
     542                                                        array(
     543                                                                'data' => $oversized_data,
     544                                                                'type' => 'update',
     545                                                        ),
     546                                                ),
     547                                        ),
     548                                ),
     549                        )
     550                );
     551
     552                $response = rest_get_server()->dispatch( $request );
     553                $this->assertErrorResponse( 'rest_invalid_param', $response, 400 );
     554        }
     555
     556        /**
     557         * Verifies that the route-level validate_callback rejects a request body
     558         * exceeding MAX_BODY_SIZE.
     559         *
     560         * @ticket 64890
     561         */
     562        public function test_sync_rejects_oversized_request_body(): void {
     563                wp_set_current_user( self::$editor_id );
     564
     565                $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' );
     566
     567                // Set valid parsed params so per-arg schema validation passes first.
     568                $request->set_body_params(
     569                        array(
     570                                'rooms' => array(
     571                                        $this->build_room( $this->get_post_room() ),
     572                                ),
     573                        )
     574                );
     575
     576                // Set an oversized raw body to trigger the route-level validate_callback.
     577                $request->set_body( str_repeat( 'x', WP_HTTP_Polling_Sync_Server::MAX_BODY_SIZE + 1 ) );
     578
     579                $response = rest_get_server()->dispatch( $request );
     580                $this->assertErrorResponse( 'rest_sync_body_too_large', $response, 413 );
    294581        }
    295582
Note: See TracChangeset for help on using the changeset viewer.