| 1 |
<?php |
| 2 |
/** |
| 3 |
* Connect from the Hub: the site side (xspeed-hub#307). |
| 4 |
* |
| 5 |
* A person types this site's URL into the Hub. The Hub asks connect_info() |
| 6 |
* whether the site supports it, then sends the browser to the consent page |
| 7 |
* here. An admin approves; we hand the browser back to the configured Hub |
| 8 |
* with a single-use code bound to the Hub's PKCE challenge. The Hub then |
| 9 |
* redeems the code, with its verifier, on /mcp/attach for this site's token. |
| 10 |
* |
| 11 |
* @package XSpeed |
| 12 |
*/ |
| 13 |
|
| 14 |
declare(strict_types=1); |
| 15 |
|
| 16 |
namespace XSpeed\Modules\Mcp; |
| 17 |
|
| 18 |
defined( 'ABSPATH' ) || exit; |
| 19 |
|
| 20 |
/** |
| 21 |
* Consent page, single-use codes and their redemption. |
| 22 |
* |
| 23 |
* Why a code plus PKCE rather than the attach nonce: the code travels in a |
| 24 |
* browser URL (history, referrer, logs). Alone it is worth nothing; the |
| 25 |
* verifier that unlocks it never leaves the Hub's server. |
| 26 |
* |
| 27 |
* Why the browser only ever goes back to Mcp_Hub::hub_url(): taking the |
| 28 |
* return address from the request would let anyone who can get an admin to |
| 29 |
* click a link send this site's code to a server of their choosing. |
| 30 |
*/ |
| 31 |
final class Mcp_Hub_Connect { |
| 32 |
|
| 33 |
/** The admin.php?page= slug of the consent screen. Not in any menu. */ |
| 34 |
public const PAGE_SLUG = 'xspeed-hub-connect'; |
| 35 |
|
| 36 |
/** Nonce action for the Approve / Deny form. */ |
| 37 |
private const NONCE_ACTION = 'xspeed_hub_connect'; |
| 38 |
|
| 39 |
/** Seconds a code stays redeemable. The Hub redeems it at once. */ |
| 40 |
private const CODE_TTL = 300; |
| 41 |
|
| 42 |
/** Transient prefix; the key is the code's SHA-256, never the code. */ |
| 43 |
private const CODE_KEY = 'xspeed_hubc_'; |
| 44 |
|
| 45 |
/** What the Hub reads before sending anyone here. */ |
| 46 |
public static function connect_info(): array { |
| 47 |
return array( |
| 48 |
'plugin' => 'xspeed', |
| 49 |
// Bumped if the consent or redemption contract changes shape. |
| 50 |
'hub_connect' => 1, |
| 51 |
'site_url' => Mcp_Hub::site_url_canonical(), |
| 52 |
'connect_url' => admin_url( 'admin.php?page=' . self::PAGE_SLUG ), |
| 53 |
); |
| 54 |
} |
| 55 |
|
| 56 |
/** A Hub `state`: opaque base64url, long enough to be unguessable. */ |
| 57 |
public static function valid_state( string $state ): bool { |
| 58 |
return 1 === preg_match( '/^[A-Za-z0-9_-]{32,128}$/', $state ); |
| 59 |
} |
| 60 |
|
| 61 |
/** An S256 challenge: base64url of a SHA-256, so exactly 43 characters. */ |
| 62 |
public static function valid_challenge( string $challenge ): bool { |
| 63 |
return 1 === preg_match( '/^[A-Za-z0-9_-]{43}$/', $challenge ); |
| 64 |
} |
| 65 |
|
| 66 |
/** The S256 challenge a verifier answers to (RFC 7636 §4.2). */ |
| 67 |
public static function challenge_for( string $verifier ): string { |
| 68 |
return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url of a hash, per RFC 7636. |
| 69 |
} |
| 70 |
|
| 71 |
/** |
| 72 |
* Issue a single-use code for the admin who approved. |
| 73 |
* |
| 74 |
* @param string $challenge The Hub's S256 challenge. |
| 75 |
* @param int $user_id The approving admin, recorded on redemption. |
| 76 |
*/ |
| 77 |
public static function issue_code( string $challenge, int $user_id ): string { |
| 78 |
$code = bin2hex( random_bytes( 32 ) ); |
| 79 |
set_transient( |
| 80 |
self::CODE_KEY . hash( 'sha256', $code ), |
| 81 |
array( |
| 82 |
'challenge' => $challenge, |
| 83 |
'user_id' => $user_id, |
| 84 |
'expires' => time() + self::CODE_TTL, |
| 85 |
), |
| 86 |
self::CODE_TTL |
| 87 |
); |
| 88 |
return $code; |
| 89 |
} |
| 90 |
|
| 91 |
/** |
| 92 |
* Redeem a code with its verifier. Single use: the code is gone after the |
| 93 |
* first attempt, right or wrong, so it cannot be guessed at. |
| 94 |
* |
| 95 |
* @return array{site_url:string,site_token:string,user_id:int}|null |
| 96 |
*/ |
| 97 |
public static function redeem_code( string $code, string $verifier ): ?array { |
| 98 |
if ( 1 !== preg_match( '/^[a-f0-9]{64}$/', $code ) ) { |
| 99 |
return null; |
| 100 |
} |
| 101 |
// RFC 7636 §4.1: 43–128 unreserved characters. |
| 102 |
if ( 1 !== preg_match( '/^[A-Za-z0-9._~-]{43,128}$/', $verifier ) ) { |
| 103 |
return null; |
| 104 |
} |
| 105 |
$key = self::CODE_KEY . hash( 'sha256', $code ); |
| 106 |
$entry = get_transient( $key ); |
| 107 |
delete_transient( $key ); |
| 108 |
if ( ! is_array( $entry ) || ! isset( $entry['challenge'], $entry['user_id'], $entry['expires'] ) ) { |
| 109 |
return null; |
| 110 |
} |
| 111 |
if ( (int) $entry['expires'] < time() ) { |
| 112 |
return null; |
| 113 |
} |
| 114 |
if ( ! hash_equals( (string) $entry['challenge'], self::challenge_for( $verifier ) ) ) { |
| 115 |
return null; |
| 116 |
} |
| 117 |
return Mcp_Hub::grant_attach_credential( (int) $entry['user_id'] ); |
| 118 |
} |
| 119 |
|
| 120 |
/** Where the browser goes back to on the Hub, with the outcome in the query. */ |
| 121 |
public static function return_url( array $args ): string { |
| 122 |
return Mcp_Hub::hub_url() . '/attach/return?' . http_build_query( $args ); |
| 123 |
} |
| 124 |
|
| 125 |
/** |
| 126 |
* Register the consent screen as a page with no menu entry. wp-admin |
| 127 |
* refuses an unregistered ?page= with a 403 before admin_init runs. |
| 128 |
*/ |
| 129 |
public static function register_page(): void { |
| 130 |
add_submenu_page( |
| 131 |
'', |
| 132 |
__( 'Connect to xSpeed Hub', 'xspeed' ), |
| 133 |
'', |
| 134 |
'manage_options', |
| 135 |
self::PAGE_SLUG, |
| 136 |
'__return_null' |
| 137 |
); |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Serve the consent page on admin_init, before admin chrome is sent. |
| 142 |
* |
| 143 |
* admin_init runs after wp-admin's auth_redirect(), so a signed-out |
| 144 |
* visitor has already been sent to wp-login and back by the time we look. |
| 145 |
*/ |
| 146 |
public static function maybe_handle_page(): void { |
| 147 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing only; the form below is nonce-checked. |
| 148 |
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; |
| 149 |
if ( self::PAGE_SLUG !== $page ) { |
| 150 |
return; |
| 151 |
} |
| 152 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 153 |
self::emit_page( |
| 154 |
__( 'Not allowed', 'xspeed' ), |
| 155 |
'<p>' . esc_html__( 'Only an administrator of this site can connect it to xSpeed Hub. Sign in as an administrator and start again from the Hub.', 'xspeed' ) . '</p>', |
| 156 |
403 |
| 157 |
); |
| 158 |
} |
| 159 |
|
| 160 |
$is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ); |
| 161 |
// phpcs:disable WordPress.Security.NonceVerification -- GET renders; POST is verified below before anything is acted on. |
| 162 |
$source = $is_post ? $_POST : $_GET; |
| 163 |
$state = isset( $source['state'] ) ? sanitize_text_field( wp_unslash( $source['state'] ) ) : ''; |
| 164 |
$challenge = isset( $source['code_challenge'] ) ? sanitize_text_field( wp_unslash( $source['code_challenge'] ) ) : ''; |
| 165 |
$method = isset( $source['code_challenge_method'] ) ? sanitize_text_field( wp_unslash( $source['code_challenge_method'] ) ) : 'S256'; |
| 166 |
$account = isset( $source['account'] ) ? sanitize_email( wp_unslash( $source['account'] ) ) : ''; |
| 167 |
// phpcs:enable |
| 168 |
|
| 169 |
if ( ! self::valid_state( $state ) || ! self::valid_challenge( $challenge ) || 'S256' !== $method ) { |
| 170 |
self::emit_page( |
| 171 |
__( 'This link is not valid', 'xspeed' ), |
| 172 |
'<p>' . esc_html__( 'Start again from xSpeed Hub: Add site, then Connect with WordPress.', 'xspeed' ) . '</p>', |
| 173 |
400 |
| 174 |
); |
| 175 |
} |
| 176 |
|
| 177 |
if ( $is_post ) { |
| 178 |
check_admin_referer( self::NONCE_ACTION ); |
| 179 |
$approved = ! empty( $_POST['approve'] ); |
| 180 |
$args = $approved |
| 181 |
? array( |
| 182 |
'state' => $state, |
| 183 |
'code' => self::issue_code( $challenge, get_current_user_id() ), |
| 184 |
'site_url' => Mcp_Hub::site_url_canonical(), |
| 185 |
) |
| 186 |
: array( |
| 187 |
'state' => $state, |
| 188 |
'error' => 'access_denied', |
| 189 |
); |
| 190 |
// Not wp_safe_redirect: the Hub is off-site by design. The address is |
| 191 |
// the configured hub_url(), never anything from this request. |
| 192 |
wp_redirect( self::return_url( $args ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- configured Hub URL, not request input. |
| 193 |
exit; |
| 194 |
} |
| 195 |
|
| 196 |
self::emit_consent( $state, $challenge, $account ); |
| 197 |
} |
| 198 |
|
| 199 |
/** The Approve / Deny screen. */ |
| 200 |
private static function emit_consent( string $state, string $challenge, string $account ): void { |
| 201 |
$hub_host = (string) wp_parse_url( Mcp_Hub::hub_url(), PHP_URL_HOST ); |
| 202 |
$site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); |
| 203 |
$user = wp_get_current_user(); |
| 204 |
|
| 205 |
$rows = '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( $site_host ) . '</span></div>'; |
| 206 |
$rows .= '<div class="row"><span>' . esc_html__( 'Connecting to', 'xspeed' ) . '</span><span>' . esc_html( $hub_host ) . '</span></div>'; |
| 207 |
if ( '' !== $account ) { |
| 208 |
$rows .= '<div class="row"><span>' . esc_html__( 'Hub account', 'xspeed' ) . '</span><span>' . esc_html( $account ) . '</span></div>'; |
| 209 |
} |
| 210 |
$rows .= '<div class="row"><span>' . esc_html__( 'Signed in as', 'xspeed' ) . '</span><span>' . esc_html( $user->user_login ) . '</span></div>'; |
| 211 |
$rows .= '<div class="row"><span>' . esc_html__( 'Access', 'xspeed' ) . '</span><span>' . esc_html__( 'Purge caches, change settings and run speed tests on this site.', 'xspeed' ) . '</span></div>'; |
| 212 |
|
| 213 |
$form = '<form method="post" action="' . esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ) . '">'; |
| 214 |
$form .= wp_nonce_field( self::NONCE_ACTION, '_wpnonce', true, false ); |
| 215 |
$form .= '<input type="hidden" name="state" value="' . esc_attr( $state ) . '" />'; |
| 216 |
$form .= '<input type="hidden" name="code_challenge" value="' . esc_attr( $challenge ) . '" />'; |
| 217 |
$form .= '<input type="hidden" name="code_challenge_method" value="S256" />'; |
| 218 |
$form .= '<div class="actions">'; |
| 219 |
$form .= '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'xspeed' ) . '</button>'; |
| 220 |
$form .= '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'xspeed' ) . '</button>'; |
| 221 |
$form .= '</div></form>'; |
| 222 |
|
| 223 |
self::emit_page( |
| 224 |
__( 'Connect this site to xSpeed Hub', 'xspeed' ), |
| 225 |
'<p class="sub">' . esc_html__( 'xSpeed Hub lets you and your AI tools manage this site’s cache alongside your other sites.', 'xspeed' ) . '</p>' . $rows . $form, |
| 226 |
200 |
| 227 |
); |
| 228 |
} |
| 229 |
|
| 230 |
/** |
| 231 |
* Emit a standalone page in the consent style and stop. |
| 232 |
* |
| 233 |
* @param string $title Plain text. |
| 234 |
* @param string $body Already escaped HTML. |
| 235 |
* @param int $status HTTP status. |
| 236 |
*/ |
| 237 |
private static function emit_page( string $title, string $body, int $status ): void { |
| 238 |
status_header( $status ); |
| 239 |
nocache_headers(); |
| 240 |
header( 'Content-Type: text/html; charset=utf-8' ); |
| 241 |
// The page carries a live nonce and a state; it must not be framed. |
| 242 |
header( 'X-Frame-Options: DENY' ); |
| 243 |
echo '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . esc_html( $title ) . '</title>'; |
| 244 |
echo '<style>' . McpModule::CONSENT_CSS . '</style></head><body><div class="card">'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet constant. |
| 245 |
echo '<h1>' . esc_html( $title ) . '</h1>'; |
| 246 |
echo $body; // phpcs:ignore WordPress.Security.EscapeOutput -- every part is escaped where it is built. |
| 247 |
echo '</div></body></html>'; |
| 248 |
exit; |
| 249 |
} |
| 250 |
} |
| 251 |
|