diff --git a/lib/compat/wordpress-6.9/block-comments.php b/lib/compat/wordpress-6.9/block-comments.php index 4b24a4edd9331b..31e50c67a9724d 100644 --- a/lib/compat/wordpress-6.9/block-comments.php +++ b/lib/compat/wordpress-6.9/block-comments.php @@ -120,13 +120,22 @@ function gutenberg_register_block_comment_metadata() { return $value; }, 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + // During comment creation the comment does not yet exist, so + // `object_id` is 0. Defer to the comment controller's own + // create permission — if the request can create the + // comment at all, it can set the suggestion meta on it. + if ( ! $object_id ) { + return current_user_can( 'edit_posts' ); + } + $comment = get_comment( $object_id ); + if ( $comment && 'note' === $comment->comment_type ) { + return current_user_can( 'edit_post', $comment->comment_post_ID ); + } return current_user_can( 'edit_comment', $object_id ); }, ) ); - // Lifecycle status for a suggestion. `pending` on creation; moved to - // `applied` or `rejected` by the apply/reject actions. register_meta( 'comment', '_wp_suggestion_status', @@ -141,6 +150,10 @@ function gutenberg_register_block_comment_metadata() { ), ), 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + $comment = get_comment( $object_id ); + if ( $comment && 'note' === $comment->comment_type ) { + return current_user_can( 'edit_post', $comment->comment_post_ID ); + } return current_user_can( 'edit_comment', $object_id ); }, ) diff --git a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php index 45aab3bee54cf3..a4ce7d5360bdda 100644 --- a/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php +++ b/lib/compat/wordpress-6.9/class-gutenberg-rest-comment-controller-6-9.php @@ -156,6 +156,100 @@ public function get_item_permissions_check( $request ) { return true; } + /** + * Checks if a given request has access to update a comment. + * + * Extends core's check so that users who can `edit_post` on the parent + * post are also allowed to update note-type comments — but only for + * suggestion-lifecycle fields (status and `_wp_suggestion_status` meta). + * This unblocks the suggestion workflow where a post editor applies or + * rejects a suggestion authored by someone else, without granting them + * the ability to rewrite the note's content, reassign authorship, or + * otherwise modify another user's comment. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access, WP_Error otherwise. + */ + public function update_item_permissions_check( $request ) { + $comment = $this->get_comment( $request['id'] ); + if ( is_wp_error( $comment ) ) { + return $comment; + } + + // For note comments, allow users who can edit the parent post to + // update suggestion-lifecycle fields only. + if ( + 'note' === $comment->comment_type && + self::is_suggestion_lifecycle_update( $request ) + ) { + $post = get_post( $comment->comment_post_ID ); + if ( $post && current_user_can( 'edit_post', $post->ID ) ) { + return true; + } + } + + // Fall back to core's default check (moderate_comments or edit_comment). + return parent::update_item_permissions_check( $request ); + } + + /** + * Determines whether a note-update request touches only the fields used + * by the suggestion apply/reject lifecycle. + * + * Allowed fields: + * - `status` (limited to `approved` or `hold`) + * - `meta._wp_suggestion_status` + * + * Any other field present in the request body disqualifies the request + * from the `edit_post` shortcut, forcing it through core's edit_comment + * check instead. + * + * @param WP_REST_Request $request + * @return bool + */ + private static function is_suggestion_lifecycle_update( $request ) { + // Accept either a JSON body (the block editor client) or a form- + // encoded body (custom integrations / curl scripts). Either way the + // shortcut is gated by the same allowlist below — query/URL params + // are intentionally excluded so the body is the source of truth for + // what's being written. + $params = $request->get_json_params(); + if ( ! is_array( $params ) ) { + $params = $request->get_body_params(); + } + if ( ! is_array( $params ) || empty( $params ) ) { + return false; + } + + $allowed_keys = array( 'id', 'status', 'meta' ); + foreach ( array_keys( $params ) as $key ) { + if ( ! in_array( $key, $allowed_keys, true ) ) { + return false; + } + } + + if ( + isset( $params['status'] ) && + ! in_array( $params['status'], array( 'approved', 'hold' ), true ) + ) { + return false; + } + + if ( isset( $params['meta'] ) ) { + if ( ! is_array( $params['meta'] ) ) { + return false; + } + $allowed_meta = array( '_wp_suggestion_status' ); + foreach ( array_keys( $params['meta'] ) as $meta_key ) { + if ( ! in_array( $meta_key, $allowed_meta, true ) ) { + return false; + } + } + } + + return true; + } + public function create_item_permissions_check( $request ) { $is_note = ! empty( $request['type'] ) && 'note' === $request['type']; @@ -296,6 +390,55 @@ public function create_item_permissions_check( $request ) { return true; } + /** + * Validates that an incoming request's `_wp_suggestion` meta is within the + * allowed byte budget. Truncating arbitrary JSON corrupts the payload, so + * we reject before any storage happens. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if no payload or within bounds, WP_Error otherwise. + */ + protected static function validate_suggestion_payload_size( $request ) { + $meta = $request['meta'] ?? null; + if ( ! is_array( $meta ) || ! isset( $meta['_wp_suggestion'] ) ) { + return true; + } + $value = $meta['_wp_suggestion']; + if ( ! is_string( $value ) ) { + return true; + } + if ( strlen( $value ) > GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES ) { + return new WP_Error( + 'rest_suggestion_too_large', + sprintf( + /* translators: %d: maximum allowed byte length. */ + __( 'Suggestion payload exceeds the %d-byte limit.', 'gutenberg' ), + GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES + ), + array( 'status' => 413 ) + ); + } + return true; + } + + /** + * Updates a comment. + * + * Wraps core's update path with a pre-flight size check on the suggestion + * payload so oversized values are rejected with a clean 413 instead of + * silently dropped by the meta sanitize_callback. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error + */ + public function update_item( $request ) { + $size_check = self::validate_suggestion_payload_size( $request ); + if ( is_wp_error( $size_check ) ) { + return $size_check; + } + return parent::update_item( $request ); + } + /** * Creates a comment. * @@ -325,6 +468,14 @@ public function create_item( $request ) { ); } + // Reject oversized suggestion payloads with a 413 so the client knows. + // Without this, the meta sanitize_callback would silently reject the + // value and the suggestion would disappear server-side. + $size_check = self::validate_suggestion_payload_size( $request ); + if ( is_wp_error( $size_check ) ) { + return $size_check; + } + $prepared_comment = $this->prepare_item_for_database( $request ); if ( is_wp_error( $prepared_comment ) ) { return $prepared_comment; @@ -655,5 +806,6 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { function () { $controller = new Gutenberg_REST_Comment_Controller_6_9(); $controller->register_routes(); - } + }, + 11 ); diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 959e9c764c2384..de4eff37b841c2 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -6,6 +6,7 @@ - Added an `editorIntent` preference (`edit`, `suggest`, `view`) with a matching `setEditorIntent` action and `getEditorIntent` selector. Surfaced as an Edit / Suggest / View menu in the editor options for post types that support notes. The `view` intent puts the block editor into a read-only preview. Keyboard shortcuts follow the Google Docs convention: Ctrl+Alt+Shift+Z (Edit), +X (Suggest), +C (View) on Windows / ⌘⌥⇧Z/X/C on macOS. - Added a suggestion-overlay subsystem that powers the `suggest` intent. When active, an `editor.BlockEdit` filter diverts `setAttributes` into an in-memory overlay keyed by `clientId`; the block renders with the pending change merged on top of its real attributes, but the block-editor store stays at the baseline. A toolbar button on the selected block submits the overlay as a note comment with a `_wp_suggestion` meta payload (`schemaVersion`, `blockName`, `baseRevision`, `operations`) or discards it. Phase 2: capture only; Apply/Reject and diff rendering follow. +- REST: allow post editors to accept or reject suggestion notes on their posts without requiring the `moderate_comments` capability. Tightens the `comments` controller for `type=note` so non-author editors can only update the suggestion lifecycle (`status` and `_wp_suggestion_status`), and rejects suggestion payloads larger than 64 KB before they reach the database. ## 14.45.0 (2026-04-29) diff --git a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php index 02d2ea8ed39a56..f866348e8cdb72 100644 --- a/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php +++ b/phpunit/experimental/class-wp-rest-comments-controller-gutenberg-test.php @@ -1,6 +1,27 @@ array( 'reopen' ), ); } + + /** + * Test that a suggestion payload can be stored and retrieved via meta. + */ + public function test_create_note_with_suggestion_meta() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + + $payload = wp_json_encode( + array( + 'schemaVersion' => 1, + 'blockName' => 'core/paragraph', + 'baseRevision' => '2026-04-15T00:00:00', + 'operations' => array( + array( + 'type' => 'attribute-set', + 'attribute' => 'content', + 'before' => 'Hello', + 'after' => 'Hello world', + ), + ), + ) + ); + + $params = array( + 'post' => $post_id, + 'content' => '', + 'type' => 'note', + 'author' => self::$editor_id, + 'meta' => array( + '_wp_suggestion' => $payload, + ), + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + $comment_id = $data['id'] ?? null; + $this->assertIsInt( $comment_id ); + + // Bypass REST schema variability across WP versions and check the + // stored meta directly. The sanitize_callback is in scope of this + // test; the REST layer assembles schemas at runtime in a way that + // isn't always available in the experimental phpunit harness. + $stored = get_comment_meta( $comment_id, '_wp_suggestion', true ); + $this->assertNotEmpty( $stored, 'Suggestion meta should round-trip into storage.' ); + $decoded = json_decode( $stored, true ); + $this->assertSame( 'core/paragraph', $decoded['blockName'] ?? null ); + $this->assertSame( 1, $decoded['schemaVersion'] ?? null ); + $this->assertCount( 1, $decoded['operations'] ?? array() ); + } + + /** + * Test that an editor can update a note they did not author (edit_post check). + */ + public function test_editor_can_update_note_on_own_post() { + wp_set_current_user( self::$admin_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + + // Admin creates a note on editor's post. + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$admin_id, + 'comment_content' => 'suggestion note', + ) + ); + + // Editor (post author) updates the note they did not author. + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $comment_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'status' => 'approved', + 'meta' => array( + '_wp_suggestion_status' => 'applied', + ), + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + // The suggestion-lifecycle override passed because the editor owns + // the parent post; the update succeeds with a 200 status. + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Test that a subscriber cannot update a note on someone else's post. + */ + public function test_subscriber_cannot_update_note() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'user_id' => self::$editor_id, + 'comment_content' => 'a suggestion', + ) + ); + + // Subscriber tries to update the note. + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $comment_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'meta' => array( + '_wp_suggestion_status' => 'rejected', + ), + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Test that _wp_suggestion_status does not persist invalid enum values. + */ + public function test_suggestion_status_ignores_invalid_value() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + ) + ); + + // First set a valid value. + update_comment_meta( $comment_id, '_wp_suggestion_status', 'pending' ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $comment_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'meta' => array( + '_wp_suggestion_status' => 'invalid_value', + ), + ) + ) + ); + + rest_get_server()->dispatch( $request ); + // Even if the request succeeds, the invalid value should not + // overwrite the existing valid value. + $stored = get_comment_meta( $comment_id, '_wp_suggestion_status', true ); + $this->assertSame( 'pending', $stored ); + } + + /** + * Test that a subscriber cannot apply a suggestion even if the request + * only touches the suggestion-lifecycle fields. + */ + public function test_subscriber_cannot_apply_suggestion() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => '', + ) + ); + + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $comment_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'status' => 'approved', + 'meta' => array( + '_wp_suggestion_status' => 'applied', + ), + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Test that creating a note with an oversized suggestion payload is + * rejected with a clear 413 error rather than silently truncated. + */ + public function test_create_rejects_oversized_suggestion_payload() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + + $oversized = str_repeat( 'a', GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES + 1 ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => $post_id, + 'content' => '', + 'type' => 'note', + 'meta' => array( + '_wp_suggestion' => $oversized, + ), + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_suggestion_too_large', $response, 413 ); + } + + /** + * Test that updating a note with an oversized suggestion payload is + * rejected with a clear 413 error. + */ + public function test_update_rejects_oversized_suggestion_payload() { + wp_set_current_user( self::$editor_id ); + $post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => 1, + 'user_id' => self::$editor_id, + 'comment_content' => 'a suggestion', + ) + ); + + $oversized = str_repeat( 'a', GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES + 1 ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $comment_id ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'meta' => array( + '_wp_suggestion' => $oversized, + ), + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_suggestion_too_large', $response, 413 ); + } + + /** + * Test that the sanitize_callback rejects rather than truncates an + * oversized payload reaching the meta layer through a non-REST path. + * Truncating mid-string would corrupt the JSON. + */ + public function test_sanitize_callback_rejects_oversized_value() { + $post_id = self::factory()->post->create(); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + ) + ); + + $oversized = str_repeat( 'a', GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES + 1 ); + update_comment_meta( $comment_id, '_wp_suggestion', $oversized ); + + $stored = get_comment_meta( $comment_id, '_wp_suggestion', true ); + $this->assertSame( '', $stored, 'Oversized payload should be rejected, not truncated.' ); + } + + /** + * Test that `is_suggestion_lifecycle_update` correctly rejects + * request bodies that touch fields outside the suggestion-lifecycle + * allowlist. We assert against the private helper via a request + * probe rather than through the full REST dispatch because actual + * permission behavior for `edit_comment` on a foreign note on a + * post the current user authored is governed by core's + * `map_meta_cap` for `edit_comment` (which delegates to `edit_post` + * on the comment's parent post) — outside the scope of this override. + */ + public function test_lifecycle_update_rejects_non_allowlisted_fields() { + $cases = array( + 'content field blocks shortcut' => array( + 'body' => array( + 'status' => 'approved', + 'content' => 'rewritten', + ), + 'expected' => false, + ), + 'only id/status/meta passes shortcut' => array( + 'body' => array( + 'status' => 'approved', + 'meta' => array( + '_wp_suggestion_status' => 'applied', + ), + ), + 'expected' => true, + ), + 'non-approved status blocks shortcut' => array( + 'body' => array( + 'status' => 'spam', + ), + 'expected' => false, + ), + 'non-allowlisted meta blocks shortcut' => array( + 'body' => array( + 'meta' => array( + '_wp_note_status' => 'resolved', + ), + ), + 'expected' => false, + ), + ); + + foreach ( $cases as $label => $case ) { + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/1' ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $case['body'] ) ); + + $reflection = new ReflectionMethod( + 'Gutenberg_REST_Comment_Controller_6_9', + 'is_suggestion_lifecycle_update' + ); + $reflection->setAccessible( true ); + + $this->assertSame( + $case['expected'], + $reflection->invoke( null, $request ), + "Lifecycle shortcut expectation mismatched for: {$label}" + ); + } + } + + /** + * Test that the lifecycle helper also accepts form-encoded request + * bodies, not only JSON. Custom integrations may issue updates with + * `application/x-www-form-urlencoded` and should benefit from the + * same `edit_post` shortcut as the JSON path. + */ + public function test_lifecycle_update_accepts_form_encoded_bodies() { + $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/1' ); + $request->add_header( 'Content-Type', 'application/x-www-form-urlencoded' ); + $request->set_body_params( + array( + 'status' => 'approved', + 'meta' => array( + '_wp_suggestion_status' => 'applied', + ), + ) + ); + + $reflection = new ReflectionMethod( + 'Gutenberg_REST_Comment_Controller_6_9', + 'is_suggestion_lifecycle_update' + ); + $reflection->setAccessible( true ); + $this->assertTrue( $reflection->invoke( null, $request ) ); + } }