Make WordPress Core

Changeset 62198


Ignore:
Timestamp:
04/02/2026 01:23:45 AM (4 days ago)
Author:
peterwilsoncc
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.

Props czarate, westonruter, joefusco.
Fixes #64890.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php

    r61839 r62198  
    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
  • trunk/tests/phpunit/tests/rest-api/rest-sync-server.php

    r62099 r62198  
    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.