diff --git a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php index 88554a48c7d54..a90821ab78d3e 100644 --- a/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php +++ b/src/wp-includes/collaboration/class-wp-http-polling-sync-server.php @@ -37,6 +37,30 @@ class WP_HTTP_Polling_Sync_Server { */ const COMPACTION_THRESHOLD = 50; + /** + * Maximum total size (in bytes) of the request body. + * + * @since 7.0.0 + * @var int + */ + const MAX_BODY_SIZE = 16 * MB_IN_BYTES; + + /** + * Maximum number of rooms allowed per request. + * + * @since 7.0.0 + * @var int + */ + const MAX_ROOMS_PER_REQUEST = 50; + + /** + * Maximum length of a single update data string. + * + * @since 7.0.0 + * @var int + */ + const MAX_UPDATE_DATA_SIZE = MB_IN_BYTES; + /** * Sync update type: compaction. * @@ -96,8 +120,9 @@ public function register_routes(): void { $typed_update_args = array( 'properties' => array( 'data' => array( - 'type' => 'string', - 'required' => true, + 'type' => 'string', + 'required' => true, + 'maxLength' => self::MAX_UPDATE_DATA_SIZE, ), 'type' => array( 'type' => 'string', @@ -149,12 +174,14 @@ public function register_routes(): void { 'methods' => array( WP_REST_Server::CREATABLE ), 'callback' => array( $this, 'handle_request' ), 'permission_callback' => array( $this, 'check_permissions' ), + 'validate_callback' => array( $this, 'validate_request' ), 'args' => array( 'rooms' => array( 'items' => array( 'properties' => $room_args, 'type' => 'object', ), + 'maxItems' => self::MAX_ROOMS_PER_REQUEST, 'required' => true, 'type' => 'array', ), @@ -223,6 +250,30 @@ public function check_permissions( WP_REST_Request $request ) { return true; } + /** + * Validates that the request body does not exceed the maximum allowed size. + * + * Runs as the route-level validate_callback, after per-arg schema + * validation has already passed. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request The REST request. + * @return true|WP_Error True if valid, WP_Error if the body is too large. + */ + public function validate_request( WP_REST_Request $request ) { + $body = $request->get_body(); + if ( is_string( $body ) && strlen( $body ) > self::MAX_BODY_SIZE ) { + return new WP_Error( + 'rest_sync_body_too_large', + __( 'Request body is too large.' ), + array( 'status' => 413 ) + ); + } + + return true; + } + /** * Handles request: stores sync updates and awareness data, and returns * updates the client is missing. @@ -278,24 +329,47 @@ public function handle_request( WP_REST_Request $request ) { * * @param string $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'. * @param string $entity_name The entity name, e.g. 'post', 'category', 'site'. - * @param string|null $object_id The object ID / entity key for single entities, null for collections. + * @param string|null $object_id The numeric object ID / entity key for single entities, null for collections. * @return bool True if user has permission, otherwise false. */ private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool { - // Handle single post type entities with a defined object ID. - if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) { - return current_user_can( 'edit_post', (int) $object_id ); + if ( is_string( $object_id ) ) { + if ( ! ctype_digit( $object_id ) ) { + return false; + } + $object_id = (int) $object_id; } - - // Handle single taxonomy term entities with a defined object ID. - if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) { - $taxonomy = get_taxonomy( $entity_name ); - return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms ); + if ( null !== $object_id && $object_id <= 0 ) { + // Object ID must be numeric if provided. + return false; } - // Handle single comment entities with a defined object ID. - if ( 'root' === $entity_kind && 'comment' === $entity_name && is_numeric( $object_id ) ) { - return current_user_can( 'edit_comment', (int) $object_id ); + // Validate permissions for the provided object ID. + if ( is_int( $object_id ) ) { + // Handle single post type entities with a defined object ID. + if ( 'postType' === $entity_kind ) { + if ( get_post_type( $object_id ) !== $entity_name ) { + // Post is not of the specified post type. + return false; + } + return current_user_can( 'edit_post', $object_id ); + } + + // Handle single taxonomy term entities with a defined object ID. + if ( 'taxonomy' === $entity_kind ) { + $term_exists = term_exists( $object_id, $entity_name ); + if ( ! is_array( $term_exists ) || ! isset( $term_exists['term_id'] ) ) { + // Either term doesn't exist OR term is not in specified taxonomy. + return false; + } + + return current_user_can( 'edit_term', $object_id ); + } + + // Handle single comment entities with a defined object ID. + if ( 'root' === $entity_kind && 'comment' === $entity_name ) { + return current_user_can( 'edit_comment', $object_id ); + } } // All the remaining checks are for collections. If an object ID is provided, diff --git a/tests/phpunit/tests/rest-api/rest-sync-server.php b/tests/phpunit/tests/rest-api/rest-sync-server.php index 7a04226ced8c9..7ded16bd3b033 100644 --- a/tests/phpunit/tests/rest-api/rest-sync-server.php +++ b/tests/phpunit/tests/rest-api/rest-sync-server.php @@ -9,14 +9,20 @@ */ class WP_Test_REST_Sync_Server extends WP_Test_REST_Controller_Testcase { - protected static $editor_id; - protected static $subscriber_id; - protected static $post_id; + protected static int $editor_id; + protected static int $subscriber_id; + protected static int $post_id; + protected static int $category_id; + protected static int $tag_id; + protected static int $comment_id; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); self::$post_id = $factory->post->create( array( 'post_author' => self::$editor_id ) ); + self::$category_id = $factory->category->create(); + self::$tag_id = $factory->tag->create(); + self::$comment_id = $factory->comment->create( array( 'comment_post_ID' => self::$post_id ) ); // Enable option in setUpBeforeClass to ensure REST routes are registered. update_option( 'wp_collaboration_enabled', 1 ); @@ -27,6 +33,9 @@ public static function wpTearDownAfterClass() { self::delete_user( self::$subscriber_id ); delete_option( 'wp_collaboration_enabled' ); wp_delete_post( self::$post_id, true ); + wp_delete_term( self::$category_id, 'category' ); + wp_delete_term( self::$tag_id, 'post_tag' ); + wp_delete_comment( self::$comment_id, true ); } public function set_up() { @@ -277,6 +286,107 @@ public function test_sync_permission_checked_per_room() { $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); } + /** + * @ticket 64890 + */ + public function test_sync_malformed_object_id_rejected() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:1abc' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_zero_object_id_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'postType/post:0' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_post_type_mismatch_rejected(): void { + wp_set_current_user( self::$editor_id ); + + // The test post is of type 'post', not 'page'. + $response = $this->dispatch_sync( array( $this->build_room( 'postType/page:' . self::$post_id ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_taxonomy_term_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:' . self::$category_id ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 64890 + */ + public function test_sync_nonexistent_taxonomy_term_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:999999' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_taxonomy_term_wrong_taxonomy_rejected(): void { + wp_set_current_user( self::$editor_id ); + + // The tag term exists in 'post_tag', not 'category'. + $response = $this->dispatch_sync( array( $this->build_room( 'taxonomy/category:' . self::$tag_id ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_comment_allowed(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'root/comment:' . self::$comment_id ) ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 64890 + */ + public function test_sync_nonexistent_comment_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'root/comment:999999' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 64890 + */ + public function test_sync_nonexistent_post_type_collection_rejected(): void { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch_sync( array( $this->build_room( 'postType/nonexistent_type' ) ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + /* * Validation tests. */ @@ -293,6 +403,183 @@ public function test_sync_invalid_room_format_rejected() { $this->assertSame( 400, $response->get_status() ); } + /** + * Verifies that schema type validation rejects a non-string value for the + * update 'data' field, confirming that per-arg schema validation still runs + * with a route-level validate_callback registered. + * + * @ticket 64890 + */ + public function test_sync_rejects_non_string_update_data(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => array( 'user' => 'test' ), + 'client_id' => 1, + 'room' => $this->get_post_room(), + 'updates' => array( + array( + 'data' => 12345, + 'type' => 'update', + ), + ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that schema enum validation rejects an invalid update type, + * confirming that per-arg schema validation still runs with a route-level + * validate_callback registered. + * + * @ticket 64890 + */ + public function test_sync_rejects_invalid_update_type_enum(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => array( 'user' => 'test' ), + 'client_id' => 1, + 'room' => $this->get_post_room(), + 'updates' => array( + array( + 'data' => 'dGVzdA==', + 'type' => 'invalid_type', + ), + ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that schema required-field validation rejects a room missing + * the 'client_id' field, confirming that per-arg schema validation still + * runs with a route-level validate_callback registered. + * + * @ticket 64890 + */ + public function test_sync_rejects_missing_required_room_field(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => array( 'user' => 'test' ), + // 'client_id' deliberately omitted. + 'room' => $this->get_post_room(), + 'updates' => array(), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that the maxItems constraint rejects a request with more rooms + * than MAX_ROOMS_PER_REQUEST. + * + * @ticket 64890 + */ + public function test_sync_rejects_rooms_exceeding_max_items(): void { + wp_set_current_user( self::$editor_id ); + + $rooms = array(); + for ( $i = 0; $i < WP_HTTP_Polling_Sync_Server::MAX_ROOMS_PER_REQUEST + 1; $i++ ) { + $rooms[] = $this->build_room( 'root/site', $i + 1 ); + } + + $response = $this->dispatch_sync( $rooms ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that the maxLength constraint rejects update data exceeding + * MAX_UPDATE_DATA_SIZE. + * + * @ticket 64890 + */ + public function test_sync_rejects_update_data_exceeding_max_length(): void { + wp_set_current_user( self::$editor_id ); + + $oversized_data = str_repeat( 'a', WP_HTTP_Polling_Sync_Server::MAX_UPDATE_DATA_SIZE + 1 ); + + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + $request->set_body_params( + array( + 'rooms' => array( + array( + 'after' => 0, + 'awareness' => array( 'user' => 'test' ), + 'client_id' => 1, + 'room' => $this->get_post_room(), + 'updates' => array( + array( + 'data' => $oversized_data, + 'type' => 'update', + ), + ), + ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Verifies that the route-level validate_callback rejects a request body + * exceeding MAX_BODY_SIZE. + * + * @ticket 64890 + */ + public function test_sync_rejects_oversized_request_body(): void { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + + // Set valid parsed params so per-arg schema validation passes first. + $request->set_body_params( + array( + 'rooms' => array( + $this->build_room( $this->get_post_room() ), + ), + ) + ); + + // Set an oversized raw body to trigger the route-level validate_callback. + $request->set_body( str_repeat( 'x', WP_HTTP_Polling_Sync_Server::MAX_BODY_SIZE + 1 ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_sync_body_too_large', $response, 413 ); + } + /* * Response format tests. */