PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
xspeed / includes / modules / Mcp / Mcp_Hub_Connect.php

Mcp_Hub_Connect.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.0, at includes/modules/Mcp/Mcp_Hub_Connect.php

251 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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