Skip to content

Commit 6c0e7b8

Browse files
authored
fix(sso): lazily generate admin-bar magic links (#1712)
* fix(sso): lazily generate admin-bar magic links * test(sso): cover admin bar redirect handler * test(sso): strengthen admin bar redirect coverage * wip: isolate admin bar magic-link tests
1 parent 30e57a8 commit 6c0e7b8

4 files changed

Lines changed: 470 additions & 14 deletions

File tree

inc/sso/class-admin-bar-magic-links.php

Lines changed: 166 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ class Admin_Bar_Magic_Links {
2222

2323
use \WP_Ultimo\Traits\Singleton;
2424

25+
/**
26+
* Admin-post action used to lazily resolve a dashboard link.
27+
*
28+
* @since 2.0.0
29+
* @var string
30+
*/
31+
const ADMIN_POST_ACTION = 'wu_admin_bar_magic_link';
32+
33+
/**
34+
* Query argument containing the requested site ID.
35+
*
36+
* @since 2.0.0
37+
* @var string
38+
*/
39+
const SITE_ID_QUERY_ARG = 'wu_site_id';
40+
2541
/**
2642
* Initialize hooks.
2743
*
@@ -33,6 +49,9 @@ public function init(): void {
3349
// Hook late to modify the URLs after WordPress core adds them.
3450
add_action('admin_bar_menu', array($this, 'modify_my_sites_menu'), 999);
3551

52+
// Resolve dashboard URLs only after the user selects a site.
53+
add_action('admin_post_' . self::ADMIN_POST_ACTION, array($this, 'handle_admin_bar_magic_link'));
54+
3655
// Hook early into admin_page_access_denied to show magic links.
3756
add_action('admin_page_access_denied', array($this, 'show_access_denied_with_magic_links'), 5);
3857
}
@@ -42,7 +61,8 @@ public function init(): void {
4261
*
4362
* This function hooks into the admin bar after WordPress core has
4463
* added all the My Sites menu items, and replaces dashboard URLs
45-
* with magic links for sites that have custom domains.
64+
* with same-origin lazy redirect URLs. Magic links are generated only
65+
* after the user selects a dashboard link.
4666
*
4767
* @since 2.0.0
4868
*
@@ -56,27 +76,160 @@ public function modify_my_sites_menu($wp_admin_bar): void {
5676
return;
5777
}
5878

59-
// Process each node.
79+
// Process each dashboard node without resolving its destination.
6080
foreach ($wp_admin_bar->get_nodes() as $node) {
61-
$parts = explode('-', $node->id);
62-
if (count($parts) >= 3 && 'blog' === $parts[0] && is_numeric($parts[1]) && 'd' === $parts[2]) {
63-
$site_id = (int) $parts[1];
64-
} else {
81+
if ( ! preg_match('/^blog-(\d+)-d$/', $node->id, $matches)) {
6582
continue;
6683
}
6784

68-
// Generate magic link.
69-
$magic_link = wu_get_admin_url($site_id);
85+
$site_id = (int) $matches[1];
7086

71-
if ( ! $magic_link ) {
72-
continue;
87+
// Keep the click on this authenticated origin until it is validated.
88+
$node->href = $this->get_admin_bar_action_url($site_id);
89+
90+
$wp_admin_bar->add_node($node);
91+
}
92+
}
93+
94+
/**
95+
* Build the same-origin action URL for a site's dashboard link.
96+
*
97+
* @since 2.0.0
98+
*
99+
* @param int $site_id Site ID.
100+
* @return string
101+
*/
102+
public function get_admin_bar_action_url($site_id) {
103+
104+
$site_id = absint($site_id);
105+
106+
return add_query_arg(
107+
[
108+
'action' => self::ADMIN_POST_ACTION,
109+
self::SITE_ID_QUERY_ARG => $site_id,
110+
'_wpnonce' => wp_create_nonce($this->get_admin_bar_nonce_action($site_id)),
111+
],
112+
admin_url('admin-post.php')
113+
);
114+
}
115+
116+
/**
117+
* Resolve a selected dashboard link and redirect to its validated destination.
118+
*
119+
* @since 2.0.0
120+
* @return void
121+
*/
122+
public function handle_admin_bar_magic_link(): void {
123+
124+
if ( ! is_user_logged_in() ) {
125+
wp_die(esc_html__('You do not have permission to access this site.', 'ultimate-multisite'), 403);
126+
}
127+
128+
$site_id = $this->get_requested_site_id();
129+
$nonce = $this->get_requested_nonce();
130+
131+
if ( ! $site_id || ! $nonce || ! wp_verify_nonce($nonce, $this->get_admin_bar_nonce_action($site_id)) ) {
132+
wp_die(esc_html__('The requested site link is invalid.', 'ultimate-multisite'), 403);
133+
}
134+
135+
$destination = $this->get_site_dashboard_url($site_id);
136+
137+
if ( ! $destination ) {
138+
wp_die(esc_html__('You do not have permission to access this site.', 'ultimate-multisite'), 403);
139+
}
140+
141+
$destination_host = wp_parse_url($destination, PHP_URL_HOST);
142+
143+
if ( ! is_string($destination_host) || '' === $destination_host ) {
144+
wp_die(esc_html__('The requested site link is invalid.', 'ultimate-multisite'), 403);
145+
}
146+
147+
$allow_destination_host = static function ($allowed_hosts, $host) use ($destination_host) {
148+
if (0 === strcasecmp($destination_host, $host)) {
149+
$allowed_hosts[] = $destination_host;
73150
}
74151

75-
// Update the node with the magic link.
76-
$node->href = $magic_link;
152+
return $allowed_hosts;
153+
};
77154

78-
$wp_admin_bar->add_node($node);
155+
add_filter('allowed_redirect_hosts', $allow_destination_host, 100, 2);
156+
$redirected = wp_safe_redirect($destination, 302, 'Ultimate-Multisite');
157+
remove_filter('allowed_redirect_hosts', $allow_destination_host, 100);
158+
159+
if ( ! $redirected ) {
160+
wp_die(esc_html__('The requested site link is invalid.', 'ultimate-multisite'), 403);
161+
}
162+
163+
exit;
164+
}
165+
166+
/**
167+
* Return the verified dashboard destination for a site.
168+
*
169+
* @since 2.0.0
170+
*
171+
* @param int $site_id Site ID.
172+
* @return false|string
173+
*/
174+
public function get_site_dashboard_url($site_id) {
175+
176+
$site_id = absint($site_id);
177+
$site = get_site($site_id);
178+
179+
if (
180+
! $site instanceof \WP_Site
181+
|| $site->deleted
182+
|| $site->spam
183+
|| $site->archived
184+
|| (! is_super_admin() && ! is_user_member_of_blog(get_current_user_id(), $site_id))
185+
) {
186+
return false;
187+
}
188+
189+
return wu_get_admin_url($site_id);
190+
}
191+
192+
/**
193+
* Get the nonce action for a site's dashboard link.
194+
*
195+
* @since 2.0.0
196+
*
197+
* @param int $site_id Site ID.
198+
* @return string
199+
*/
200+
protected function get_admin_bar_nonce_action($site_id) {
201+
202+
return self::ADMIN_POST_ACTION . '_' . absint($site_id);
203+
}
204+
205+
/**
206+
* Get a validated site ID from the request.
207+
*
208+
* @since 2.0.0
209+
* @return false|int
210+
*/
211+
protected function get_requested_site_id() {
212+
213+
$site_id = wu_request(self::SITE_ID_QUERY_ARG);
214+
215+
if ( ! is_string($site_id) || ! ctype_digit($site_id) || ! absint($site_id) ) {
216+
return false;
79217
}
218+
219+
return absint($site_id);
220+
}
221+
222+
/**
223+
* Get a nonce string from the request.
224+
*
225+
* @since 2.0.0
226+
* @return false|string
227+
*/
228+
protected function get_requested_nonce() {
229+
230+
$nonce = wu_request('_wpnonce');
231+
232+
return is_string($nonce) ? $nonce : false;
80233
}
81234

82235
/**

inc/sso/class-magic-link.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ protected function verify_user_site_access($user_id, $site_id) {
236236
return false;
237237
}
238238

239-
if (is_user_member_of_blog($user_id, $site_id)) {
239+
if (is_super_admin($user_id) || is_user_member_of_blog($user_id, $site_id)) {
240240
return true;
241241
}
242242
// Check if the site is the dashboard site in WP Frontend Admin which the user would not be a member of.

0 commit comments

Comments
 (0)