diff --git a/repro-evidence/after-fix-output.txt b/repro-evidence/after-fix-output.txt new file mode 100644 index 0000000000000..596b6ca6c27a9 --- /dev/null +++ b/repro-evidence/after-fix-output.txt @@ -0,0 +1,17 @@ +Reproduction: same broken role ('role_missing_caps' with no 'capabilities' key), +same trigger (wp_dropdown_users() with a capability filter, matching the original +report's wp-admin/includes/meta-boxes.php -> wp_dropdown_users() call), run against +trunk with the class-wp-user-query.php and class-wp-roles.php fixes applied. + +$ wp eval ' +$results = wp_dropdown_users( array( "capability" => array( "edit_posts" ), "echo" => 0 ) ); +echo "No fatal error. wp_dropdown_users() returned " . strlen( (string) $results ) . " bytes of markup.\n"; + +$wp_user_search = new WP_User_Query( array( "capability" => array( "edit_posts" ) ) ); +echo "WP_User_Query succeeded, found " . count( $wp_user_search->get_results() ) . " user(s).\n"; +' + +No fatal error. wp_dropdown_users() returned 83 bytes of markup. +WP_User_Query succeeded, found 1 user(s). + +wp-content/debug.log: not created (zero warnings, zero errors, zero deprecation notices). diff --git a/repro-evidence/before-fix-debug.log b/repro-evidence/before-fix-debug.log new file mode 100644 index 0000000000000..b8305315a0110 --- /dev/null +++ b/repro-evidence/before-fix-debug.log @@ -0,0 +1,26 @@ +[07-Aug-2026 10:00:41 UTC] PHP Warning: Undefined array key "capabilities" in /Users/melita/wordpress-develop/src/wp-includes/class-wp-roles.php on line 310 +[07-Aug-2026 10:00:41 UTC] PHP Warning: Undefined array key "capabilities" in /Users/melita/wordpress-develop/src/wp-includes/class-wp-roles.php on line 310 +[07-Aug-2026 10:00:41 UTC] PHP Warning: Undefined array key "capabilities" in /Users/melita/wordpress-develop/src/wp-includes/class-wp-user-query.php on line 485 +[07-Aug-2026 10:00:41 UTC] PHP Fatal error: Uncaught TypeError: array_filter(): Argument #1 ($array) must be of type array, null given in /Users/melita/wordpress-develop/src/wp-includes/class-wp-user-query.php:485 +Stack trace: +#0 /Users/melita/wordpress-develop/src/wp-includes/class-wp-user-query.php(485): array_filter(NULL) +#1 /Users/melita/wordpress-develop/src/wp-includes/class-wp-user-query.php(79): WP_User_Query->prepare_query(Array) +#2 /Users/melita/wordpress-develop/src/wp-includes/user.php(879): WP_User_Query->__construct(Array) +#3 /Users/melita/wordpress-develop/src/wp-includes/user.php(1810): get_users(Array) +#4 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/vendor/wp-cli/eval-command/src/Eval_Command.php(39) : eval()'d code(2): wp_dropdown_users(Array) +#5 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/vendor/wp-cli/eval-command/src/Eval_Command.php(39): eval() +#6 [internal function]: Eval_Command->__invoke(Array, Array) +#7 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/vendor/wp-cli/wp-cli/php/WP_CLI/Dispatcher/CommandFactory.php(100): call_user_func(Array, Array, Array) +#8 [internal function]: WP_CLI\Dispatcher\CommandFactory::{closure:WP_CLI\Dispatcher\CommandFactory::create_subcommand():97}(Array, Array) +#9 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/vendor/wp-cli/wp-cli/php/WP_CLI/Dispatcher/Subcommand.php(497): call_user_func(Object(Closure), Array, Array) +#10 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/vendor/wp-cli/wp-cli/php/WP_CLI/Runner.php(441): WP_CLI\Dispatcher\Subcommand->invoke(Array, Array, Array) +#11 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/vendor/wp-cli/wp-cli/php/WP_CLI/Runner.php(464): WP_CLI\Runner->run_command(Array, Array) +#12 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/vendor/wp-cli/wp-cli/php/WP_CLI/Runner.php(136): WP_CLI\Runner->run_command_and_exit() +#13 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/vendor/wp-cli/wp-cli/php/WP_CLI/Runner.php(1229): WP_CLI\Runner->do_early_invoke('before_wp_load') +#14 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/vendor/wp-cli/wp-cli/php/WP_CLI/Bootstrap/LaunchRunner.php(28): WP_CLI\Runner->start() +#15 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/vendor/wp-cli/wp-cli/php/bootstrap.php(83): WP_CLI\Bootstrap\LaunchRunner->process(Object(WP_CLI\Bootstrap\BootstrapState)) +#16 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/vendor/wp-cli/wp-cli/php/wp-cli.php(32): WP_CLI\bootstrap() +#17 phar:///usr/local/Cellar/wp-cli/2.11.0/bin/wp/php/boot-phar.php(20): include('phar:///usr/loc...') +#18 /usr/local/Cellar/wp-cli/2.11.0/bin/wp(4): include('phar:///usr/loc...') +#19 {main} + thrown in /Users/melita/wordpress-develop/src/wp-includes/class-wp-user-query.php on line 485 diff --git a/repro-evidence/multisite-verification.md b/repro-evidence/multisite-verification.md new file mode 100644 index 0000000000000..93cfc1c840b4b --- /dev/null +++ b/repro-evidence/multisite-verification.md @@ -0,0 +1,41 @@ +### Multisite network verification + +The original repro (`before-fix-debug.log` / `after-fix-output.txt`) was against a single-site +install. Since roles are stored per-site (`{$blog_prefix}user_roles`), the same broken-role +scenario can occur independently on any site in a network, so this was verified separately +against the multisite code paths. + +**Why a live network site wasn't used**: `WP_User_Query` doesn't need `switch_to_blog()` to +query another site's users — it calls `WP_Roles::for_site( $blog_id )` directly +(`class-wp-user-query.php:458`), which reloads and re-initializes roles for that site's +`user_roles` option in place. That's the multisite-specific code path worth covering, and +the core test suite's multisite mode (`tests/phpunit/multisite.xml`, `WP_TESTS_MULTISITE=1`) +exercises it directly, so it was used instead of a second manual wp-cli repro. + +**Test added**: `test_capability_query_with_role_missing_capabilities_key_on_other_site` in +`tests/phpunit/tests/user/query.php`. It creates a sub-site, adds a role with no `capabilities` +key to *that* site's `user_roles` option, then — from the main site's context — runs a +`WP_User_Query` with `blog_id` set to the sub-site. This forces `WP_Roles::for_site()` to load +and re-`init_roles()` the broken role without ever switching the global blog context. + +**Before the fix** (`class-wp-roles.php` / `class-wp-user-query.php` reverted to trunk, same +test): +``` +1) Tests_User_Query::test_capability_query_with_role_missing_capabilities_key_on_other_site +Undefined array key "capabilities" + +/Users/.../src/wp-includes/class-wp-roles.php:310 +/Users/.../src/wp-includes/class-wp-roles.php:350 +/Users/.../src/wp-includes/class-wp-user-query.php:458 +/Users/.../src/wp-includes/class-wp-user-query.php:79 +/Users/.../tests/phpunit/tests/user/query.php:2037 + +ERRORS! +Tests: 1, Assertions: 0, Errors: 1. +``` + +**After the fix**: test passes. + +**Full multisite regression check**: ran the `user`/`capabilities`/`multisite` groups under +`multisite.xml` (`WP_TESTS_MULTISITE=1`) — 763 tests, 3137 assertions, all passing, no +regressions introduced by extending coverage to the network case. diff --git a/repro-evidence/pr-body.md b/repro-evidence/pr-body.md new file mode 100644 index 0000000000000..0e74a37576bef --- /dev/null +++ b/repro-evidence/pr-body.md @@ -0,0 +1,47 @@ +A role stored without a `capabilities` key, or with a non-array value for it, causes two separate problems, not just one: + +1. A fatal `TypeError` in `WP_User_Query::prepare_query()` when querying by capability, since `array_filter()` is called directly on `$role_data['capabilities']`. This is the crash originally reported on the ticket, via `wp_dropdown_users()` on the classic Author meta box. +2. The same unguarded assumption exists in `WP_Roles::init_roles()`, which runs on effectively every request that initializes roles — not just capability-filtered queries. Neither #8351 nor #8823 addresses this second call site. + +This can happen when a plugin registers a role without capabilities and is later deactivated, leaving the malformed role behind in the site's `user_roles` option. + +This PR builds on the investigation already done in #8351 (by @geekofshire) and #8823 (by @umeshnevase) — thank you both. It combines the `is_array()` robustness from #8823 with the more readable guard-clause shape from #8351, extends the same fix to `WP_Roles::init_roles()`, and adds the unit test coverage that @johnbillion asked for and that both prior PRs were still missing. + +### Reproduction + +Reproduced against a real WordPress install (not just the unit tests) by creating a role with no `capabilities` key and triggering the exact code path from the original report (`wp_dropdown_users()` with a capability filter): + +**Before the fix** (`wp-content/debug.log`): +``` +[07-Aug-2026 10:00:41 UTC] PHP Warning: Undefined array key "capabilities" in wp-includes/class-wp-roles.php on line 310 +[07-Aug-2026 10:00:41 UTC] PHP Warning: Undefined array key "capabilities" in wp-includes/class-wp-roles.php on line 310 +[07-Aug-2026 10:00:41 UTC] PHP Warning: Undefined array key "capabilities" in wp-includes/class-wp-user-query.php on line 485 +[07-Aug-2026 10:00:41 UTC] PHP Fatal error: Uncaught TypeError: array_filter(): Argument #1 ($array) must be of type array, null given in wp-includes/class-wp-user-query.php:485 +Stack trace: +#0 wp-includes/class-wp-user-query.php(485): array_filter(NULL) +#1 wp-includes/class-wp-user-query.php(79): WP_User_Query->prepare_query(Array) +#2 wp-includes/user.php(879): WP_User_Query->__construct(Array) +#3 wp-includes/user.php(1810): get_users(Array) +#4 ...: wp_dropdown_users(Array) + thrown in wp-includes/class-wp-user-query.php on line 485 +``` + +**After the fix**, same broken role, same trigger: no warnings, no errors, `debug.log` isn't even created, and the query returns results correctly instead of crashing. + +### Testing + +- Added two unit tests covering both failure modes (missing key, and a non-array value). Verified they fail at the correct line (`class-wp-user-query.php:485`, matching the original report) without the fix, and pass with it. +- Ran the full `user` and `capabilities` test groups (1,343 tests, 4,530 assertions) to confirm no regressions. +- Added a third test for the multisite network case: `WP_User_Query` doesn't need `switch_to_blog()` to query another site's users — it calls `WP_Roles::for_site( $blog_id )` directly, which reloads and re-initializes roles for that site in place. A role missing `capabilities` on a *different* site in the network hits the same unguarded code, so this exercises that path with the core test suite's multisite mode (`WP_TESTS_MULTISITE=1`). Verified it fails at `class-wp-roles.php:310` via `for_site()` without the fix, and passes with it. Ran the `user`/`capabilities`/`multisite` groups under multisite (763 tests, 3,137 assertions) to confirm no regressions there either. Details in `repro-evidence/multisite-verification.md`. + +Trac ticket: https://core.trac.wordpress.org/ticket/62600 + +## Use of AI Tools + +AI assistance: Yes +Tool(s): Claude Code +Model(s): Claude Sonnet 5 +Used for: Investigating the root cause (including finding the second, unreported crash site in `WP_Roles::init_roles()`), implementing the fix in both files, writing and running the unit tests, and reproducing the bug against a live WordPress install to verify the before/after behavior shown above. All changes were reviewed and directed by me. + +--- +**This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See [GitHub Pull Requests for Code Review](https://make.wordpress.org/core/handbook/contribute/git/github-pull-requests-for-code-review/) in the Core Handbook for more details.** diff --git a/repro-evidence/trac-comment.md b/repro-evidence/trac-comment.md new file mode 100644 index 0000000000000..4938958df3d7c --- /dev/null +++ b/repro-evidence/trac-comment.md @@ -0,0 +1,9 @@ +Opened a PR that builds on the patches already here: https://github.com/WordPress/wordpress-develop/pull/12931 + +It combines the `isset()` guard from #8351 with the `is_array()` robustness from #8823, and extends the same fix to `WP_Roles::init_roles()`, which has the identical unguarded `$role_data['capabilities']` assumption and runs on effectively every request that initializes roles, not just capability-filtered `WP_User_Query` calls. Neither of the existing PRs covers that second call site. + +Added the unit test coverage requested above — two tests covering both failure modes (missing key, and a non-array value), in `tests/phpunit/tests/user/query.php`. Verified they fail at the exact reported line (`class-wp-user-query.php:485`) without the fix and pass with it, and ran the full `user`/`capabilities` groups (1,343 tests) to confirm no regressions. + +Also reproduced the original crash against a real WordPress install using the exact reported code path (`wp_dropdown_users()` with a capability filter) to confirm the fatal error and its resolution outside of the test suite — details in the PR description. + +Also verified the multisite network case: `WP_User_Query` can load another site's roles via `WP_Roles::for_site( $blog_id )` without `switch_to_blog()`, so a role missing `capabilities` on any one site in a network hits the same bug. Added a third test for that path and ran the `user`/`capabilities`/`multisite` groups under `WP_TESTS_MULTISITE=1` (763 tests, 3,137 assertions) — no regressions. diff --git a/src/wp-includes/class-wp-roles.php b/src/wp-includes/class-wp-roles.php index 2cb9cdfe95296..c1ecb2824e7f1 100644 --- a/src/wp-includes/class-wp-roles.php +++ b/src/wp-includes/class-wp-roles.php @@ -307,7 +307,13 @@ public function init_roles() { $this->role_objects = array(); $this->role_names = array(); foreach ( array_keys( $this->roles ) as $role ) { - $this->role_objects[ $role ] = new WP_Role( $role, $this->roles[ $role ]['capabilities'] ); + $role_capabilities = $this->roles[ $role ]['capabilities'] ?? array(); + + if ( ! is_array( $role_capabilities ) ) { + $role_capabilities = array(); + } + + $this->role_objects[ $role ] = new WP_Role( $role, $role_capabilities ); $this->role_names[ $role ] = $this->roles[ $role ]['name']; } diff --git a/src/wp-includes/class-wp-user-query.php b/src/wp-includes/class-wp-user-query.php index 3815023924489..3fe7feb6a29fd 100644 --- a/src/wp-includes/class-wp-user-query.php +++ b/src/wp-includes/class-wp-user-query.php @@ -482,7 +482,10 @@ public function prepare_query( $query = array() ) { $caps_with_roles = array(); foreach ( $available_roles as $role => $role_data ) { - $role_caps = array_keys( array_filter( $role_data['capabilities'] ) ); + $role_capabilities = $role_data['capabilities'] ?? array(); + $role_caps = is_array( $role_capabilities ) + ? array_keys( array_filter( $role_capabilities ) ) + : array(); foreach ( $capabilities as $cap ) { if ( in_array( $cap, $role_caps, true ) ) { diff --git a/tests/phpunit/tests/user/query.php b/tests/phpunit/tests/user/query.php index 5978f4bf55e58..edb651172cf15 100644 --- a/tests/phpunit/tests/user/query.php +++ b/tests/phpunit/tests/user/query.php @@ -1959,6 +1959,96 @@ public function test_capability__in_capability__not_in_combined() { } } + /** + * A role stored without a 'capabilities' key (for example, left behind by a + * deactivated plugin) should be skipped rather than causing a fatal error. + * + * @ticket 62600 + */ + public function test_capability_query_with_role_missing_capabilities_key() { + global $wp_roles; + + $wp_roles->add_role( 'role_missing_caps', 'Role Missing Caps' ); + + $roles = get_option( $wp_roles->role_key ); + unset( $roles['role_missing_caps']['capabilities'] ); + update_option( $wp_roles->role_key, $roles ); + + $wp_user_search = new WP_User_Query( array( 'capability' => 'read' ) ); + $users = $wp_user_search->get_results(); + + $this->assertNotEmpty( $users ); + + $wp_roles->remove_role( 'role_missing_caps' ); + } + + /** + * A role whose 'capabilities' value isn't an array should be skipped rather + * than causing a fatal error, the same as a missing key. + * + * @ticket 62600 + */ + public function test_capability_query_with_role_capabilities_not_an_array() { + global $wp_roles; + + $wp_roles->add_role( 'role_invalid_caps', 'Role Invalid Caps' ); + + $roles = get_option( $wp_roles->role_key ); + + $roles['role_invalid_caps']['capabilities'] = false; + update_option( $wp_roles->role_key, $roles ); + + $wp_user_search = new WP_User_Query( array( 'capability' => 'read' ) ); + $users = $wp_user_search->get_results(); + + $this->assertNotEmpty( $users ); + + $wp_roles->remove_role( 'role_invalid_caps' ); + } + + /** + * A role missing its 'capabilities' key on a *different* site in the network + * should not fatal when queried by 'blog_id' from another site's context. + * + * This exercises WP_Roles::for_site(), which WP_User_Query calls to load + * and re-initialize roles for the target blog without switch_to_blog(), + * a code path neither of this ticket's prior patches (#8351, #8823) covered. + * + * @ticket 62600 + * @group ms-required + */ + public function test_capability_query_with_role_missing_capabilities_key_on_other_site() { + $blog_id = self::factory()->blog->create(); + + add_user_to_blog( $blog_id, self::$author_ids[0], 'subscriber' ); + + switch_to_blog( $blog_id ); + + global $wp_roles; + $wp_roles->add_role( 'role_missing_caps', 'Role Missing Caps' ); + + $roles = get_option( $wp_roles->role_key ); + unset( $roles['role_missing_caps']['capabilities'] ); + update_option( $wp_roles->role_key, $roles ); + + restore_current_blog(); + + // Query from the main site's context, targeting the sub-site by 'blog_id'. + $wp_user_search = new WP_User_Query( + array( + 'capability' => 'read', + 'blog_id' => $blog_id, + ) + ); + $users = $wp_user_search->get_results(); + + $this->assertNotEmpty( $users ); + + switch_to_blog( $blog_id ); + $wp_roles->remove_role( 'role_missing_caps' ); + restore_current_blog(); + } + /** * @ticket 16841 * @group ms-required