From 5fa4d60e31f4686b5d99527f9e0e71f4bbfb1ec1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 20:43:33 -0700 Subject: [PATCH 1/2] Comments: Make REST comment moderation comment-type aware. `WP_REST_Comments_Controller::check_edit_permission()` short-circuited on the global `moderate_comments` primitive, so a moderator of a custom comment type could not edit or delete comments of that type via REST, while a global moderator could act on every type regardless of its capability model. Route the moderator shortcut through the `moderate_comment` meta capability instead. For comment types using the default capability model this resolves to `moderate_comments` (behavior unchanged), while a type with its own capabilities is gated by its own moderation primitive. Apply the same change to the orphaned-comment branch of `check_read_permission()`. This consumes the meta capabilities added in the previous commit; it makes no change for the built-in comment, pingback, trackback, or note types. See #35214. --- .../endpoints/class-wp-rest-comments-controller.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index f462928847c77..b1038d50539c3 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -1932,7 +1932,7 @@ protected function check_read_permission( $comment, $request ) { return false; } - if ( empty( $comment->comment_post_ID ) && ! current_user_can( 'moderate_comments' ) ) { + if ( empty( $comment->comment_post_ID ) && ! current_user_can( 'moderate_comment', $comment->comment_ID ) ) { return false; } @@ -1956,7 +1956,13 @@ protected function check_edit_permission( $comment ) { return false; } - if ( current_user_can( 'moderate_comments' ) ) { + /* + * Use the per-comment `moderate_comment` meta capability rather than the + * global `moderate_comments` primitive. For comment types using the default + * capability model this resolves to `moderate_comments` (unchanged), while a + * type with its own capabilities is gated by its own moderation primitive. + */ + if ( current_user_can( 'moderate_comment', $comment->comment_ID ) ) { return true; } From 04c12eca91d592feec727f01ba96150087b18872 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 20:43:33 -0700 Subject: [PATCH 2/2] Comments: Add REST tests for comment-type-aware moderation. Dispatch real update and delete requests through the comments controller to prove `check_edit_permission()` honors per-type capabilities: a moderator (or editor) of an independent `review` type can edit and delete review comments, a global `moderate_comments` moderator cannot touch them, and the default and built-in ping types behave exactly as before. See #35214. --- .../rest-api/rest-comment-type-moderation.php | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 tests/phpunit/tests/rest-api/rest-comment-type-moderation.php diff --git a/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php new file mode 100644 index 0000000000000..b63ae32a73a9c --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php @@ -0,0 +1,228 @@ +post->create(); + + self::$global_moderator_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + get_userdata( self::$global_moderator_id )->add_cap( 'moderate_comments' ); + + self::$review_moderator_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + get_userdata( self::$review_moderator_id )->add_cap( 'moderate_reviews' ); + + self::$review_editor_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + get_userdata( self::$review_editor_id )->add_cap( 'edit_others_reviews' ); + } + + public function set_up() { + parent::set_up(); + + // A comment type with an independent capability model. + register_comment_type( 'review', array( 'capability_type' => 'review' ) ); + } + + public function tear_down() { + unregister_comment_type( 'review' ); + + parent::tear_down(); + } + + /** + * Creates a comment of the given type on the shared post. + * + * @param string $comment_type Comment type slug. + * @return int The new comment ID. + */ + private function make_comment( $comment_type ) { + return self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + ) + ); + } + + /** + * Dispatches a REST request to update a comment's content. + * + * @param int $comment_id Comment to update. + * @return WP_REST_Response + */ + private function dispatch_update( $comment_id ) { + $request = new WP_REST_Request( 'POST', '/wp/v2/comments/' . $comment_id ); + $request->set_param( 'content', 'Updated content' ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * Dispatches a REST request to force-delete a comment. + * + * @param int $comment_id Comment to delete. + * @return WP_REST_Response + */ + private function dispatch_delete( $comment_id ) { + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $comment_id ); + $request->set_param( 'force', true ); + + return rest_get_server()->dispatch( $request ); + } + + /* + * Independent capability model. + */ + + /** + * @ticket 35214 + */ + public function test_review_moderator_can_update_review_comment() { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'review' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_review_moderator_can_delete_review_comment() { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_delete( $this->make_comment( 'review' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * The type's `edit_others_reviews` primitive also grants moderation through the + * `edit_comment` fallback in `check_edit_permission()`. + * + * @ticket 35214 + */ + public function test_review_editor_can_update_review_comment() { + wp_set_current_user( self::$review_editor_id ); + + $response = $this->dispatch_update( $this->make_comment( 'review' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * A global `moderate_comments` moderator has no power over an independent type. + * + * @ticket 35214 + */ + public function test_global_moderator_cannot_update_review_comment() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'review' ) ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_global_moderator_cannot_delete_review_comment() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_delete( $this->make_comment( 'review' ) ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * A review moderator has no power over a default-model comment. + * + * @ticket 35214 + */ + public function test_review_moderator_cannot_update_default_comment() { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'comment' ) ); + + $this->assertSame( 403, $response->get_status() ); + } + + /* + * Default capability model: unchanged from historical behavior. + */ + + /** + * @ticket 35214 + */ + public function test_global_moderator_can_update_default_comment() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'comment' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_global_moderator_can_delete_default_comment() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_delete( $this->make_comment( 'comment' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * The built-in pingback type uses the default model and is unaffected. + * + * @ticket 35214 + */ + public function test_global_moderator_can_update_builtin_ping_comment() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'pingback' ) ); + + $this->assertSame( 200, $response->get_status() ); + } +}