Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
13ec380
fix: misleading message when accessing admin menu page
Oct 22, 2024
c169041
fix phpcs: remove empty line
Oct 22, 2024
b55ed49
fix phpcs: add space
Oct 22, 2024
061fc20
Update comment
nirajgiriXD Oct 25, 2024
261dbd4
Update src/wp-admin/includes/menu.php
nirajgiriXD Jul 24, 2025
dd3aca5
Update src/wp-admin/includes/menu.php
nirajgiriXD Jul 24, 2025
cfdb1d4
Merge branch 'trunk' into fix/misleading-permissions-message
nirajgiriXD Jul 24, 2025
c3c6ba5
Update src/wp-admin/includes/menu.php
nirajgiriXD Dec 2, 2025
d03c40e
Update src/wp-admin/includes/plugin.php
nirajgiriXD Dec 2, 2025
9a7a02f
Update src/wp-admin/includes/plugin.php
nirajgiriXD Dec 2, 2025
24606e8
Update src/wp-admin/includes/plugin.php
nirajgiriXD Dec 2, 2025
23fd2b8
Update src/wp-admin/includes/plugin.php
nirajgiriXD Dec 2, 2025
2f2a4a4
Update src/wp-admin/includes/plugin.php
nirajgiriXD Dec 2, 2025
a95d739
Update src/wp-admin/includes/menu.php
nirajgiriXD Feb 17, 2026
ad8e1df
Update src/wp-admin/includes/plugin.php
nirajgiriXD Feb 17, 2026
e5e363d
Update src/wp-admin/includes/plugin.php
nirajgiriXD Feb 17, 2026
c71ac8d
Update src/wp-admin/includes/plugin.php
nirajgiriXD Feb 17, 2026
c03dfd5
Merge branch 'trunk' into fix/misleading-permissions-message
nirajgiriXD Feb 17, 2026
b90dbb8
Merge branch 'trunk' into fix/misleading-permissions-message
nirajgiriXD May 13, 2026
ab14ebe
Merge branch 'trunk' into fix/misleading-permissions-message
t-hamano Jul 28, 2026
0697636
Update src/wp-admin/includes/menu.php
nirajgiriXD Jul 28, 2026
e7244f7
Update src/wp-admin/includes/plugin.php
nirajgiriXD Jul 28, 2026
b5f9663
fix: rename admin_page_exists() to wp_admin_page_exists() and fix doc…
nirajgiriXD Jul 28, 2026
89b1fd1
fix: add PHPUnit tests for wp_admin_page_exists()
nirajgiriXD Jul 28, 2026
2754548
Merge branch 'fix/misleading-permissions-message' of https://github.c…
nirajgiriXD Jul 28, 2026
97aa9b6
Merge branch 'trunk' into fix/misleading-permissions-message
t-hamano Sep 16, 2026
26c620e
fix: treat capability-restricted admin pages as existing in wp_admin_…
nirajgiriXD Sep 17, 2026
afd6920
test: remove duplicate tear_down() and cover capability-restricted ad…
nirajgiriXD Sep 17, 2026
e488c9e
Merge branch 'WordPress:trunk' into fix/misleading-permissions-message
nirajgiriXD Sep 17, 2026
ed27b8b
Merge branch 'trunk' into fix/misleading-permissions-message
t-hamano Sep 20, 2026
ecbf577
Merge branch 'trunk' into fix/misleading-permissions-message
westonruter Sep 21, 2026
7cabe0c
Docs: Type the admin menu no-privilege globals.
westonruter Sep 21, 2026
4e5ca9a
Add native return type hints
westonruter Sep 21, 2026
8616ec7
Docs: Describe the globals imported by `user_can_access_admin_page()`.
westonruter Sep 21, 2026
c2f649f
Merge branch 'trunk' into fix/misleading-permissions-message
t-hamano Sep 30, 2026
7ffa3e2
Merge branch 'trunk' into fix/misleading-permissions-message
nirajgiriXD Oct 6, 2026
4505ed6
Merge branch 'trunk' into fix/misleading-permissions-message
nirajgiriXD Oct 6, 2026
5a321a7
Remove the `$admin_page_parent` global and look up the parent each time.
t-hamano Oct 7, 2026
45e85f0
Keep a persistent registry of pages the current user cannot access.
t-hamano Oct 7, 2026
ff77174
Unset the `$plugin_page` global itself in the test.
t-hamano Oct 7, 2026
3da2ec5
Register a real page in the registered page test.
t-hamano Oct 7, 2026
abd809f
Add a `wp_` prefix to the `admin_page_not_found` hook.
t-hamano Oct 12, 2026
f8c7092
Remove unnecessary current user restoration from the new tests.
t-hamano Oct 12, 2026
4b081fe
Add `@ticket` annotations to the new tests.
t-hamano Oct 12, 2026
369fb0d
Use richer types for the admin page `@global` annotations.
t-hamano Oct 13, 2026
f3b6128
Remove the PHPStan baseline entry fixed by the `$_wp_submenu_nopriv` …
t-hamano Oct 13, 2026
0b87cf5
Merge branch 'trunk' into fix/misleading-permissions-message
peterwilsoncc Oct 15, 2026
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
12 changes: 11 additions & 1 deletion src/wp-admin/includes/menu.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 65 additions & 15 deletions src/wp-admin/includes/plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array<string, true>> $_wp_submenu_nopriv
* @global array<string, true> $_wp_nopriv_pages
* @global array<string, true> $_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).
Expand All @@ -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 );
Expand All @@ -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;
}

Expand Down Expand Up @@ -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<string, true> $_registered_pages Array of all registered admin page hooks.
* @global array<string, true> $_wp_menu_nopriv Array of top-level menu slugs the current user cannot access.
* @global array<string, array<string, true>> $_wp_submenu_nopriv Array of submenu slugs the current user cannot access, keyed by parent slug.
* @global array<string, true> $_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.
*/
Comment thread
nirajgiriXD marked this conversation as resolved.
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;
}
Comment thread
t-hamano marked this conversation as resolved.

foreach ( (array) $_wp_submenu_nopriv as $nopriv_submenus ) {
Comment thread
peterwilsoncc marked this conversation as resolved.
if ( isset( $nopriv_submenus[ $plugin_page ] ) ) {
return true;
}
}

return false;
}

/**
* Gets the hook name for the administrative page of a plugin.
*
Expand Down Expand Up @@ -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<string, true> $_wp_menu_nopriv Array of top-level menu slugs the current user cannot access.
* @global array<string, array<string, true>> $_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<string, true> $_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;

Expand Down
5 changes: 0 additions & 5 deletions tests/phpstan/baselines/offsetAccess.nonOffsetAccessible.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 109 additions & 6 deletions tests/phpunit/tests/admin/includesPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down Expand Up @@ -97,6 +103,103 @@ public function test_menu_page_url() {
wp_set_current_user( $current_user );
}

/**
* @ticket 14060
*
* @covers ::wp_admin_page_exists
Comment thread
peterwilsoncc marked this conversation as resolved.
*/
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.
*
Expand Down
Loading