| 1 |
<?php |
| 2 |
/** |
| 3 |
* MCP authentication — the permission_callback for MCP-originated REST |
| 4 |
* calls proxied by the hosted broker (api.xspeedcache.com). |
| 5 |
* |
| 6 |
* The Free plugin's REST routes all gate on |
| 7 |
* `current_user_can( 'manage_options' )` + a WP nonce |
| 8 |
* (class-rest-api.php), and Rest_Manager::wrap_permission() forces that |
| 9 |
* cap check as an always-on gate a module can only make stricter. MCP |
| 10 |
* calls arrive server-to-server from the broker with NO logged-in user, |
| 11 |
* so they can't use that path. |
| 12 |
* |
| 13 |
* Instead the broker presents the per-site `site_token` in the |
| 14 |
* `X-XSpeed-MCP-Token` header on every proxied call. This class |
| 15 |
* validates that token (constant-time) against the value stored by the |
| 16 |
* pairing flow. Routes wired to Mcp_Auth::permission() are registered |
| 17 |
* DIRECTLY by McpModule::boot() (not via Rest_Manager) precisely so they |
| 18 |
* can bypass the forced cap check. |
| 19 |
* |
| 20 |
* Security contract: |
| 21 |
* - The token is a 32-byte random secret minted at connect time. |
| 22 |
* - Comparison is constant-time (hash_equals) to avoid timing oracles. |
| 23 |
* - A missing/empty stored token means "not connected" → always deny. |
| 24 |
* - This is an ADDITIONAL credential surface exposed only on Pro and |
| 25 |
* only after an admin explicitly connects; it never weakens the |
| 26 |
* existing admin-only routes. |
| 27 |
* |
| 28 |
* @package XSpeed |
| 29 |
*/ |
| 30 |
|
| 31 |
declare(strict_types=1); |
| 32 |
|
| 33 |
namespace XSpeed\Modules\Mcp; |
| 34 |
|
| 35 |
defined( 'ABSPATH' ) || exit; |
| 36 |
|
| 37 |
final class Mcp_Auth { |
| 38 |
|
| 39 |
/** Header the broker sends the site token in. */ |
| 40 |
public const TOKEN_HEADER = 'X-XSpeed-MCP-Token'; |
| 41 |
|
| 42 |
/** |
| 43 |
* The permission_callback for MCP tool routes. |
| 44 |
* |
| 45 |
* Accepts the request iff a valid `site_token` is presented in the |
| 46 |
* X-XSpeed-MCP-Token header. Does NOT fall through to |
| 47 |
* current_user_can — these routes are token-only by design. |
| 48 |
* |
| 49 |
* @param \WP_REST_Request $request Incoming request. |
| 50 |
* @return bool|\WP_Error True when authorized, WP_Error(401) otherwise. |
| 51 |
*/ |
| 52 |
public static function permission( \WP_REST_Request $request ) { |
| 53 |
// Locked-out IPs are rejected before any token comparison. |
| 54 |
if ( Mcp_Rate_Limiter::is_locked() ) { |
| 55 |
return new \WP_Error( |
| 56 |
'xspeed_mcp_rate_limited', |
| 57 |
__( 'Too many failed attempts. Try again later.', 'xspeed' ), |
| 58 |
array( 'status' => 429 ) |
| 59 |
); |
| 60 |
} |
| 61 |
|
| 62 |
$presented = self::extract_token( $request ); |
| 63 |
|
| 64 |
if ( '' === $presented ) { |
| 65 |
Mcp_Rate_Limiter::record_failure(); |
| 66 |
return self::unauthorized( 'Missing MCP token.' ); |
| 67 |
} |
| 68 |
|
| 69 |
$stored = Mcp_Pairing::site_token(); |
| 70 |
|
| 71 |
if ( '' === $stored ) { |
| 72 |
// Not connected — nothing to authenticate against. |
| 73 |
return self::unauthorized( 'This site is not connected to xSpeed MCP.' ); |
| 74 |
} |
| 75 |
|
| 76 |
if ( ! hash_equals( $stored, $presented ) ) { |
| 77 |
Mcp_Rate_Limiter::record_failure(); |
| 78 |
return self::unauthorized( 'Invalid MCP token.' ); |
| 79 |
} |
| 80 |
|
| 81 |
Mcp_Rate_Limiter::clear(); |
| 82 |
return true; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Read the site token from the request header. Trimmed; empty string |
| 87 |
* when absent. |
| 88 |
* |
| 89 |
* @param \WP_REST_Request $request Incoming request. |
| 90 |
*/ |
| 91 |
private static function extract_token( \WP_REST_Request $request ): string { |
| 92 |
$header = $request->get_header( self::TOKEN_HEADER ); |
| 93 |
if ( ! is_string( $header ) ) { |
| 94 |
return ''; |
| 95 |
} |
| 96 |
return trim( $header ); |
| 97 |
} |
| 98 |
|
| 99 |
/** |
| 100 |
* Build a 401 WP_Error with the REST status attached so the broker |
| 101 |
* (and any direct caller) sees a proper HTTP 401. |
| 102 |
* |
| 103 |
* @param string $message Human-readable reason. |
| 104 |
* @return \WP_Error |
| 105 |
*/ |
| 106 |
private static function unauthorized( string $message ) { |
| 107 |
return new \WP_Error( |
| 108 |
'xspeed_mcp_unauthorized', |
| 109 |
$message, |
| 110 |
array( 'status' => 401 ) |
| 111 |
); |
| 112 |
} |
| 113 |
} |
| 114 |
|