From 25db5d053444a3a4066cfc28b931a2eabb8b2375 Mon Sep 17 00:00:00 2001 From: Andrei Lupu Date: Tue, 11 Aug 2026 15:39:29 +0300 Subject: [PATCH] Fix fatal in role_can_view() when cap check fires before init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user_has_cap/role_has_cap filters are registered in the Admin constructor, but the Settings object they dereference is only constructed in Plugin::init() on init priority 9. Any capability check for view_stream between plugins_loaded and init 9 — security/firewall plugins evaluating rules on plugins_loaded do exactly this — reaches role_can_view() with a null options chain, and in_array() with a null haystack is an uncaught TypeError on PHP 8+, fataling wp-admin and admin-ajax for every logged-in user. Null-coalesce the options access and deny access during the pre-init window instead of fataling. Adds regression tests for both the early (denied, no fatal) and normal (granted) paths. Co-Authored-By: Claude Fable 5 --- classes/class-admin.php | 12 ++++++--- tests/phpunit/test-class-admin.php | 39 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/classes/class-admin.php b/classes/class-admin.php index 51848a38f..ea2b0f2b1 100644 --- a/classes/class-admin.php +++ b/classes/class-admin.php @@ -1748,16 +1748,20 @@ public function register_list_table() { /** * Check if a particular role has access * + * The user_has_cap/role_has_cap filters that call this are registered in the + * constructor, but the Settings object is not constructed until init priority 9. + * A capability check fired before then (e.g. by a security plugin evaluating + * firewall rules on plugins_loaded) must be denied rather than fatal on the + * null options chain. + * * @param string $role User role. * * @return bool */ private function role_can_view( $role ) { - if ( in_array( $role, $this->plugin->settings->options['general_role_access'], true ) ) { - return true; - } + $allowed_roles = $this->plugin->settings->options['general_role_access'] ?? array(); - return false; + return in_array( $role, (array) $allowed_roles, true ); } /** diff --git a/tests/phpunit/test-class-admin.php b/tests/phpunit/test-class-admin.php index ba29f6869..b16fee4b4 100644 --- a/tests/phpunit/test-class-admin.php +++ b/tests/phpunit/test-class-admin.php @@ -73,6 +73,45 @@ public function test_init() { $this->assertInstanceOf( '\WP_Stream\Export', $this->admin->export ); } + /** + * The user_has_cap filter is registered in the Admin constructor, but the + * Settings object is only built on init priority 9. A capability check for + * the view cap fired before then (e.g. a firewall plugin on plugins_loaded) + * must be denied, not fatal on the null options chain. + */ + public function test_filter_user_caps_before_settings_initialized() { + $settings = $this->plugin->settings; + $this->plugin->settings = null; + + $user = get_user_by( 'id', $this->admin_user_id ); + $allcaps = $this->admin->filter_user_caps( + array(), + array( $this->admin->view_cap ), + array( $this->admin->view_cap, $this->admin_user_id ), + $user + ); + + $this->plugin->settings = $settings; + + $this->assertArrayNotHasKey( $this->admin->view_cap, $allcaps ); + } + + /** + * Once Settings exists, the view cap is granted to allowed roles as before. + */ + public function test_filter_user_caps_grants_view_cap_to_allowed_role() { + $user = get_user_by( 'id', $this->admin_user_id ); + $allcaps = $this->admin->filter_user_caps( + array(), + array( $this->admin->view_cap ), + array( $this->admin->view_cap, $this->admin_user_id ), + $user + ); + + $this->assertArrayHasKey( $this->admin->view_cap, $allcaps ); + $this->assertTrue( $allcaps[ $this->admin->view_cap ] ); + } + public function test_prepare_admin_notices() { // Test no notices $this->admin->notices = array();