From e6118f3a21590879415c2c78c360236f8091ca02 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 2 Mar 2026 14:31:56 +1000 Subject: [PATCH 1/7] Harden suggested-authors endpoint against user enumeration - Enforce minimum search length of 2 characters server-side - Remove user_email from search columns to prevent email confirmation - Empty or short searches now return empty results instead of listing users Co-Authored-By: Claude Opus 4.6 --- includes/class-map-rest-api.php | 18 ++++++---- tests/Test_Rest_API.php | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/includes/class-map-rest-api.php b/includes/class-map-rest-api.php index abf2627..da85c1f 100644 --- a/includes/class-map-rest-api.php +++ b/includes/class-map-rest-api.php @@ -292,8 +292,15 @@ public static function revoke_invite( \WP_REST_Request $request ): \WP_REST_Resp * @return \WP_REST_Response */ public static function get_suggested_authors( \WP_REST_Request $request ): \WP_REST_Response { - $post_id = (int) $request->get_param( 'post_id' ); - $search = (string) $request->get_param( 'search' ); + $post_id = (int) $request->get_param( 'post_id' ); + $search = (string) $request->get_param( 'search' ); + + // Require a minimum search length to prevent user enumeration. + // On large networks, a search term is always required. + if ( mb_strlen( $search ) < 2 ) { + return new \WP_REST_Response( array(), 200 ); + } + $post = get_post( $post_id ); $existing_ids = Co_Authors::get_co_authors( $post_id ); $existing_ids[] = (int) $post->post_author; @@ -302,13 +309,10 @@ public static function get_suggested_authors( \WP_REST_Request $request ): \WP_R 'capability' => array( 'edit_posts' ), 'number' => 20, 'exclude' => $existing_ids, - 'search_columns' => array( 'user_login', 'user_nicename', 'user_email', 'display_name' ), + 'search' => '*' . $search . '*', + 'search_columns' => array( 'user_login', 'user_nicename', 'display_name' ), ); - if ( '' !== $search ) { - $args['search'] = '*' . $search . '*'; - } - $users = get_users( $args ); $result = array(); foreach ( $users as $user ) { diff --git a/tests/Test_Rest_API.php b/tests/Test_Rest_API.php index 0300b96..3de30db 100644 --- a/tests/Test_Rest_API.php +++ b/tests/Test_Rest_API.php @@ -180,6 +180,66 @@ public function test_invite_management_forbidden_for_co_author(): void { $this->assertSame( 403, $response->get_status() ); } + // ------------------------------------------------------------------------- + // Suggested authors + // ------------------------------------------------------------------------- + + public function test_suggested_authors_returns_empty_for_short_search(): void { + wp_set_current_user( $this->author_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/suggested-authors' ); + $request->set_param( 'search', 'a' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + } + + public function test_suggested_authors_returns_empty_for_empty_search(): void { + wp_set_current_user( $this->author_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/suggested-authors' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + } + + public function test_suggested_authors_does_not_search_by_email(): void { + $target = self::factory()->user->create( + array( + 'role' => 'author', + 'user_email' => 'unique-test-email@example.com', + 'display_name' => 'Test Target', + ) + ); + wp_set_current_user( $this->author_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/suggested-authors' ); + $request->set_param( 'search', 'unique-test-email' ); + $response = rest_get_server()->dispatch( $request ); + + $ids = array_column( $response->get_data(), 'id' ); + $this->assertNotContains( $target, $ids ); + } + + public function test_suggested_authors_returns_results_for_valid_search(): void { + $target = self::factory()->user->create( + array( + 'role' => 'author', + 'display_name' => 'Searchable Author', + ) + ); + wp_set_current_user( $this->author_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/suggested-authors' ); + $request->set_param( 'search', 'Searchable' ); + $response = rest_get_server()->dispatch( $request ); + + $ids = array_column( $response->get_data(), 'id' ); + $this->assertContains( $target, $ids ); + } + public function test_editor_can_manage_co_authors_via_edit_others_posts(): void { // An editor (who has edit_others_posts) can manage co-authors even // though they are not the post author. From 4c1e26eef1dc661f9006c86366dea5dda9d10f84 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 2 Mar 2026 14:45:26 +1000 Subject: [PATCH 2/7] Require 5-character minimum search on large networks Uses wp_is_large_network('users') to raise the minimum search length from 2 to 5 characters on sites with many users. Co-Authored-By: Claude Opus 4.6 --- includes/class-map-rest-api.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/includes/class-map-rest-api.php b/includes/class-map-rest-api.php index da85c1f..fcaec2f 100644 --- a/includes/class-map-rest-api.php +++ b/includes/class-map-rest-api.php @@ -296,8 +296,9 @@ public static function get_suggested_authors( \WP_REST_Request $request ): \WP_R $search = (string) $request->get_param( 'search' ); // Require a minimum search length to prevent user enumeration. - // On large networks, a search term is always required. - if ( mb_strlen( $search ) < 2 ) { + // On large networks, require a longer search term to limit scope. + $min_length = wp_is_large_network( 'users' ) ? 5 : 2; + if ( mb_strlen( $search ) < $min_length ) { return new \WP_REST_Response( array(), 200 ); } From aba6dbb030d26292996fa1c2710137b50443a334 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 2 Mar 2026 14:48:52 +1000 Subject: [PATCH 3/7] Fix: use global namespace for wp_is_large_network Co-Authored-By: Claude Opus 4.6 --- includes/class-map-rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-map-rest-api.php b/includes/class-map-rest-api.php index fcaec2f..4c00031 100644 --- a/includes/class-map-rest-api.php +++ b/includes/class-map-rest-api.php @@ -297,7 +297,7 @@ public static function get_suggested_authors( \WP_REST_Request $request ): \WP_R // Require a minimum search length to prevent user enumeration. // On large networks, require a longer search term to limit scope. - $min_length = wp_is_large_network( 'users' ) ? 5 : 2; + $min_length = \wp_is_large_network( 'users' ) ? 5 : 2; if ( mb_strlen( $search ) < $min_length ) { return new \WP_REST_Response( array(), 200 ); } From 53e5bf44f5615479741ee85081649c985e2164a7 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 2 Mar 2026 14:51:59 +1000 Subject: [PATCH 4/7] Guard wp_is_large_network with function_exists for single-site wp_is_large_network() is only available on multisite installs. Co-Authored-By: Claude Opus 4.6 --- includes/class-map-rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-map-rest-api.php b/includes/class-map-rest-api.php index 4c00031..fd7809e 100644 --- a/includes/class-map-rest-api.php +++ b/includes/class-map-rest-api.php @@ -297,7 +297,7 @@ public static function get_suggested_authors( \WP_REST_Request $request ): \WP_R // Require a minimum search length to prevent user enumeration. // On large networks, require a longer search term to limit scope. - $min_length = \wp_is_large_network( 'users' ) ? 5 : 2; + $min_length = function_exists( 'wp_is_large_network' ) && \wp_is_large_network( 'users' ) ? 5 : 2; if ( mb_strlen( $search ) < $min_length ) { return new \WP_REST_Response( array(), 200 ); } From bcbeb9de39fd659811f985460d3ffc09772cb183 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 2 Mar 2026 15:37:33 +1000 Subject: [PATCH 5/7] Restrict search to indexed columns on large networks Drop display_name from search_columns when wp_is_large_network is true, since it is not an indexed column and causes full table scans. Co-Authored-By: Claude Opus 4.6 --- includes/class-map-rest-api.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/includes/class-map-rest-api.php b/includes/class-map-rest-api.php index fd7809e..01d0779 100644 --- a/includes/class-map-rest-api.php +++ b/includes/class-map-rest-api.php @@ -297,7 +297,8 @@ public static function get_suggested_authors( \WP_REST_Request $request ): \WP_R // Require a minimum search length to prevent user enumeration. // On large networks, require a longer search term to limit scope. - $min_length = function_exists( 'wp_is_large_network' ) && \wp_is_large_network( 'users' ) ? 5 : 2; + $is_large_network = function_exists( 'wp_is_large_network' ) && \wp_is_large_network( 'users' ); + $min_length = $is_large_network ? 5 : 2; if ( mb_strlen( $search ) < $min_length ) { return new \WP_REST_Response( array(), 200 ); } @@ -306,12 +307,17 @@ public static function get_suggested_authors( \WP_REST_Request $request ): \WP_R $existing_ids = Co_Authors::get_co_authors( $post_id ); $existing_ids[] = (int) $post->post_author; + // On large networks, restrict to indexed columns only for performance. + $search_columns = $is_large_network + ? array( 'user_login', 'user_nicename' ) + : array( 'user_login', 'user_nicename', 'display_name' ); + $args = array( 'capability' => array( 'edit_posts' ), 'number' => 20, 'exclude' => $existing_ids, 'search' => '*' . $search . '*', - 'search_columns' => array( 'user_login', 'user_nicename', 'display_name' ), + 'search_columns' => $search_columns, ); $users = get_users( $args ); From 050ab293bc0b0df5d9bf14391b77a7b6d72e22b2 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 2 Mar 2026 15:38:31 +1000 Subject: [PATCH 6/7] Allow email search for users with list_users capability Users who can already view user details (editors/admins) can search by email. Authors cannot, preventing email enumeration for lower roles. Co-Authored-By: Claude Opus 4.6 --- includes/class-map-rest-api.php | 5 +++++ tests/Test_Rest_API.php | 20 +++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/includes/class-map-rest-api.php b/includes/class-map-rest-api.php index 01d0779..8004991 100644 --- a/includes/class-map-rest-api.php +++ b/includes/class-map-rest-api.php @@ -308,10 +308,15 @@ public static function get_suggested_authors( \WP_REST_Request $request ): \WP_R $existing_ids[] = (int) $post->post_author; // On large networks, restrict to indexed columns only for performance. + // Allow email search only for users who can already view user details. $search_columns = $is_large_network ? array( 'user_login', 'user_nicename' ) : array( 'user_login', 'user_nicename', 'display_name' ); + if ( current_user_can( 'list_users' ) ) { + $search_columns[] = 'user_email'; + } + $args = array( 'capability' => array( 'edit_posts' ), 'number' => 20, diff --git a/tests/Test_Rest_API.php b/tests/Test_Rest_API.php index 3de30db..68293fb 100644 --- a/tests/Test_Rest_API.php +++ b/tests/Test_Rest_API.php @@ -205,7 +205,7 @@ public function test_suggested_authors_returns_empty_for_empty_search(): void { $this->assertSame( array(), $response->get_data() ); } - public function test_suggested_authors_does_not_search_by_email(): void { + public function test_suggested_authors_does_not_search_by_email_for_authors(): void { $target = self::factory()->user->create( array( 'role' => 'author', @@ -223,6 +223,24 @@ public function test_suggested_authors_does_not_search_by_email(): void { $this->assertNotContains( $target, $ids ); } + public function test_suggested_authors_searches_by_email_for_editors(): void { + $target = self::factory()->user->create( + array( + 'role' => 'author', + 'user_email' => 'editor-findable@example.com', + 'display_name' => 'Hidden Name', + ) + ); + wp_set_current_user( $this->editor_id ); + + $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/suggested-authors' ); + $request->set_param( 'search', 'editor-findable' ); + $response = rest_get_server()->dispatch( $request ); + + $ids = array_column( $response->get_data(), 'id' ); + $this->assertContains( $target, $ids ); + } + public function test_suggested_authors_returns_results_for_valid_search(): void { $target = self::factory()->user->create( array( From 53759640a0bdbff54142f871b55bb62055187215 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Mon, 2 Mar 2026 15:42:16 +1000 Subject: [PATCH 7/7] Fix email search test to use admin role (list_users capability) Editors don't have list_users on single-site; admins do. Co-Authored-By: Claude Opus 4.6 --- tests/Test_Rest_API.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/Test_Rest_API.php b/tests/Test_Rest_API.php index 68293fb..a0e1a1a 100644 --- a/tests/Test_Rest_API.php +++ b/tests/Test_Rest_API.php @@ -223,18 +223,19 @@ public function test_suggested_authors_does_not_search_by_email_for_authors(): v $this->assertNotContains( $target, $ids ); } - public function test_suggested_authors_searches_by_email_for_editors(): void { + public function test_suggested_authors_searches_by_email_for_admins(): void { + $admin = self::factory()->user->create( array( 'role' => 'administrator' ) ); $target = self::factory()->user->create( array( 'role' => 'author', - 'user_email' => 'editor-findable@example.com', + 'user_email' => 'admin-findable@example.com', 'display_name' => 'Hidden Name', ) ); - wp_set_current_user( $this->editor_id ); + wp_set_current_user( $admin ); $request = new WP_REST_Request( 'GET', '/multi-author-posts/v1/posts/' . $this->post_id . '/suggested-authors' ); - $request->set_param( 'search', 'editor-findable' ); + $request->set_param( 'search', 'admin-findable' ); $response = rest_get_server()->dispatch( $request ); $ids = array_column( $response->get_data(), 'id' );