Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php
/**
* Class External_Admin_Menu_Links_Check.
*
* @package plugin-check
*/

namespace WordPress\Plugin_Check\Checker\Checks\Plugin_Repo;

use Exception;
use WordPress\Plugin_Check\Checker\Check_Categories;
use WordPress\Plugin_Check\Checker\Check_Result;
use WordPress\Plugin_Check\Checker\Checks\Abstract_File_Check;
use WordPress\Plugin_Check\Traits\Amend_Check_Result;
use WordPress\Plugin_Check\Traits\Stable_Check;

/**
* Check to detect external URLs used in WordPress admin menu functions.
*
* This check detects when plugins use external URLs (starting with http://, https://, or //)
* as the menu slug parameter in WordPress admin menu functions. This practice violates
* WordPress.org Plugin Directory Guideline #11 which prohibits hijacking the admin experience.
*
* @since 1.9.0
*/
class External_Admin_Menu_Links_Check extends Abstract_File_Check {

use Amend_Check_Result;
use Stable_Check;

/**
* List of top-level admin menu functions to check.
*
* Only add_menu_page is checked as it adds to the top-level admin menu.
* External URLs in submenus (add_submenu_page, add_options_page, etc.) are
* acceptable for documentation, support links, and other resources.
*
* @since 1.9.0
* @var array
*/
protected $menu_functions = array(
'add_menu_page',
);

/**
* Gets the categories for the check.
*
* Every check must have at least one category.
*
* @since 1.9.0
*
* @return array The categories for the check.
*/
public function get_categories() {
return array( Check_Categories::CATEGORY_PLUGIN_REPO );
}

/**
* Amends the given result by running the check on the given list of files.
*
* @since 1.9.0
*
* @param Check_Result $result The check result to amend, including the plugin context to check.
* @param array $files List of absolute file paths.
*
* @throws Exception Thrown when the check fails with a critical error (unrelated to any errors detected as part of
* the check).
*/
protected function check_files( Check_Result $result, array $files ) {
$php_files = self::filter_files_by_extension( $files, 'php' );

$this->look_for_external_menu_links( $result, $php_files );
}

/**
* Looks for external URLs in admin menu functions and amends the given result with an error if found.
*
* @since 1.9.0
*
* @param Check_Result $result The check result to amend, including the plugin context to check.
* @param array $php_files List of absolute PHP file paths.
*/
protected function look_for_external_menu_links( Check_Result $result, array $php_files ) {
// Build regex pattern for all menu functions.
$functions_pattern = implode( '|', array_map( 'preg_quote', $this->menu_functions ) );

// Pattern to match menu function calls with external URLs in the 4th parameter.
// This regex matches:
// - Function name from the list.
// - Opening parenthesis.
// - First 3 parameters (non-greedy, can be strings with single/double quotes or variables).
// - 4th parameter containing http://, https://, or // at the start of a string.
$pattern = '/\b(' . $functions_pattern . ')\s*\(\s*' .
// First parameter.
'(?:[^,]+)\s*,\s*' .
// Second parameter.
'(?:[^,]+)\s*,\s*' .
// Third parameter.
'(?:[^,]+)\s*,\s*' .
// Fourth parameter - look for external URL (http://, https://, or //).
'[\'"](?:https?:)?\/\/[^\'"]+[\'"]/i';

$files = self::files_preg_match_all( $pattern, $php_files );

if ( ! empty( $files ) ) {
foreach ( $files as $file ) {
$this->add_result_error_for_file(
$result,
__(
'<strong>External URL used in top-level admin menu.</strong><br>Plugins should not use add_menu_page() for external links as it disrupts the WordPress navigation experience.',
'plugin-check'
),
'external_admin_menu_link',
$file['file'],
$file['line'],
$file['column'],
'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/#11-plugins-should-not-hijack-the-admin',
7
);
}
}
}

/**
* Gets the description for the check.
*
* Every check must have a short description explaining what the check does.
*
* @since 1.9.0
*
* @return string Description.
*/
public function get_description(): string {
return __( 'Detects external URLs used in top-level WordPress admin menu, which disrupts the expected user experience.', 'plugin-check' );
}

/**
* Gets the documentation URL for the check.
*
* Every check must have a URL with further information about the check.
*
* @since 1.9.0
*
* @return string The documentation URL.
*/
public function get_documentation_url(): string {
return __( 'https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/#11-plugins-should-not-hijack-the-admin', 'plugin-check' );
}
}
1 change: 1 addition & 0 deletions includes/Checker/Default_Check_Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ private function register_default_checks() {
'direct_db' => new Checks\Security\Direct_DB_Check(),
'minified_files' => new Checks\Plugin_Repo\Minified_Files_Check(),
'direct_file_access' => new Checks\Plugin_Repo\Direct_File_Access_Check(),
'external_admin_menu_links' => new Checks\Plugin_Repo\External_Admin_Menu_Links_Check(),
)
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php
/**
* Plugin Name: Test Plugin External Admin Menu Links With Errors
* Plugin URI: https://github.com/WordPress/plugin-check
* Description: Test plugin with external URLs in admin menu functions.
* Requires at least: 6.0
* Requires PHP: 5.6
* Version: 1.0.0
* Author: WordPress Performance Team
* Author URI: https://make.wordpress.org/performance/
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
* Text Domain: test-plugin-external-admin-menu-links-with-errors
*
* @package test-plugin-external-admin-menu-links-with-errors
*/

/**
* These are examples of problematic code that adds external links to
* the TOP-LEVEL admin menu using add_menu_page().
*/

// ❌ Adding external link to top-level menu with https.
add_menu_page(
'External Resource',
'External Resource',
'manage_options',
'https://example.com/external-page',
'',
'dashicons-admin-site',
30
);

// ❌ Adding external link to top-level menu with http.
add_menu_page(
'HTTP External',
'HTTP External',
'manage_options',
'http://example.com/http-page',
'',
'dashicons-admin-site',
31
);

// ❌ Adding external link with protocol-relative URL to top-level menu.
add_menu_page(
'Protocol Relative',
'Protocol Relative',
'manage_options',
'//example.com/protocol-relative',
'',
'dashicons-admin-site',
32
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php
/**
* Plugin Name: Test Plugin External Admin Menu Links Without Errors
* Plugin URI: https://github.com/WordPress/plugin-check
* Description: Test plugin with proper internal admin menu usage.
* Requires at least: 6.0
* Requires PHP: 5.6
* Version: 1.0.0
* Author: WordPress Performance Team
* Author URI: https://make.wordpress.org/performance/
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
* Text Domain: test-plugin-external-admin-menu-links-without-errors
*
* @package test-plugin-external-admin-menu-links-without-errors
*/

/**
* These are examples of correct admin menu usage with internal slugs.
*/

// ✅ Adding internal page to main menu.
add_menu_page(
'My Plugin Settings',
'My Plugin',
'manage_options',
'my-plugin-settings',
'my_plugin_settings_page',
'dashicons-admin-generic',
30
);

// ✅ Adding internal page to options menu.
add_options_page(
'My Plugin Options',
'My Plugin',
'manage_options',
'my-plugin-options',
'my_plugin_options_page'
);

// ✅ Adding internal page to management/tools menu.
add_management_page(
'My Plugin Tools',
'My Plugin Tools',
'manage_options',
'my-plugin-tools',
'my_plugin_tools_page'
);

// ✅ Adding internal page to theme menu.
add_theme_page(
'Theme Settings',
'Theme Settings',
'manage_options',
'my-theme-settings',
'my_theme_settings_page'
);

// ✅ Adding internal page to plugins menu.
add_plugins_page(
'Plugin Manager',
'Plugin Manager',
'manage_options',
'my-plugin-manager',
'my_plugin_manager_page'
);

// ✅ Adding internal page to users menu.
add_users_page(
'User Settings',
'User Settings',
'manage_options',
'my-user-settings',
'my_user_settings_page'
);

// ✅ Adding internal page to dashboard menu.
add_dashboard_page(
'Dashboard Info',
'Dashboard Info',
'manage_options',
'my-dashboard-info',
'my_dashboard_info_page'
);

// ✅ Adding submenu page with external URL is allowed.
add_submenu_page(
'my-plugin-settings',
'Documentation',
'Documentation',
'manage_options',
'https://example.com/docs',
''
);

// ✅ External URLs in submenu functions are allowed for docs, support, etc.
add_options_page(
'Plugin Documentation',
'Documentation',
'manage_options',
'https://example.com/plugin-docs'
);

// ✅ External URL in management page for support link.
add_management_page(
'Support',
'Get Support',
'manage_options',
'https://example.com/support'
);

// ✅ Adding submenu page with internal slug.
add_submenu_page(
'my-plugin-settings',
'Advanced Settings',
'Advanced',
'manage_options',
'my-plugin-advanced',
'my_plugin_advanced_page'
);

/**
* Callback functions for admin pages.
*/
function my_plugin_settings_page() {
echo '<div class="wrap"><h1>My Plugin Settings</h1></div>';
}

function my_plugin_options_page() {
echo '<div class="wrap"><h1>My Plugin Options</h1></div>';
}

function my_plugin_tools_page() {
echo '<div class="wrap"><h1>My Plugin Tools</h1></div>';
}

function my_theme_settings_page() {
echo '<div class="wrap"><h1>Theme Settings</h1></div>';
}

function my_plugin_manager_page() {
echo '<div class="wrap"><h1>Plugin Manager</h1></div>';
}

function my_user_settings_page() {
echo '<div class="wrap"><h1>User Settings</h1></div>';
}

function my_dashboard_info_page() {
echo '<div class="wrap"><h1>Dashboard Info</h1></div>';
}

function my_plugin_advanced_page() {
echo '<div class="wrap"><h1>Advanced Settings</h1></div>';
}
Loading
Loading