Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions repro-evidence/after-fix-output.txt
Original file line number Diff line number Diff line change
@@ -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).
26 changes: 26 additions & 0 deletions repro-evidence/before-fix-debug.log
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions repro-evidence/multisite-verification.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 additions & 0 deletions repro-evidence/pr-body.md
Original file line number Diff line number Diff line change
@@ -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.**
9 changes: 9 additions & 0 deletions repro-evidence/trac-comment.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion src/wp-includes/class-wp-roles.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
}

Expand Down
5 changes: 4 additions & 1 deletion src/wp-includes/class-wp-user-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) ) {
Expand Down
90 changes: 90 additions & 0 deletions tests/phpunit/tests/user/query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading