From f9810ece4f07b52094fd21e07a7d249e9587b9cc Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 10 Aug 2026 00:25:49 -0600 Subject: [PATCH 1/4] fix(sso): lazily generate admin-bar magic links --- inc/sso/class-admin-bar-magic-links.php | 179 ++++++++++++++++-- inc/sso/class-magic-link.php | 2 +- .../SSO/Admin_Bar_Magic_Links_Test.php | 103 ++++++++++ tests/WP_Ultimo/SSO/Magic_Link_Test.php | 23 +++ 4 files changed, 293 insertions(+), 14 deletions(-) diff --git a/inc/sso/class-admin-bar-magic-links.php b/inc/sso/class-admin-bar-magic-links.php index 318beda84..379e18a03 100644 --- a/inc/sso/class-admin-bar-magic-links.php +++ b/inc/sso/class-admin-bar-magic-links.php @@ -22,6 +22,22 @@ class Admin_Bar_Magic_Links { use \WP_Ultimo\Traits\Singleton; + /** + * Admin-post action used to lazily resolve a dashboard link. + * + * @since 2.0.0 + * @var string + */ + const ADMIN_POST_ACTION = 'wu_admin_bar_magic_link'; + + /** + * Query argument containing the requested site ID. + * + * @since 2.0.0 + * @var string + */ + const SITE_ID_QUERY_ARG = 'wu_site_id'; + /** * Initialize hooks. * @@ -33,6 +49,9 @@ public function init(): void { // Hook late to modify the URLs after WordPress core adds them. add_action('admin_bar_menu', array($this, 'modify_my_sites_menu'), 999); + // Resolve dashboard URLs only after the user selects a site. + add_action('admin_post_' . self::ADMIN_POST_ACTION, array($this, 'handle_admin_bar_magic_link')); + // Hook early into admin_page_access_denied to show magic links. add_action('admin_page_access_denied', array($this, 'show_access_denied_with_magic_links'), 5); } @@ -42,7 +61,8 @@ public function init(): void { * * This function hooks into the admin bar after WordPress core has * added all the My Sites menu items, and replaces dashboard URLs - * with magic links for sites that have custom domains. + * with same-origin lazy redirect URLs. Magic links are generated only + * after the user selects a dashboard link. * * @since 2.0.0 * @@ -56,27 +76,160 @@ public function modify_my_sites_menu($wp_admin_bar): void { return; } - // Process each node. + // Process each dashboard node without resolving its destination. foreach ($wp_admin_bar->get_nodes() as $node) { - $parts = explode('-', $node->id); - if (count($parts) >= 3 && 'blog' === $parts[0] && is_numeric($parts[1]) && 'd' === $parts[2]) { - $site_id = (int) $parts[1]; - } else { + if ( ! preg_match('/^blog-(\d+)-d$/', $node->id, $matches)) { continue; } - // Generate magic link. - $magic_link = wu_get_admin_url($site_id); + $site_id = (int) $matches[1]; - if ( ! $magic_link ) { - continue; + // Keep the click on this authenticated origin until it is validated. + $node->href = $this->get_admin_bar_action_url($site_id); + + $wp_admin_bar->add_node($node); + } + } + + /** + * Build the same-origin action URL for a site's dashboard link. + * + * @since 2.0.0 + * + * @param int $site_id Site ID. + * @return string + */ + public function get_admin_bar_action_url($site_id) { + + $site_id = absint($site_id); + + return add_query_arg( + array( + 'action' => self::ADMIN_POST_ACTION, + self::SITE_ID_QUERY_ARG => $site_id, + '_wpnonce' => wp_create_nonce($this->get_admin_bar_nonce_action($site_id)), + ), + admin_url('admin-post.php') + ); + } + + /** + * Resolve a selected dashboard link and redirect to its validated destination. + * + * @since 2.0.0 + * @return void + */ + public function handle_admin_bar_magic_link(): void { + + if ( ! is_user_logged_in() ) { + wp_die(esc_html__('You do not have permission to access this site.', 'ultimate-multisite'), 403); + } + + $site_id = $this->get_requested_site_id(); + $nonce = $this->get_requested_nonce(); + + if ( ! $site_id || ! $nonce || ! wp_verify_nonce($nonce, $this->get_admin_bar_nonce_action($site_id)) ) { + wp_die(esc_html__('The requested site link is invalid.', 'ultimate-multisite'), 403); + } + + $destination = $this->get_site_dashboard_url($site_id); + + if ( ! $destination ) { + wp_die(esc_html__('You do not have permission to access this site.', 'ultimate-multisite'), 403); + } + + $destination_host = wp_parse_url($destination, PHP_URL_HOST); + + if ( ! is_string($destination_host) || '' === $destination_host ) { + wp_die(esc_html__('The requested site link is invalid.', 'ultimate-multisite'), 403); + } + + $allow_destination_host = static function ($allowed_hosts, $host) use ($destination_host) { + if (strtolower($destination_host) === strtolower($host)) { + $allowed_hosts[] = $destination_host; } - // Update the node with the magic link. - $node->href = $magic_link; + return $allowed_hosts; + }; - $wp_admin_bar->add_node($node); + add_filter('allowed_redirect_hosts', $allow_destination_host, 100, 2); + $redirected = wp_safe_redirect($destination, 302, 'Ultimate-Multisite'); + remove_filter('allowed_redirect_hosts', $allow_destination_host, 100); + + if ( ! $redirected ) { + wp_die(esc_html__('The requested site link is invalid.', 'ultimate-multisite'), 403); + } + + exit; + } + + /** + * Return the verified dashboard destination for a site. + * + * @since 2.0.0 + * + * @param int $site_id Site ID. + * @return false|string + */ + public function get_site_dashboard_url($site_id) { + + $site_id = absint($site_id); + $site = get_site($site_id); + + if ( + ! $site instanceof \WP_Site + || $site->deleted + || $site->spam + || $site->archived + || (! is_super_admin() && ! is_user_member_of_blog(get_current_user_id(), $site_id)) + ) { + return false; + } + + return wu_get_admin_url($site_id); + } + + /** + * Get the nonce action for a site's dashboard link. + * + * @since 2.0.0 + * + * @param int $site_id Site ID. + * @return string + */ + protected function get_admin_bar_nonce_action($site_id) { + + return self::ADMIN_POST_ACTION . '_' . absint($site_id); + } + + /** + * Get a validated site ID from the request. + * + * @since 2.0.0 + * @return false|int + */ + protected function get_requested_site_id() { + + $site_id = wu_request(self::SITE_ID_QUERY_ARG); + + if ( ! is_string($site_id) || ! ctype_digit($site_id) || ! absint($site_id) ) { + return false; } + + return absint($site_id); + } + + /** + * Get a nonce string from the request. + * + * @since 2.0.0 + * @return false|string + */ + protected function get_requested_nonce() { + + $nonce = wu_request('_wpnonce'); + + return is_string($nonce) ? $nonce : false; } /** diff --git a/inc/sso/class-magic-link.php b/inc/sso/class-magic-link.php index 46aeb35e2..c6bc1e8d4 100644 --- a/inc/sso/class-magic-link.php +++ b/inc/sso/class-magic-link.php @@ -236,7 +236,7 @@ protected function verify_user_site_access($user_id, $site_id) { return false; } - if (is_user_member_of_blog($user_id, $site_id)) { + if (is_super_admin($user_id) || is_user_member_of_blog($user_id, $site_id)) { return true; } // Check if the site is the dashboard site in WP Frontend Admin which the user would not be a member of. diff --git a/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php b/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php index a3847b7cd..36f0cf6f1 100644 --- a/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php +++ b/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php @@ -60,4 +60,107 @@ public function test_modify_my_sites_menu_no_user(): void { $this->assertTrue(true); // No exception thrown. } + + /** + * Test dashboard nodes are changed to lazy same-origin action URLs. + */ + public function test_modify_my_sites_menu_uses_lazy_action_urls(): void { + $user_id = self::factory()->user->create(); + $site_id = get_current_blog_id(); + + add_user_to_blog($site_id, $user_id, 'administrator'); + wp_set_current_user($user_id); + + if ( ! class_exists('\WP_Admin_Bar') ) { + require_once ABSPATH . WPINC . '/class-wp-admin-bar.php'; + } + + $admin_bar = new \WP_Admin_Bar(); + $admin_bar->initialize(); + $admin_bar->add_node( + array( + 'id' => 'blog-' . $site_id . '-d', + 'href' => 'https://example.test/original-dashboard', + ) + ); + $admin_bar->add_node( + array( + 'id' => 'blog-' . $site_id . '-c', + 'href' => 'https://example.test/original-site', + ) + ); + $admin_bar->add_node( + array( + 'id' => 'blog-' . $site_id . '-d-extra', + 'href' => 'https://example.test/malformed-dashboard', + ) + ); + + $generated_magic_links = 0; + $magic_link_filter = static function ($url) use (&$generated_magic_links) { + ++$generated_magic_links; + + return $url; + }; + + add_filter('wu_magic_link_url', $magic_link_filter); + $this->magic_links->modify_my_sites_menu($admin_bar); + remove_filter('wu_magic_link_url', $magic_link_filter); + + $dashboard_node = $admin_bar->get_node('blog-' . $site_id . '-d'); + $site_node = $admin_bar->get_node('blog-' . $site_id . '-c'); + $malformed_node = $admin_bar->get_node('blog-' . $site_id . '-d-extra'); + $action_args = array(); + + wp_parse_str(wp_parse_url($dashboard_node->href, PHP_URL_QUERY), $action_args); + + $this->assertSame(Admin_Bar_Magic_Links::ADMIN_POST_ACTION, $action_args['action']); + $this->assertSame((string) $site_id, $action_args[ Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG ]); + $this->assertNotFalse(wp_verify_nonce($action_args['_wpnonce'], Admin_Bar_Magic_Links::ADMIN_POST_ACTION . '_' . $site_id)); + $this->assertSame(0, $generated_magic_links); + $this->assertSame('https://example.test/original-site', $site_node->href); + $this->assertSame('https://example.test/malformed-dashboard', $malformed_node->href); + } + + /** + * Test only accessible, existing sites can resolve dashboard URLs. + */ + public function test_get_site_dashboard_url_rejects_inaccessible_and_missing_sites(): void { + $user_id = self::factory()->user->create(); + $site_id = get_current_blog_id(); + + add_user_to_blog($site_id, $user_id, 'administrator'); + wp_set_current_user($user_id); + + $this->assertSame(wu_get_admin_url($site_id), $this->magic_links->get_site_dashboard_url($site_id)); + $this->assertFalse($this->magic_links->get_site_dashboard_url(999999)); + + $inaccessible_site_id = self::factory()->blog->create(); + + $this->assertFalse($this->magic_links->get_site_dashboard_url($inaccessible_site_id)); + } + + /** + * Test malformed request values cannot reach nonce or site validation. + */ + public function test_request_values_reject_arrays(): void { + $site_id_method = new \ReflectionMethod($this->magic_links, 'get_requested_site_id'); + $nonce_method = new \ReflectionMethod($this->magic_links, 'get_requested_nonce'); + $request = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tests malformed request input. + + if (PHP_VERSION_ID < 80100) { + $site_id_method->setAccessible(true); + $nonce_method->setAccessible(true); + } + + $_REQUEST[ Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG ] = array('invalid'); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tests malformed request input. + $_REQUEST['_wpnonce'] = array('invalid'); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tests malformed request input. + + try { + $this->assertFalse($site_id_method->invoke($this->magic_links)); + $this->assertFalse($nonce_method->invoke($this->magic_links)); + } finally { + $_REQUEST = $request; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restores malformed request input fixture. + } + } } diff --git a/tests/WP_Ultimo/SSO/Magic_Link_Test.php b/tests/WP_Ultimo/SSO/Magic_Link_Test.php index 64e32d2bf..dd66877ec 100644 --- a/tests/WP_Ultimo/SSO/Magic_Link_Test.php +++ b/tests/WP_Ultimo/SSO/Magic_Link_Test.php @@ -152,6 +152,29 @@ public function test_verify_user_site_access_valid() { $this->assertTrue($ref->invoke($instance, $user_id, $site_id)); } + /** + * Test verify_user_site_access allows super administrators on every site. + */ + public function test_verify_user_site_access_super_admin() { + + $instance = $this->get_instance(); + $user_id = self::factory()->user->create(); + $site_id = self::factory()->blog->create(); + $ref = new \ReflectionMethod($instance, 'verify_user_site_access'); + + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + + grant_super_admin($user_id); + + try { + $this->assertTrue($ref->invoke($instance, $user_id, $site_id)); + } finally { + revoke_super_admin($user_id); + } + } + /** * Test verify_user_site_access with invalid user. */ From 87999d45647cf18673adc8d452b10b0d1661d6b0 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 10 Aug 2026 01:06:13 -0600 Subject: [PATCH 2/4] test(sso): cover admin bar redirect handler --- .../SSO/Admin_Bar_Magic_Links_Test.php | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php b/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php index 36f0cf6f1..7acca5921 100644 --- a/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php +++ b/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php @@ -163,4 +163,161 @@ public function test_request_values_reject_arrays(): void { $_REQUEST = $request; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restores malformed request input fixture. } } + + /** + * Test the handler redirects to the resolved same-domain dashboard URL. + */ + public function test_handle_admin_bar_magic_link_redirects_to_resolved_same_domain_url(): void { + $user_id = self::factory()->user->create(); + $site_id = get_current_blog_id(); + $request = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restores the request fixture. + + add_user_to_blog($site_id, $user_id, 'administrator'); + wp_set_current_user($user_id); + + $_REQUEST = array( // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. + Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG => (string) $site_id, + '_wpnonce' => wp_create_nonce(Admin_Bar_Magic_Links::ADMIN_POST_ACTION . '_' . $site_id), + 'redirect_to' => 'https://attacker.example.test/', + ); + + $redirect = array(); + $redirect_filter = static function ($location, $status) use (&$redirect) { + $redirect = array( + 'location' => $location, + 'status' => $status, + ); + + throw new \RuntimeException('redirect_intercepted'); + }; + + add_filter('wp_redirect', $redirect_filter, 10, 2); + + try { + $this->magic_links->handle_admin_bar_magic_link(); + } catch (\RuntimeException $e) { + $this->assertSame('redirect_intercepted', $e->getMessage()); + } finally { + remove_filter('wp_redirect', $redirect_filter, 10); + $_REQUEST = $request; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restores the request fixture. + } + + $this->assertSame(get_admin_url($site_id), $redirect['location']); + $this->assertSame(302, $redirect['status']); + } + + /** + * Test the handler permits the resolved mapped-domain magic link only. + */ + public function test_handle_admin_bar_magic_link_redirects_to_resolved_mapped_domain_url(): void { + $user_id = self::factory()->user->create(); + $site_id = self::factory()->blog->create(); + $request = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restores the request fixture. + + add_user_to_blog($site_id, $user_id, 'administrator'); + wp_set_current_user($user_id); + + $mapping = wu_create_domain( + array( + 'blog_id' => $site_id, + 'domain' => 'admin-bar-magic-links.example.test', + 'active' => true, + 'primary_domain' => true, + 'secure' => false, + 'stage' => \WP_Ultimo\Database\Domains\Domain_Stage::DONE, + ) + ); + + $this->assertNotWPError($mapping); + + $_REQUEST = array( // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. + Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG => (string) $site_id, + '_wpnonce' => wp_create_nonce(Admin_Bar_Magic_Links::ADMIN_POST_ACTION . '_' . $site_id), + 'redirect_to' => 'https://attacker.example.test/', + ); + + $magic_link = 'https://admin-bar-magic-links.example.test/wp-admin/?wu_magic_token=test-token'; + $redirect = array(); + $redirect_to = ''; + $magic_link_filter = static function ($url, $filter_user_id, $filter_site_id, $filter_redirect_to) use (&$redirect_to, $magic_link) { + $redirect_to = $filter_redirect_to; + + return $magic_link; + }; + $redirect_filter = static function ($location, $status) use (&$redirect) { + $redirect = array( + 'location' => $location, + 'status' => $status, + ); + + throw new \RuntimeException('redirect_intercepted'); + }; + + add_filter('wu_magic_links_enabled', '__return_true'); + add_filter('wu_magic_link_url', $magic_link_filter, 10, 4); + add_filter('wp_redirect', $redirect_filter, 10, 2); + + try { + $this->magic_links->handle_admin_bar_magic_link(); + } catch (\RuntimeException $e) { + $this->assertSame('redirect_intercepted', $e->getMessage()); + } finally { + remove_filter('wu_magic_links_enabled', '__return_true'); + remove_filter('wu_magic_link_url', $magic_link_filter, 10); + remove_filter('wp_redirect', $redirect_filter, 10); + $_REQUEST = $request; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restores the request fixture. + } + + $this->assertSame($magic_link, $redirect['location']); + $this->assertSame(302, $redirect['status']); + $this->assertSame(get_admin_url($site_id), $redirect_to); + } + + /** + * Test invalid nonces and inaccessible sites are rejected by the handler. + */ + public function test_handle_admin_bar_magic_link_rejects_invalid_nonce_and_inaccessible_site(): void { + $user_id = self::factory()->user->create(); + $site_id = get_current_blog_id(); + $request = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restores the request fixture. + + add_user_to_blog($site_id, $user_id, 'administrator'); + wp_set_current_user($user_id); + + $die_handler = static function () { + return static function ($message) { + throw new \WPDieException(esc_html((string) $message)); + }; + }; + + add_filter('wp_die_handler', $die_handler, 1); + + try { + $_REQUEST = array( // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. + Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG => (string) $site_id, + '_wpnonce' => 'invalid-nonce', + ); + + try { + $this->magic_links->handle_admin_bar_magic_link(); + } catch (\WPDieException $e) { + $this->assertSame('The requested site link is invalid.', $e->getMessage()); + } + + $inaccessible_site_id = self::factory()->blog->create(); + $_REQUEST = array( // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. + Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG => (string) $inaccessible_site_id, + '_wpnonce' => wp_create_nonce(Admin_Bar_Magic_Links::ADMIN_POST_ACTION . '_' . $inaccessible_site_id), + ); + + try { + $this->magic_links->handle_admin_bar_magic_link(); + } catch (\WPDieException $e) { + $this->assertSame('You do not have permission to access this site.', $e->getMessage()); + } + } finally { + remove_filter('wp_die_handler', $die_handler, 1); + $_REQUEST = $request; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restores the request fixture. + } + } } From bc4f0f13f5b09208d870e37685389f4bd05e5bf3 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 10 Aug 2026 01:18:12 -0600 Subject: [PATCH 3/4] test(sso): strengthen admin bar redirect coverage --- tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php b/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php index 7acca5921..b2b30f595 100644 --- a/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php +++ b/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php @@ -239,8 +239,12 @@ public function test_handle_admin_bar_magic_link_redirects_to_resolved_mapped_do $magic_link = 'https://admin-bar-magic-links.example.test/wp-admin/?wu_magic_token=test-token'; $redirect = array(); $redirect_to = ''; - $magic_link_filter = static function ($url, $filter_user_id, $filter_site_id, $filter_redirect_to) use (&$redirect_to, $magic_link) { - $redirect_to = $filter_redirect_to; + $filter_user_id = 0; + $filter_site_id = 0; + $magic_link_filter = static function ($url, $magic_link_user_id, $magic_link_site_id, $filter_redirect_to) use (&$filter_user_id, &$filter_site_id, &$redirect_to, $magic_link) { + $filter_user_id = $magic_link_user_id; + $filter_site_id = $magic_link_site_id; + $redirect_to = $filter_redirect_to; return $magic_link; }; @@ -270,6 +274,8 @@ public function test_handle_admin_bar_magic_link_redirects_to_resolved_mapped_do $this->assertSame($magic_link, $redirect['location']); $this->assertSame(302, $redirect['status']); + $this->assertSame($user_id, $filter_user_id); + $this->assertSame($site_id, $filter_site_id); $this->assertSame(get_admin_url($site_id), $redirect_to); } @@ -300,6 +306,7 @@ public function test_handle_admin_bar_magic_link_rejects_invalid_nonce_and_inacc try { $this->magic_links->handle_admin_bar_magic_link(); + $this->fail('Expected the invalid nonce request to terminate via wp_die().'); } catch (\WPDieException $e) { $this->assertSame('The requested site link is invalid.', $e->getMessage()); } @@ -312,6 +319,7 @@ public function test_handle_admin_bar_magic_link_rejects_invalid_nonce_and_inacc try { $this->magic_links->handle_admin_bar_magic_link(); + $this->fail('Expected the inaccessible site request to terminate via wp_die().'); } catch (\WPDieException $e) { $this->assertSame('You do not have permission to access this site.', $e->getMessage()); } From 7eb939f1ad9884e64e28261ac0183e7e853e400d Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 10 Aug 2026 02:57:33 -0600 Subject: [PATCH 4/4] wip: isolate admin bar magic-link tests --- inc/sso/class-admin-bar-magic-links.php | 6 +- .../SSO/Admin_Bar_Magic_Links_Test.php | 70 +++++++++++-------- 2 files changed, 44 insertions(+), 32 deletions(-) diff --git a/inc/sso/class-admin-bar-magic-links.php b/inc/sso/class-admin-bar-magic-links.php index 379e18a03..b59369e7a 100644 --- a/inc/sso/class-admin-bar-magic-links.php +++ b/inc/sso/class-admin-bar-magic-links.php @@ -104,11 +104,11 @@ public function get_admin_bar_action_url($site_id) { $site_id = absint($site_id); return add_query_arg( - array( + [ 'action' => self::ADMIN_POST_ACTION, self::SITE_ID_QUERY_ARG => $site_id, '_wpnonce' => wp_create_nonce($this->get_admin_bar_nonce_action($site_id)), - ), + ], admin_url('admin-post.php') ); } @@ -145,7 +145,7 @@ public function handle_admin_bar_magic_link(): void { } $allow_destination_host = static function ($allowed_hosts, $host) use ($destination_host) { - if (strtolower($destination_host) === strtolower($host)) { + if (0 === strcasecmp($destination_host, $host)) { $allowed_hosts[] = $destination_host; } diff --git a/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php b/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php index b2b30f595..e249238b4 100644 --- a/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php +++ b/tests/WP_Ultimo/SSO/Admin_Bar_Magic_Links_Test.php @@ -78,22 +78,22 @@ public function test_modify_my_sites_menu_uses_lazy_action_urls(): void { $admin_bar = new \WP_Admin_Bar(); $admin_bar->initialize(); $admin_bar->add_node( - array( + [ 'id' => 'blog-' . $site_id . '-d', 'href' => 'https://example.test/original-dashboard', - ) + ] ); $admin_bar->add_node( - array( + [ 'id' => 'blog-' . $site_id . '-c', 'href' => 'https://example.test/original-site', - ) + ] ); $admin_bar->add_node( - array( + [ 'id' => 'blog-' . $site_id . '-d-extra', 'href' => 'https://example.test/malformed-dashboard', - ) + ] ); $generated_magic_links = 0; @@ -110,13 +110,17 @@ public function test_modify_my_sites_menu_uses_lazy_action_urls(): void { $dashboard_node = $admin_bar->get_node('blog-' . $site_id . '-d'); $site_node = $admin_bar->get_node('blog-' . $site_id . '-c'); $malformed_node = $admin_bar->get_node('blog-' . $site_id . '-d-extra'); - $action_args = array(); + $action_args = []; + $action_url = admin_url('admin-post.php'); wp_parse_str(wp_parse_url($dashboard_node->href, PHP_URL_QUERY), $action_args); $this->assertSame(Admin_Bar_Magic_Links::ADMIN_POST_ACTION, $action_args['action']); $this->assertSame((string) $site_id, $action_args[ Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG ]); $this->assertNotFalse(wp_verify_nonce($action_args['_wpnonce'], Admin_Bar_Magic_Links::ADMIN_POST_ACTION . '_' . $site_id)); + $this->assertSame(wp_parse_url($action_url, PHP_URL_SCHEME), wp_parse_url($dashboard_node->href, PHP_URL_SCHEME)); + $this->assertSame(wp_parse_url($action_url, PHP_URL_HOST), wp_parse_url($dashboard_node->href, PHP_URL_HOST)); + $this->assertSame(wp_parse_url($action_url, PHP_URL_PATH), wp_parse_url($dashboard_node->href, PHP_URL_PATH)); $this->assertSame(0, $generated_magic_links); $this->assertSame('https://example.test/original-site', $site_node->href); $this->assertSame('https://example.test/malformed-dashboard', $malformed_node->href); @@ -132,12 +136,18 @@ public function test_get_site_dashboard_url_rejects_inaccessible_and_missing_sit add_user_to_blog($site_id, $user_id, 'administrator'); wp_set_current_user($user_id); - $this->assertSame(wu_get_admin_url($site_id), $this->magic_links->get_site_dashboard_url($site_id)); - $this->assertFalse($this->magic_links->get_site_dashboard_url(999999)); + add_filter('wu_magic_links_enabled', '__return_false'); - $inaccessible_site_id = self::factory()->blog->create(); + try { + $this->assertSame(get_admin_url($site_id), $this->magic_links->get_site_dashboard_url($site_id)); + $this->assertFalse($this->magic_links->get_site_dashboard_url(999999)); + + $inaccessible_site_id = self::factory()->blog->create(); - $this->assertFalse($this->magic_links->get_site_dashboard_url($inaccessible_site_id)); + $this->assertFalse($this->magic_links->get_site_dashboard_url($inaccessible_site_id)); + } finally { + remove_filter('wu_magic_links_enabled', '__return_false'); + } } /** @@ -153,8 +163,8 @@ public function test_request_values_reject_arrays(): void { $nonce_method->setAccessible(true); } - $_REQUEST[ Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG ] = array('invalid'); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tests malformed request input. - $_REQUEST['_wpnonce'] = array('invalid'); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tests malformed request input. + $_REQUEST[ Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG ] = ['invalid']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tests malformed request input. + $_REQUEST['_wpnonce'] = ['invalid']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tests malformed request input. try { $this->assertFalse($site_id_method->invoke($this->magic_links)); @@ -175,22 +185,23 @@ public function test_handle_admin_bar_magic_link_redirects_to_resolved_same_doma add_user_to_blog($site_id, $user_id, 'administrator'); wp_set_current_user($user_id); - $_REQUEST = array( // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. + $_REQUEST = [ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG => (string) $site_id, '_wpnonce' => wp_create_nonce(Admin_Bar_Magic_Links::ADMIN_POST_ACTION . '_' . $site_id), 'redirect_to' => 'https://attacker.example.test/', - ); + ]; - $redirect = array(); + $redirect = []; $redirect_filter = static function ($location, $status) use (&$redirect) { - $redirect = array( + $redirect = [ 'location' => $location, 'status' => $status, - ); + ]; throw new \RuntimeException('redirect_intercepted'); }; + add_filter('wu_magic_links_enabled', '__return_false'); add_filter('wp_redirect', $redirect_filter, 10, 2); try { @@ -198,6 +209,7 @@ public function test_handle_admin_bar_magic_link_redirects_to_resolved_same_doma } catch (\RuntimeException $e) { $this->assertSame('redirect_intercepted', $e->getMessage()); } finally { + remove_filter('wu_magic_links_enabled', '__return_false'); remove_filter('wp_redirect', $redirect_filter, 10); $_REQUEST = $request; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restores the request fixture. } @@ -218,26 +230,26 @@ public function test_handle_admin_bar_magic_link_redirects_to_resolved_mapped_do wp_set_current_user($user_id); $mapping = wu_create_domain( - array( + [ 'blog_id' => $site_id, 'domain' => 'admin-bar-magic-links.example.test', 'active' => true, 'primary_domain' => true, 'secure' => false, 'stage' => \WP_Ultimo\Database\Domains\Domain_Stage::DONE, - ) + ] ); $this->assertNotWPError($mapping); - $_REQUEST = array( // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. + $_REQUEST = [ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG => (string) $site_id, '_wpnonce' => wp_create_nonce(Admin_Bar_Magic_Links::ADMIN_POST_ACTION . '_' . $site_id), 'redirect_to' => 'https://attacker.example.test/', - ); + ]; $magic_link = 'https://admin-bar-magic-links.example.test/wp-admin/?wu_magic_token=test-token'; - $redirect = array(); + $redirect = []; $redirect_to = ''; $filter_user_id = 0; $filter_site_id = 0; @@ -249,10 +261,10 @@ public function test_handle_admin_bar_magic_link_redirects_to_resolved_mapped_do return $magic_link; }; $redirect_filter = static function ($location, $status) use (&$redirect) { - $redirect = array( + $redirect = [ 'location' => $location, 'status' => $status, - ); + ]; throw new \RuntimeException('redirect_intercepted'); }; @@ -299,10 +311,10 @@ public function test_handle_admin_bar_magic_link_rejects_invalid_nonce_and_inacc add_filter('wp_die_handler', $die_handler, 1); try { - $_REQUEST = array( // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. + $_REQUEST = [ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG => (string) $site_id, '_wpnonce' => 'invalid-nonce', - ); + ]; try { $this->magic_links->handle_admin_bar_magic_link(); @@ -312,10 +324,10 @@ public function test_handle_admin_bar_magic_link_rejects_invalid_nonce_and_inacc } $inaccessible_site_id = self::factory()->blog->create(); - $_REQUEST = array( // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. + $_REQUEST = [ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Sets the handler request fixture. Admin_Bar_Magic_Links::SITE_ID_QUERY_ARG => (string) $inaccessible_site_id, '_wpnonce' => wp_create_nonce(Admin_Bar_Magic_Links::ADMIN_POST_ACTION . '_' . $inaccessible_site_id), - ); + ]; try { $this->magic_links->handle_admin_bar_magic_link();