diff --git a/includes/Checker/Checks/Plugin_Repo/External_Admin_Menu_Links_Check.php b/includes/Checker/Checks/Plugin_Repo/External_Admin_Menu_Links_Check.php new file mode 100644 index 000000000..140ca007e --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/External_Admin_Menu_Links_Check.php @@ -0,0 +1,149 @@ +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, + __( + 'External URL used in top-level admin menu.
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' ); + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index d4e53f940..0a8d03226 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -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(), ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-external-admin-menu-links-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-external-admin-menu-links-with-errors/load.php new file mode 100644 index 000000000..350102bb3 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-external-admin-menu-links-with-errors/load.php @@ -0,0 +1,54 @@ +

My Plugin Settings

'; +} + +function my_plugin_options_page() { + echo '

My Plugin Options

'; +} + +function my_plugin_tools_page() { + echo '

My Plugin Tools

'; +} + +function my_theme_settings_page() { + echo '

Theme Settings

'; +} + +function my_plugin_manager_page() { + echo '

Plugin Manager

'; +} + +function my_user_settings_page() { + echo '

User Settings

'; +} + +function my_dashboard_info_page() { + echo '

Dashboard Info

'; +} + +function my_plugin_advanced_page() { + echo '

Advanced Settings

'; +} diff --git a/tests/phpunit/tests/Checker/Checks/External_Admin_Menu_Links_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/External_Admin_Menu_Links_Check_Tests.php new file mode 100644 index 000000000..f1100f119 --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/External_Admin_Menu_Links_Check_Tests.php @@ -0,0 +1,93 @@ +run( $check_result ); + + $errors = $check_result->get_errors(); + + $this->assertNotEmpty( $errors ); + $this->assertArrayHasKey( 'load.php', $errors ); + $this->assertGreaterThan( 0, $check_result->get_error_count() ); + + // Check that the error code is correct. + $found_external_menu_error = false; + foreach ( $errors['load.php'] as $line => $columns ) { + foreach ( $columns as $column => $messages ) { + foreach ( $messages as $message ) { + if ( 'external_admin_menu_link' === $message['code'] ) { + $found_external_menu_error = true; + break 3; + } + } + } + } + $this->assertTrue( $found_external_menu_error, 'Expected external_admin_menu_link error code not found.' ); + } + + /** + * Test that internal slugs and external URLs in submenu functions do not trigger errors. + */ + public function test_no_errors_for_internal_admin_menu_links() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-external-admin-menu-links-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new External_Admin_Menu_Links_Check(); + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + + $this->assertEmpty( $errors ); + $this->assertSame( 0, $check_result->get_error_count() ); + } + + /** + * Test that the check returns the correct categories. + */ + public function test_get_categories() { + $check = new External_Admin_Menu_Links_Check(); + $categories = $check->get_categories(); + + $this->assertContains( Check_Categories::CATEGORY_PLUGIN_REPO, $categories ); + } + + /** + * Test that the check has a description. + */ + public function test_get_description() { + $check = new External_Admin_Menu_Links_Check(); + $description = $check->get_description(); + + $this->assertNotEmpty( $description ); + $this->assertIsString( $description ); + } + + /** + * Test that the check has a documentation URL. + */ + public function test_get_documentation_url() { + $check = new External_Admin_Menu_Links_Check(); + $url = $check->get_documentation_url(); + + $this->assertNotEmpty( $url ); + $this->assertStringContainsString( 'https://', $url ); + } +}