PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
← All changes | includes/mcp/class-mcp-pairing.php +170 -8 1.25.0 → 2.10.0 View file →
@@ -23,8 +23,10 @@
23 23 declare(strict_types=1);
24 24
25 25 namespace ThinkRank\Mcp;
26 26
27 +use ThinkRank\Core\Secret_At_Rest;
28 +
27 29 if ( ! defined( 'ABSPATH' ) ) {
28 30 exit; // Exit if accessed directly.
29 31 }
30 32
@@ -48,8 +50,14 @@
48 50 */
49 51 private const DEFAULT_SCOPES = [ 'read', 'write' ];
50 52
51 53 /**
54 + * Throttle window (seconds) for last-used writes — one option write per
55 + * minute at most, so a busy client can't hammer the option on every call.
56 + */
57 + private const LAST_USED_THROTTLE = 60;
58 +
59 + /**
52 60 * The PRIMARY endpoint the user pastes into their AI client — this
53 61 * site's own MCP URL.
54 62 *
55 63 * @return string
@@ -85,9 +93,9 @@
85 93
86 94 /**
87 95 * Current pairing state, defaults merged.
88 96 *
89 - * @return array{site_token:string,connected:bool,connected_at:int,scopes:string[],user_id:int}
97 + * @return array{site_token:string,token_hash:string,token_sealed:bool,connected:bool,connected_at:int,scopes:string[],user_id:int,last_used:int}
90 98 */
91 99 public static function state(): array {
92 100 $stored = get_option( self::OPTION, [] );
93 101 if ( ! is_array( $stored ) ) {
@@ -92,10 +100,33 @@
92 100 $stored = get_option( self::OPTION, [] );
93 101 if ( ! is_array( $stored ) ) {
94 102 $stored = [];
95 103 }
104 + $raw = isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '';
105 + $plain = '' === $raw ? '' : Secret_At_Rest::decrypt( $raw );
106 +
107 + // Sealed: something IS stored, but this site can no longer open it —
108 + // the auth salt rotated, or sodium went away under us (decrypt() hands
109 + // the envelope back unchanged in that case). Either way there is no
110 + // displayable credential, and the envelope must never be passed off as
111 + // one: it would be copied into a client and 401 forever.
112 + $sealed = '' !== $raw && ( '' === $plain || Secret_At_Rest::is_encrypted( $plain ) );
113 +
96 114 return [
97 - 'site_token' => isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '',
115 + // Decrypted for display and for the self-test's own probe. Stored
116 + // encrypted (#396) — a database read on its own no longer yields a
117 + // usable admin-equivalent credential.
118 + 'site_token' => $sealed ? '' : $plain,
119 + // Whether a stored token exists that cannot be shown here. Callers
120 + // use this to tell "never connected" apart from "connected, but
121 + // this site cannot display the token any more".
122 + 'token_sealed' => $sealed,
123 + // What authorize() compares against. Held separately so a token
124 + // whose ciphertext can no longer be opened — the auth salt was
125 + // rotated, the site was migrated without wp-config — keeps
126 + // authenticating the clients already configured with it, instead of
127 + // silently locking them out.
128 + 'token_hash' => isset( $stored['token_hash'] ) ? (string) $stored['token_hash'] : '',
98 129 'connected' => ! empty( $stored['connected'] ),
99 130 'connected_at' => isset( $stored['connected_at'] ) ? (int) $stored['connected_at'] : 0,
100 131 'scopes' => isset( $stored['scopes'] ) && is_array( $stored['scopes'] )
101 132 ? array_values( array_map( 'strval', $stored['scopes'] ) )
@@ -100,12 +131,104 @@
100 131 'scopes' => isset( $stored['scopes'] ) && is_array( $stored['scopes'] )
101 132 ? array_values( array_map( 'strval', $stored['scopes'] ) )
102 133 : [],
103 134 'user_id' => isset( $stored['user_id'] ) ? (int) $stored['user_id'] : 0,
135 + 'last_used' => isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0,
104 136 ];
105 137 }
106 138
107 139 /**
140 + * Record that the static token was just used to authenticate an MCP call.
141 + * Throttled to at most one option write per minute so a busy client can't
142 + * turn every request into a database write. No-op when not connected.
143 + *
144 + * @return void
145 + */
146 + public static function touch_last_used(): void {
147 + $stored = get_option( self::OPTION, [] );
148 + if ( ! is_array( $stored ) || empty( $stored['site_token'] ) ) {
149 + return;
150 + }
151 + $now = time();
152 + $last = isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0;
153 + if ( $now - $last < self::LAST_USED_THROTTLE ) {
154 + return;
155 + }
156 + $stored['last_used'] = $now;
157 + update_option( self::OPTION, $stored, false );
158 + }
159 +
160 + /**
161 + * SHA-256 used to store the pairing token's verifier at rest.
162 + *
163 + * Mirrors Mcp_OAuth::hash(), which has always stored access and refresh
164 + * tokens this way. The pairing token was the one exception (#396).
165 + *
166 + * @since 2.0.1
167 + *
168 + * @param string $value Raw token.
169 + * @return string
170 + */
171 + private static function hash( string $value ): string {
172 + return hash( 'sha256', $value );
173 + }
174 +
175 + /**
176 + * Whether a presented token is the pairing token.
177 + *
178 + * Compared against the stored hash. A row written before this change holds
179 + * a plaintext token and no hash, so it is verified against the plaintext
180 + * once and then upgraded in place — an existing pairing keeps working and
181 + * no one has to re-pair.
182 + *
183 + * @since 2.0.1
184 + *
185 + * @param string $presented Token presented by the client.
186 + * @return bool
187 + */
188 + public static function verify_token( string $presented ): bool {
189 + if ( '' === $presented ) {
190 + return false;
191 + }
192 +
193 + $state = self::state();
194 +
195 + if ( '' !== $state['token_hash'] ) {
196 + return hash_equals( $state['token_hash'], self::hash( $presented ) );
197 + }
198 +
199 + // Legacy row: plaintext, no hash.
200 + if ( '' === $state['site_token'] || ! hash_equals( $state['site_token'], $presented ) ) {
201 + return false;
202 + }
203 +
204 + self::upgrade_legacy_storage( $presented );
205 +
206 + return true;
207 + }
208 +
209 + /**
210 + * Re-store a legacy plaintext token encrypted, with its hash.
211 + *
212 + * @since 2.0.1
213 + *
214 + * @param string $token Raw token, already verified.
215 + * @return void
216 + */
217 + private static function upgrade_legacy_storage( string $token ): void {
218 + $stored = get_option( self::OPTION, [] );
219 +
220 + if ( ! is_array( $stored ) ) {
221 + return;
222 + }
223 +
224 + $stored['site_token'] = Secret_At_Rest::encrypt( $token );
225 + $stored['token_hash'] = self::hash( $token );
226 +
227 + update_option( self::OPTION, $stored, false );
228 + }
229 +
230 + /**
108 231 * The stored site token (secret). Empty string when not connected.
109 232 *
110 233 * @return string
111 234 */
@@ -124,13 +247,20 @@
124 247
125 248 /**
126 249 * Whether an MCP connection token is currently active for this site.
127 250 *
251 + * Deliberately reads the hash, not the decrypted token. Those are not the
252 + * same question: after an auth salt rotation the ciphertext will not open,
253 + * so `site_token` is '' — but `token_hash` still verifies the credential
254 + * every configured client is holding, and verify_token() still accepts it.
255 + * Answering "not connected" there made ensure_connected() mint a fresh
256 + * token over the hash, which was the only surviving copy of the live one.
257 + *
128 258 * @return bool
129 259 */
130 260 public static function is_connected(): bool {
131 261 $state = self::state();
132 - return $state['connected'] && '' !== $state['site_token'];
262 + return $state['connected'] && ( '' !== $state['token_hash'] || '' !== $state['site_token'] );
133 263 }
134 264
135 265 /**
136 266 * Whether the active connection is limited to read-only tools.
@@ -151,12 +281,16 @@
151 281 $state = self::state();
152 282 return [
153 283 'connected' => self::is_connected(),
154 284 'connection_token' => $state['site_token'],
285 + // Connected, but the token cannot be displayed on this site any
286 + // more. The screen offers a rotate instead of a blank recipe.
287 + 'token_sealed' => $state['token_sealed'],
155 288 'connect_url' => self::connect_url(),
156 289 'mcp_endpoint' => self::site_endpoint(),
157 290 'mcp_endpoint_rest' => self::site_endpoint_fallback(),
158 291 'connected_at' => $state['connected_at'],
292 + 'last_used' => $state['last_used'],
159 293 'scopes' => $state['scopes'],
160 294 'read_only' => self::is_read_only(),
161 295 // Ready-to-paste connection recipes (header-based — token stays out
162 296 // of the URL, so it can't leak into server/proxy logs).
@@ -264,19 +398,44 @@
264 398 * @param bool $read_only Grant only the `read` scope on a NEW token.
265 399 * @return array<string,mixed> Public status.
266 400 */
267 401 public static function connect( bool $read_only = false ): array {
268 - $state = self::state();
269 - $existing = '' !== $state['site_token'];
270 - $token = $existing ? $state['site_token'] : self::mint_token();
402 + $state = self::state();
403 + // The hash is what decides "is there a pairing", not the decrypted
404 + // token: after an auth salt rotation the ciphertext will not open, but
405 + // the credential every configured client holds still authenticates
406 + // against the hash.
407 + $existing = '' !== $state['token_hash'] || '' !== $state['site_token'];
271 408 $scopes = $existing && ! empty( $state['scopes'] )
272 409 ? $state['scopes']
273 410 : self::scopes_for( $read_only );
274 411
412 + if ( $existing && $state['token_sealed'] ) {
413 + // Keeping a pairing this site can no longer read. Falling through
414 + // would re-encrypt $state['site_token'] — which is '' here — and
415 + // write hash('') over token_hash, destroying the last copy of a
416 + // live credential and silently resetting its scopes and owner.
417 + // Touch only the metadata; rotate() is the deliberate re-mint.
418 + $stored = get_option( self::OPTION, [] );
419 + $stored = is_array( $stored ) ? $stored : [];
420 + $stored['connected'] = true;
421 + $stored['scopes'] = $scopes;
422 + if ( empty( $stored['user_id'] ) ) {
423 + $stored['user_id'] = get_current_user_id();
424 + }
425 +
426 + update_option( self::OPTION, $stored, false );
427 +
428 + return self::public_status();
429 + }
430 +
431 + $token = $existing ? $state['site_token'] : self::mint_token();
432 +
275 433 update_option(
276 434 self::OPTION,
277 435 [
278 - 'site_token' => $token,
436 + 'site_token' => Secret_At_Rest::encrypt( $token ),
437 + 'token_hash' => self::hash( $token ),
279 438 'connected' => true,
280 439 'connected_at' => $existing ? $state['connected_at'] : time(),
281 440 'scopes' => $scopes,
282 441 'user_id' => $existing && $state['user_id'] ? $state['user_id'] : get_current_user_id(),
@@ -299,12 +458,15 @@
299 458 $scopes = null === $read_only
300 459 ? ( ! empty( $state['scopes'] ) ? $state['scopes'] : self::DEFAULT_SCOPES )
301 460 : self::scopes_for( $read_only );
302 461
462 + $token = self::mint_token();
463 +
303 464 update_option(
304 465 self::OPTION,
305 466 [
306 - 'site_token' => self::mint_token(),
467 + 'site_token' => Secret_At_Rest::encrypt( $token ),
468 + 'token_hash' => self::hash( $token ),
307 469 'connected' => true,
308 470 'connected_at' => time(),
309 471 'scopes' => $scopes,
310 472 'user_id' => get_current_user_id() ? get_current_user_id() : $state['user_id'],