diff --git a/src/wp-admin/includes/menu.php b/src/wp-admin/includes/menu.php index f1c2985e93e8e..ff4635fffc8b2 100644 --- a/src/wp-admin/includes/menu.php +++ b/src/wp-admin/includes/menu.php @@ -376,7 +376,17 @@ function sort_menu( $a, $b ) { } unset( $last_menu_key ); -if ( ! user_can_access_admin_page() ) { +if ( ! wp_admin_page_exists() ) { + + /** + * Fires when a request is made to access an admin page that does not exist. + * + * @since 7.2.0 + */ + do_action( 'wp_admin_page_not_found' ); + + wp_die( __( 'The requested page does not exist.' ), 404 ); +} elseif ( ! user_can_access_admin_page() ) { /** * Fires when access to an admin page is denied. diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php index c627e6d8f55d5..c655ef21c27c6 100644 --- a/src/wp-admin/includes/plugin.php +++ b/src/wp-admin/includes/plugin.php @@ -1477,12 +1477,13 @@ function add_menu_page( $page_title, $menu_title, $capability, $menu_slug, $call * @since 1.5.0 * @since 5.3.0 Added the `$position` parameter. * - * @global array $submenu - * @global array $menu - * @global array $_wp_real_parent_file - * @global bool $_wp_submenu_nopriv - * @global array $_registered_pages - * @global array $_parent_pages + * @global array $submenu + * @global array $menu + * @global array $_wp_real_parent_file + * @global array> $_wp_submenu_nopriv + * @global array $_wp_nopriv_pages + * @global array $_registered_pages + * @global array $_parent_pages * * @param string $parent_slug The slug name for the parent menu (or the file name of a standard * WordPress admin page). @@ -1501,7 +1502,7 @@ function add_menu_page( $page_title, $menu_title, $capability, $menu_slug, $call */ function add_submenu_page( $parent_slug, $page_title, $menu_title, $capability, $menu_slug, $callback = '', $position = null ) { global $submenu, $menu, $_wp_real_parent_file, $_wp_submenu_nopriv, - $_registered_pages, $_parent_pages; + $_wp_nopriv_pages, $_registered_pages, $_parent_pages; $menu_slug = plugin_basename( $menu_slug ); $parent_slug = plugin_basename( $parent_slug ); @@ -1512,6 +1513,7 @@ function add_submenu_page( $parent_slug, $page_title, $menu_title, $capability, if ( ! current_user_can( $capability ) ) { $_wp_submenu_nopriv[ $parent_slug ][ $menu_slug ] = true; + $_wp_nopriv_pages[ $menu_slug ] = true; return false; } @@ -2162,6 +2164,54 @@ function get_plugin_page_hook( $plugin_page, $parent_page ) { } } +/** + * Determines whether the requested admin page is registered. + * + * This does not check whether the current user has the capability to access + * the page, only whether the page itself exists. Pages registered for a + * capability the current user lacks are recorded in the no-privilege + * registries rather than in $_registered_pages, and are still considered + * to exist so that the caller can report a permission error instead of a + * "page not found" error. + * + * @since 7.2.0 + * + * @global string|null $plugin_page The plugin page slug being loaded. + * @global array $_registered_pages Array of all registered admin page hooks. + * @global array $_wp_menu_nopriv Array of top-level menu slugs the current user cannot access. + * @global array> $_wp_submenu_nopriv Array of submenu slugs the current user cannot access, keyed by parent slug. + * @global array $_wp_nopriv_pages Array of page slugs registered for a capability the current user lacks. + * + * @return bool True if the admin page exists, false otherwise. + */ +function wp_admin_page_exists(): bool { + global $plugin_page, $_registered_pages, $_wp_menu_nopriv, $_wp_submenu_nopriv, + $_wp_nopriv_pages; + + if ( ! isset( $plugin_page ) ) { + return true; + } + + $hookname = get_plugin_page_hookname( $plugin_page, get_admin_page_parent() ); + + if ( isset( $_registered_pages[ $hookname ] ) ) { + return true; + } + + // The page may be registered for a capability the current user lacks. + if ( isset( $_wp_menu_nopriv[ $plugin_page ] ) || isset( $_wp_nopriv_pages[ $plugin_page ] ) ) { + return true; + } + + foreach ( (array) $_wp_submenu_nopriv as $nopriv_submenus ) { + if ( isset( $nopriv_submenus[ $plugin_page ] ) ) { + return true; + } + } + + return false; +} + /** * Gets the hook name for the administrative page of a plugin. * @@ -2202,17 +2252,17 @@ function get_plugin_page_hookname( $plugin_page, $parent_page ) { * * @since 1.5.0 * - * @global string $pagenow The filename of the current screen. - * @global array $menu - * @global array $submenu - * @global array $_wp_menu_nopriv - * @global array $_wp_submenu_nopriv - * @global string $plugin_page - * @global array $_registered_pages + * @global string $pagenow The filename of the current screen. + * @global array $menu The top-level admin menu items. + * @global array $submenu The admin submenu items, keyed by parent slug. + * @global array $_wp_menu_nopriv Array of top-level menu slugs the current user cannot access. + * @global array> $_wp_submenu_nopriv Array of submenu slugs the current user cannot access, keyed by parent slug. + * @global string|null $plugin_page The plugin page slug being loaded. + * @global array $_registered_pages Array of all registered admin page hooks. * * @return bool True if the current user can access the admin page, false otherwise. */ -function user_can_access_admin_page() { +function user_can_access_admin_page(): bool { global $pagenow, $menu, $submenu, $_wp_menu_nopriv, $_wp_submenu_nopriv, $plugin_page, $_registered_pages; diff --git a/tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon b/tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon index 5b2396b941816..9179b5365005f 100644 --- a/tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon +++ b/tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon @@ -23,11 +23,6 @@ parameters: identifier: offsetAccess.nonOffsetAccessible count: 4 path: ../../../src/wp-admin/includes/class-wp-debug-data.php - - - message: '#^Cannot access offset mixed on bool\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: ../../../src/wp-admin/includes/plugin.php - message: '#^Cannot access offset ''new_version'' on bool\.$#' identifier: offsetAccess.nonOffsetAccessible diff --git a/tests/phpunit/tests/admin/includesPlugin.php b/tests/phpunit/tests/admin/includesPlugin.php index 21e3064ff6b55..813ba3429c204 100644 --- a/tests/phpunit/tests/admin/includesPlugin.php +++ b/tests/phpunit/tests/admin/includesPlugin.php @@ -26,13 +26,19 @@ public function tear_down() { * Resets the global menu registries modified by the menu API tests. */ private function reset_menu_globals() { - global $menu, $submenu, $admin_page_hooks, $_registered_pages, $_parent_pages; + global $menu, $submenu, $admin_page_hooks, $_registered_pages, $_parent_pages, + $_wp_menu_nopriv, $_wp_submenu_nopriv, $_wp_nopriv_pages; - $menu = array(); - $submenu = array(); - $admin_page_hooks = array(); - $_registered_pages = array(); - $_parent_pages = array(); + $menu = array(); + $submenu = array(); + $admin_page_hooks = array(); + $_registered_pages = array(); + $_parent_pages = array(); + $_wp_menu_nopriv = array(); + $_wp_submenu_nopriv = array(); + $_wp_nopriv_pages = array(); + + unset( $GLOBALS['plugin_page'] ); } public static function wpSetUpBeforeClass( $factory ) { @@ -97,6 +103,103 @@ public function test_menu_page_url() { wp_set_current_user( $current_user ); } + /** + * @ticket 14060 + * + * @covers ::wp_admin_page_exists + */ + public function test_wp_admin_page_exists_returns_true_when_no_plugin_page_is_set() { + unset( $GLOBALS['plugin_page'] ); + + $this->assertTrue( wp_admin_page_exists() ); + } + + /** + * @ticket 14060 + * + * @covers ::wp_admin_page_exists + */ + public function test_wp_admin_page_exists_returns_true_for_a_registered_page() { + global $pagenow, $plugin_page; + + wp_set_current_user( self::$admin_id ); + + add_options_page( 'Test Settings', 'Test Settings', 'manage_options', 'testsettings', 'mt_settings_page' ); + + $original_pagenow = $pagenow; + $pagenow = 'options-general.php'; + $plugin_page = 'testsettings'; + + $exists = wp_admin_page_exists(); + + $pagenow = $original_pagenow; + + $this->assertTrue( $exists ); + } + + /** + * @ticket 14060 + * + * @covers ::wp_admin_page_exists + */ + public function test_wp_admin_page_exists_returns_false_for_an_unregistered_page() { + global $plugin_page, $_registered_pages; + + $plugin_page = 'does-not-exist'; + $_registered_pages = array(); + + $this->assertFalse( wp_admin_page_exists() ); + } + + /** + * Pages the current user cannot access are not added to $_registered_pages, + * but they do exist and must not be reported as missing. + * + * @ticket 14060 + * + * @covers ::wp_admin_page_exists + */ + public function test_wp_admin_page_exists_returns_true_for_a_registered_page_the_user_cannot_access() { + global $plugin_page; + + wp_set_current_user( self::$admin_id ); + + add_menu_page( 'Test Toplevel', 'Test Toplevel', 'manage_options', 'mt-top-level-handle', 'mt_toplevel_page' ); + + // Register a submenu page requiring a capability no user has. + wp_set_current_user( 0 ); + add_submenu_page( 'mt-top-level-handle', 'No Priv', 'No Priv', 'do_not_allow', 'mt-no-priv', 'mt_no_priv_page' ); + + $plugin_page = 'mt-no-priv'; + + $this->assertTrue( wp_admin_page_exists() ); + } + + /** + * The no-privilege menu registries are reset in menu.php after the + * `_admin_menu` hook fires, so pages registered before that must still + * be considered to exist. + * + * @ticket 14060 + * + * @covers ::wp_admin_page_exists + */ + public function test_wp_admin_page_exists_returns_true_for_an_inaccessible_page_after_nopriv_registries_are_reset() { + global $plugin_page, $_wp_menu_nopriv, $_wp_submenu_nopriv; + + wp_set_current_user( 0 ); + + add_submenu_page( 'options-general.php', 'No Priv', 'No Priv', 'do_not_allow', 'mt-no-priv', 'mt_no_priv_page' ); + + // Simulate menu.php resetting the registries. + $_wp_menu_nopriv = array(); + $_wp_submenu_nopriv = array(); + + $plugin_page = 'mt-no-priv'; + + $this->assertTrue( wp_admin_page_exists() ); + } + /** * Tests the position parameter. *