From 5eed1c8ea50eb3dfda7605749f267bf9e3234dc3 Mon Sep 17 00:00:00 2001 From: rajeshcpr <45383780+rajeshcpr@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:56:00 +0530 Subject: [PATCH 01/10] $_REQUEST['term'] used unsanitized in user search query User-supplied search term is concatenated directly into the get_users() search argument without sanitize_text_field() or wp_unslash(). --- src/wp-admin/includes/ajax-actions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 2af08fba70af9..4043e39154072 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -338,11 +338,11 @@ function wp_ajax_autocomplete_user() { 'fields' => 'ID', ) ) : array() ); - + $term = isset( $_REQUEST['term'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['term'] ) ) : ''; $users = get_users( array( 'blog_id' => false, - 'search' => '*' . $_REQUEST['term'] . '*', + 'search' => '*' . $term . '*', 'include' => $include_blog_users, 'exclude' => $exclude_blog_users, 'search_columns' => array( 'user_login', 'user_nicename', 'user_email' ), From 2302623095312a85d749b7f68d6dacfe046981c9 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Sat, 1 Aug 2026 19:06:35 +0900 Subject: [PATCH 02/10] Tests: Add unit tests for wp_ajax_autocomplete_user(). The user autocomplete Ajax endpoint had no test coverage, so the search term sanitization added on this branch was unverified. These tests lock in that behavior: HTML tags are stripped before the term reaches get_users(), and a missing `term` request variable no longer raises a PHP warning. Both fail without the sanitization change. The remaining tests document the surrounding contract that the sanitization must not break: the response shape, the `add` vs `search` autocomplete types, the `user_email` field, the `promote_users` and `manage_network_users` capability checks, the `autocomplete_users_for_site_admins` filter, and large networks. The handler bails out early unless multisite is active, so the class is marked with the `ms-required` group. Blank lines are added around the `$term` assignment to satisfy the alignment sniff. See #65051. Co-Authored-By: Claude --- src/wp-admin/includes/ajax-actions.php | 2 + .../tests/ajax/wpAjaxAutocompleteUser.php | 269 ++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index ca69ba886a27d..9336f4f43e26d 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -338,7 +338,9 @@ function wp_ajax_autocomplete_user() { 'fields' => 'ID', ) ) : array() ); + $term = isset( $_REQUEST['term'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['term'] ) ) : ''; + $users = get_users( array( 'blog_id' => false, diff --git a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php new file mode 100644 index 0000000000000..8d3d24a4a6936 --- /dev/null +++ b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php @@ -0,0 +1,269 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$site_admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + self::$target_user_id = $factory->user->create( + array( + 'role' => 'subscriber', + 'user_login' => 'autocompleteuser', + 'user_email' => 'autocompleteuser@example.org', + ) + ); + + if ( is_multisite() ) { + grant_super_admin( self::$super_admin_id ); + } + } + + /** + * Runs the Ajax handler and returns the response passed to wp_die(). + * + * The handler never echoes anything, so the response is only available + * through the exception thrown by the die handler. + * + * @return string The raw response. + */ + protected function handle_autocomplete_user() { + try { + $this->_handleAjax( 'autocomplete-user' ); + } catch ( WPAjaxDieStopException $e ) { + return $e->getMessage(); + } + + $this->fail( 'wp_ajax_autocomplete_user() did not stop execution.' ); + } + + /** + * Tests that users of the current site are returned when searching them. + * + * @ticket 65051 + */ + public function test_should_return_users_matching_the_search_term() { + wp_set_current_user( self::$super_admin_id ); + + $_GET['autocomplete_type'] = 'search'; + $_GET['term'] = 'autocompleteuser'; + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); + $this->assertSame( 'autocompleteuser', $response[0]['value'], 'The user login should be returned as the value.' ); + $this->assertStringContainsString( 'autocompleteuser@example.org', $response[0]['label'], 'The label should contain the email address.' ); + } + + /** + * Tests that the email address is returned when it is the requested field. + * + * @ticket 65051 + */ + public function test_should_return_the_email_address_as_the_value_when_requested() { + wp_set_current_user( self::$super_admin_id ); + + $_GET['autocomplete_type'] = 'search'; + $_GET['autocomplete_field'] = 'user_email'; + $_GET['term'] = 'autocompleteuser'; + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); + $this->assertSame( 'autocompleteuser@example.org', $response[0]['value'], 'The email address should be returned as the value.' ); + } + + /** + * Tests that users of the current site are excluded when adding a user to it. + * + * @ticket 65051 + */ + public function test_should_exclude_users_of_the_current_site_when_adding() { + wp_set_current_user( self::$super_admin_id ); + + // The default autocomplete type is 'add', which excludes existing users of the site. + $_GET['term'] = 'autocompleteuser'; + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertSame( array(), $response, 'A user of the current site should not be suggested.' ); + } + + /** + * Tests that HTML tags are removed from the search term. + * + * @ticket 65051 + */ + public function test_should_strip_tags_from_the_search_term() { + wp_set_current_user( self::$super_admin_id ); + + $_GET['autocomplete_type'] = 'search'; + $_GET['term'] = 'autocompleteuser'; + + $search = null; + add_action( + 'pre_get_users', + static function ( $query ) use ( &$search ) { + $search = $query->get( 'search' ); + } + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertSame( '*autocompleteuser*', $search, 'The search term should be sanitized before it is passed to get_users().' ); + $this->assertCount( 1, $response, 'The sanitized term should still match the user.' ); + } + + /** + * Tests that a missing search term does not trigger a PHP warning. + * + * @ticket 65051 + */ + public function test_should_not_warn_when_the_search_term_is_missing() { + wp_set_current_user( self::$super_admin_id ); + + $_GET['autocomplete_type'] = 'search'; + + $warnings = array(); + set_error_handler( + static function ( $errno, $errstr ) use ( &$warnings ) { + $warnings[] = $errstr; + return true; + }, + E_WARNING + ); + + try { + $response = json_decode( $this->handle_autocomplete_user(), true ); + } finally { + restore_error_handler(); + } + + $this->assertSame( + array(), + array_values( + array_filter( + $warnings, + static function ( $warning ) { + return false !== strpos( $warning, 'term' ); + } + ) + ), + 'Accessing a missing search term should not raise a PHP warning.' + ); + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + } + + /** + * Tests that users without the 'promote_users' capability are denied. + * + * @ticket 65051 + */ + public function test_should_deny_users_without_the_promote_users_capability() { + wp_set_current_user( self::$subscriber_id ); + + $_GET['autocomplete_type'] = 'search'; + $_GET['term'] = 'autocompleteuser'; + + $this->assertSame( '-1', $this->handle_autocomplete_user() ); + } + + /** + * Tests that site administrators are denied unless the filter allows them. + * + * @ticket 65051 + */ + public function test_should_deny_site_administrators_by_default() { + wp_set_current_user( self::$site_admin_id ); + + $_GET['autocomplete_type'] = 'search'; + $_GET['term'] = 'autocompleteuser'; + + $this->assertSame( '-1', $this->handle_autocomplete_user() ); + } + + /** + * Tests that site administrators are allowed by the + * 'autocomplete_users_for_site_admins' filter. + * + * @ticket 65051 + */ + public function test_should_allow_site_administrators_when_filtered() { + wp_set_current_user( self::$site_admin_id ); + + add_filter( 'autocomplete_users_for_site_admins', '__return_true' ); + + $_GET['autocomplete_type'] = 'search'; + $_GET['term'] = 'autocompleteuser'; + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'The matching user should be returned.' ); + } + + /** + * Tests that no autocompletion happens on large networks. + * + * @ticket 65051 + */ + public function test_should_deny_the_request_on_a_large_network() { + wp_set_current_user( self::$super_admin_id ); + + add_filter( 'wp_is_large_network', '__return_true' ); + + $_GET['autocomplete_type'] = 'search'; + $_GET['term'] = 'autocompleteuser'; + + $this->assertSame( '-1', $this->handle_autocomplete_user() ); + } +} From 88870e8cd4da5db9dab38cec34688e95cd697b0e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 17:25:50 -0700 Subject: [PATCH 03/10] Short-circuit empty searches and non-string terms --- src/wp-admin/includes/ajax-actions.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 9336f4f43e26d..fbe930b614c01 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -298,6 +298,15 @@ function wp_ajax_autocomplete_user() { $return = array(); + // Obtain the search term, and short-circuit missing/invalid search term. + if ( ! isset( $_REQUEST['term'] ) || ! is_string( $_REQUEST['term'] ) ) { + wp_die( 0 ); + } + $term = sanitize_text_field( wp_unslash( $_REQUEST['term'] ) ); + if ( '' === $term ) { + wp_die( 0 ); + } + /* * Check the type of request. * Current allowed values are `add` and `search`. @@ -339,8 +348,6 @@ function wp_ajax_autocomplete_user() { ) ) : array() ); - $term = isset( $_REQUEST['term'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['term'] ) ) : ''; - $users = get_users( array( 'blog_id' => false, From 293fba9bd11ed724e463d51eda4cbf109f55fb8b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 17:35:37 -0700 Subject: [PATCH 04/10] Address PHPStan issues in tests --- .../tests/ajax/wpAjaxAutocompleteUser.php | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php index 8d3d24a4a6936..e26ef06531485 100644 --- a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php +++ b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php @@ -23,31 +23,23 @@ class Tests_Ajax_wpAjaxAutocompleteUser extends WP_Ajax_UnitTestCase { /** * A user with super admin privileges. - * - * @var int */ - protected static $super_admin_id; + protected static int $super_admin_id; /** * An administrator of the current site. - * - * @var int */ - protected static $site_admin_id; + protected static int $site_admin_id; /** * A user without the 'promote_users' capability. - * - * @var int */ - protected static $subscriber_id; + protected static int $subscriber_id; /** * The user expected to be found by the autocomplete queries. - * - * @var int */ - protected static $target_user_id; + protected static int $target_user_id; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$super_admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); @@ -74,7 +66,7 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { * * @return string The raw response. */ - protected function handle_autocomplete_user() { + protected function handle_autocomplete_user(): string { try { $this->_handleAjax( 'autocomplete-user' ); } catch ( WPAjaxDieStopException $e ) { @@ -99,8 +91,11 @@ public function test_should_return_users_matching_the_search_term() { $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); - $this->assertSame( 'autocompleteuser', $response[0]['value'], 'The user login should be returned as the value.' ); - $this->assertStringContainsString( 'autocompleteuser@example.org', $response[0]['label'], 'The label should contain the email address.' ); + $result = array_first( $response ); + $this->assertIsArray( $result ); + $this->assertSame( 'autocompleteuser', $result['value'], 'The user login should be returned as the value.' ); + $this->assertIsString( $result['label'] ); + $this->assertStringContainsString( 'autocompleteuser@example.org', $result['label'], 'The label should contain the email address.' ); } /** @@ -119,7 +114,9 @@ public function test_should_return_the_email_address_as_the_value_when_requested $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); - $this->assertSame( 'autocompleteuser@example.org', $response[0]['value'], 'The email address should be returned as the value.' ); + $result = array_first( $response ); + $this->assertIsArray( $result ); + $this->assertSame( 'autocompleteuser@example.org', $result['value'], 'The email address should be returned as the value.' ); } /** @@ -152,12 +149,13 @@ public function test_should_strip_tags_from_the_search_term() { $search = null; add_action( 'pre_get_users', - static function ( $query ) use ( &$search ) { + static function ( WP_User_Query $query ) use ( &$search ) { $search = $query->get( 'search' ); } ); $response = json_decode( $this->handle_autocomplete_user(), true ); + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); $this->assertSame( '*autocompleteuser*', $search, 'The search term should be sanitized before it is passed to get_users().' ); $this->assertCount( 1, $response, 'The sanitized term should still match the user.' ); From 3ce62502b4048c1a2af9a4dbf368c1c85439d68a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 17:49:56 -0700 Subject: [PATCH 05/10] Ensure GET input var is slashed and add test for apostrophe-containing email --- .../tests/ajax/wpAjaxAutocompleteUser.php | 128 +++++++++++------- 1 file changed, 81 insertions(+), 47 deletions(-) diff --git a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php index e26ef06531485..e855bbfc65e21 100644 --- a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php +++ b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php @@ -49,7 +49,7 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { array( 'role' => 'subscriber', 'user_login' => 'autocompleteuser', - 'user_email' => 'autocompleteuser@example.org', + 'user_email' => 'autocompleteuser+bat\'leth@klingon.example.org', ) ); @@ -84,8 +84,12 @@ protected function handle_autocomplete_user(): string { public function test_should_return_users_matching_the_search_term() { wp_set_current_user( self::$super_admin_id ); - $_GET['autocomplete_type'] = 'search'; - $_GET['term'] = 'autocompleteuser'; + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); $response = json_decode( $this->handle_autocomplete_user(), true ); @@ -95,7 +99,7 @@ public function test_should_return_users_matching_the_search_term() { $this->assertIsArray( $result ); $this->assertSame( 'autocompleteuser', $result['value'], 'The user login should be returned as the value.' ); $this->assertIsString( $result['label'] ); - $this->assertStringContainsString( 'autocompleteuser@example.org', $result['label'], 'The label should contain the email address.' ); + $this->assertStringContainsString( 'autocompleteuser+bat\'leth@klingon.example.org', $result['label'], 'The label should contain the email address.' ); } /** @@ -106,9 +110,13 @@ public function test_should_return_users_matching_the_search_term() { public function test_should_return_the_email_address_as_the_value_when_requested() { wp_set_current_user( self::$super_admin_id ); - $_GET['autocomplete_type'] = 'search'; - $_GET['autocomplete_field'] = 'user_email'; - $_GET['term'] = 'autocompleteuser'; + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'autocomplete_field' => 'user_email', + 'term' => 'autocompleteuser', + ) + ); $response = json_decode( $this->handle_autocomplete_user(), true ); @@ -116,7 +124,7 @@ public function test_should_return_the_email_address_as_the_value_when_requested $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); $result = array_first( $response ); $this->assertIsArray( $result ); - $this->assertSame( 'autocompleteuser@example.org', $result['value'], 'The email address should be returned as the value.' ); + $this->assertSame( 'autocompleteuser+bat\'leth@klingon.example.org', $result['value'], 'The email address should be returned as the value.' ); } /** @@ -128,7 +136,11 @@ public function test_should_exclude_users_of_the_current_site_when_adding() { wp_set_current_user( self::$super_admin_id ); // The default autocomplete type is 'add', which excludes existing users of the site. - $_GET['term'] = 'autocompleteuser'; + $_GET = wp_slash( + array( + 'term' => 'autocompleteuser', + ) + ); $response = json_decode( $this->handle_autocomplete_user(), true ); @@ -143,8 +155,12 @@ public function test_should_exclude_users_of_the_current_site_when_adding() { public function test_should_strip_tags_from_the_search_term() { wp_set_current_user( self::$super_admin_id ); - $_GET['autocomplete_type'] = 'search'; - $_GET['term'] = 'autocompleteuser'; + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); $search = null; add_action( @@ -162,43 +178,45 @@ static function ( WP_User_Query $query ) use ( &$search ) { } /** - * Tests that a missing search term does not trigger a PHP warning. + * Tests that searching for an email address with apostrophes is successful. * * @ticket 65051 */ - public function test_should_not_warn_when_the_search_term_is_missing() { + public function test_search_email_address_with_apostrophe() { wp_set_current_user( self::$super_admin_id ); - $_GET['autocomplete_type'] = 'search'; - - $warnings = array(); - set_error_handler( - static function ( $errno, $errstr ) use ( &$warnings ) { - $warnings[] = $errstr; - return true; - }, - E_WARNING + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'autocomplete_field' => 'user_email', + 'term' => 'autocompleteuser+bat\'leth@klingon.example.org', + ) ); - try { - $response = json_decode( $this->handle_autocomplete_user(), true ); - } finally { - restore_error_handler(); - } + $response = json_decode( $this->handle_autocomplete_user(), true ); - $this->assertSame( - array(), - array_values( - array_filter( - $warnings, - static function ( $warning ) { - return false !== strpos( $warning, 'term' ); - } - ) - ), - 'Accessing a missing search term should not raise a PHP warning.' - ); $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'Only the matching user should be returned.' ); + $result = array_first( $response ); + $this->assertIsArray( $result ); + $this->assertSame( 'autocompleteuser+bat\'leth@klingon.example.org', $result['value'], 'The email address should be returned as the value.' ); + } + + /** + * Tests that a missing search term does not return results. + * + * @ticket 65051 + */ + public function test_missing_term_does_not_return_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); } /** @@ -209,8 +227,12 @@ static function ( $warning ) { public function test_should_deny_users_without_the_promote_users_capability() { wp_set_current_user( self::$subscriber_id ); - $_GET['autocomplete_type'] = 'search'; - $_GET['term'] = 'autocompleteuser'; + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); $this->assertSame( '-1', $this->handle_autocomplete_user() ); } @@ -223,8 +245,12 @@ public function test_should_deny_users_without_the_promote_users_capability() { public function test_should_deny_site_administrators_by_default() { wp_set_current_user( self::$site_admin_id ); - $_GET['autocomplete_type'] = 'search'; - $_GET['term'] = 'autocompleteuser'; + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); $this->assertSame( '-1', $this->handle_autocomplete_user() ); } @@ -240,8 +266,12 @@ public function test_should_allow_site_administrators_when_filtered() { add_filter( 'autocomplete_users_for_site_admins', '__return_true' ); - $_GET['autocomplete_type'] = 'search'; - $_GET['term'] = 'autocompleteuser'; + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); $response = json_decode( $this->handle_autocomplete_user(), true ); @@ -259,8 +289,12 @@ public function test_should_deny_the_request_on_a_large_network() { add_filter( 'wp_is_large_network', '__return_true' ); - $_GET['autocomplete_type'] = 'search'; - $_GET['term'] = 'autocompleteuser'; + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => 'autocompleteuser', + ) + ); $this->assertSame( '-1', $this->handle_autocomplete_user() ); } From ff20bc128b7678866e4d02800b07572471dc3bac Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 18:03:41 -0700 Subject: [PATCH 06/10] Document behavior change with @since 7.1.0 tag on wp_ajax_autocomplete_user() Note that the search term is now sanitized and that a missing, non-string, or empty term results in a `0` response instead of an empty array. Co-Authored-By: Claude Fable 5 --- src/wp-admin/includes/ajax-actions.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index fbe930b614c01..06bc96f27fd6d 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -285,6 +285,8 @@ function wp_ajax_oembed_cache() { * Handles user autocomplete via AJAX. * * @since 3.4.0 + * @since 7.1.0 The search term is now sanitized, and a missing, non-string, + * or empty term results in a `0` response instead of an empty array. */ function wp_ajax_autocomplete_user() { if ( ! is_multisite() || ! current_user_can( 'promote_users' ) || wp_is_large_network( 'users' ) ) { From 784357a0fe5e2fd74cf5188de81509c59af37899 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 18:05:52 -0700 Subject: [PATCH 07/10] Trim asterisks from search term to prevent matching all users Since wildcards are appended to the search term, a term consisting only of asterisks would be reduced to an empty search by WP_User_Query, causing all network users to be returned. Trim asterisks before the empty-term check so such a term short-circuits with a `0` response instead. Add tests for an asterisk-only term and for an asterisk-wrapped term, the latter of which still matches as before. Co-Authored-By: Claude Fable 5 --- src/wp-admin/includes/ajax-actions.php | 7 +++- .../tests/ajax/wpAjaxAutocompleteUser.php | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 06bc96f27fd6d..4546dace6a6b8 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -304,7 +304,12 @@ function wp_ajax_autocomplete_user() { if ( ! isset( $_REQUEST['term'] ) || ! is_string( $_REQUEST['term'] ) ) { wp_die( 0 ); } - $term = sanitize_text_field( wp_unslash( $_REQUEST['term'] ) ); + /* + * Asterisks are trimmed since wildcards are appended below. Without this, a + * term consisting only of asterisks would result in an empty search that + * matches all users. + */ + $term = trim( sanitize_text_field( wp_unslash( $_REQUEST['term'] ) ), '*' ); if ( '' === $term ) { wp_die( 0 ); } diff --git a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php index e855bbfc65e21..83310d26d984c 100644 --- a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php +++ b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php @@ -219,6 +219,45 @@ public function test_missing_term_does_not_return_results() { $this->assertSame( '0', $this->handle_autocomplete_user() ); } + /** + * Tests that a term consisting only of asterisks does not match all users. + * + * @ticket 65051 + */ + public function test_asterisk_only_term_does_not_return_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => '**', + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); + } + + /** + * Tests that a term wrapped in asterisks still matches. + * + * @ticket 65051 + */ + public function test_asterisk_wrapped_term_returns_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => '*autocompleteuser*', + ) + ); + + $response = json_decode( $this->handle_autocomplete_user(), true ); + + $this->assertIsArray( $response, 'The response should be a JSON encoded array.' ); + $this->assertCount( 1, $response, 'The matching user should be returned.' ); + } + /** * Tests that users without the 'promote_users' capability are denied. * From 23625b16d8830f3c03d64c9c2e7bc44dcb734c57 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 18:10:42 -0700 Subject: [PATCH 08/10] Add missing never return --- src/wp-admin/includes/ajax-actions.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 4546dace6a6b8..c51751940a976 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -287,6 +287,8 @@ function wp_ajax_oembed_cache() { * @since 3.4.0 * @since 7.1.0 The search term is now sanitized, and a missing, non-string, * or empty term results in a `0` response instead of an empty array. + * + * @return never */ function wp_ajax_autocomplete_user() { if ( ! is_multisite() || ! current_user_can( 'promote_users' ) || wp_is_large_network( 'users' ) ) { From ca51b565243869592966871840204c0b01e5ef29 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 18:16:50 -0700 Subject: [PATCH 09/10] Add tests for empty and non-string search terms Cover the remaining guard arms in wp_ajax_autocomplete_user(): an empty or whitespace-only term and a non-string (array) term each result in a `0` response. Co-Authored-By: Claude Fable 5 --- .../tests/ajax/wpAjaxAutocompleteUser.php | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php index 83310d26d984c..a9dc38ba93906 100644 --- a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php +++ b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php @@ -219,6 +219,58 @@ public function test_missing_term_does_not_return_results() { $this->assertSame( '0', $this->handle_autocomplete_user() ); } + /** + * Tests that an empty search term does not return results. + * + * @ticket 65051 + * + * @dataProvider data_empty_terms + * + * @param string $term Empty or whitespace-only term. + */ + public function test_empty_term_does_not_return_results( string $term ) { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => $term, + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_empty_terms(): array { + return array( + 'empty string' => array( '' ), + 'whitespace only' => array( ' ' ), + ); + } + + /** + * Tests that a non-string search term does not return results. + * + * @ticket 65051 + */ + public function test_non_string_term_does_not_return_results() { + wp_set_current_user( self::$super_admin_id ); + + $_GET = wp_slash( + array( + 'autocomplete_type' => 'search', + 'term' => array( 'autocompleteuser' ), + ) + ); + + $this->assertSame( '0', $this->handle_autocomplete_user() ); + } + /** * Tests that a term consisting only of asterisks does not match all users. * From 3d5285fe6bed9a67c01bb37b71c4d1154684792c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 4 Aug 2026 18:23:25 -0700 Subject: [PATCH 10/10] Cover tag-stripping when tags wrap the search term Convert the tag-stripping test to a data provider and add a case where tags wrap the searchable value. The existing script-element case exercises wp_strip_all_tags() removing an element along with its contents, while the new case exercises tags being stripped with their inner text retained. Co-Authored-By: Claude Fable 5 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../tests/ajax/wpAjaxAutocompleteUser.php | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php index a9dc38ba93906..3d1eecf2f0fa3 100644 --- a/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php +++ b/tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php @@ -151,14 +151,18 @@ public function test_should_exclude_users_of_the_current_site_when_adding() { * Tests that HTML tags are removed from the search term. * * @ticket 65051 + * + * @dataProvider data_terms_containing_tags + * + * @param string $term Term containing HTML tags. */ - public function test_should_strip_tags_from_the_search_term() { + public function test_should_strip_tags_from_the_search_term( string $term ) { wp_set_current_user( self::$super_admin_id ); $_GET = wp_slash( array( 'autocomplete_type' => 'search', - 'term' => 'autocompleteuser', + 'term' => $term, ) ); @@ -177,6 +181,22 @@ static function ( WP_User_Query $query ) use ( &$search ) { $this->assertCount( 1, $response, 'The sanitized term should still match the user.' ); } + /** + * Data provider. + * + * Note that `wp_strip_all_tags()` removes script and style elements along + * with their contents, while for other tags only the tags themselves are + * removed. + * + * @return array + */ + public static function data_terms_containing_tags(): array { + return array( + 'script element after the term' => array( 'autocompleteuser' ), + 'tags wrapping the term' => array( 'autocompleteuser' ), + ); + } + /** * Tests that searching for an email address with apostrophes is successful. *