PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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 1.1.3 1.1.4 1.1.5 1.1.6 All 31 releases
← All changes | includes/modules/Mcp/Mcp_OAuth.php +345 -13 1.0.9 → 1.3.2 View file →
@@ -52,11 +52,77 @@
52 52
53 53 /** Refresh-token lifetime (seconds) -- 30 days. */
54 54 private const REFRESH_TTL = 2592000;
55 55
56 - /** Scopes we advertise + honor. `mcp` is the umbrella scope MCP clients request. */
57 - private const SUPPORTED_SCOPES = array( 'mcp', 'read', 'write' );
56 + /**
57 + * Scopes we advertise + honor. `mcp` is the umbrella scope MCP clients
58 + * request (read+write). `configure` is an ADDITIONAL, opt-in scope that a
59 + * client must request explicitly to write credential/secret fields — it is
60 + * NOT implied by `mcp` or `write`, so credential writes stay off by default
61 + * on an ordinary connection. (#116)
62 + */
63 + private const SUPPORTED_SCOPES = array( 'mcp', 'read', 'write', 'configure' );
58 64
65 + /**
66 + * Resource bounds for dynamic client registration (RFC 7591).
67 + *
68 + * The register endpoint is public by design — that is what makes an MCP
69 + * client able to connect without an admin minting credentials first. What
70 + * was missing is a resource policy: every request appended a client to one
71 + * persistent option with no count, size, expiry or rate bound, so repeated
72 + * anonymous requests grew `xspeed_mcp_oauth` without limit. Every later
73 + * registration and OAuth operation then loaded and reserialized a larger
74 + * option, burning storage, memory and CPU until availability degraded.
75 + *
76 + * Codes, access tokens and refresh tokens were already pruned on expiry;
77 + * `clients` was the one collection retained forever.
78 + */
79 + private const MAX_CLIENTS = 100;
80 +
81 + /** Redirect URIs accepted per registration. */
82 + private const MAX_REDIRECT_URIS = 5;
83 +
84 + /** Longest accepted redirect URI, in bytes. */
85 + private const MAX_REDIRECT_URI_LEN = 2048;
86 +
87 + /** Longest accepted client_name, in bytes. */
88 + private const MAX_CLIENT_NAME_LEN = 200;
89 +
90 + /**
91 + * How long an UNUSED client survives. A client that never completes a
92 + * flow is almost always an abandoned or hostile registration, so it is
93 + * collected after this window. A client with a live code, access token or
94 + * refresh token is never collected on age — see prune_clients().
95 + */
96 + private const UNUSED_CLIENT_TTL = 86400;
97 +
98 + /** Registrations allowed per IP inside RATE_WINDOW. */
99 + private const RATE_MAX = 10;
100 +
101 + /** Window for the registration rate limit, in seconds. Fixed, not sliding. */
102 + private const RATE_WINDOW = 3600;
103 +
104 + /**
105 + * Fixed number of rate-limit counters.
106 + *
107 + * One transient per IP would let a distributed flood grow the options
108 + * TABLE without bound — the same CWE-770 shape this class exists to fix,
109 + * moved from the option value to the row count. Hashing the IP into a
110 + * fixed bucket space caps that at a constant, whatever the traffic.
111 + * (QA F2 on #254)
112 + */
113 + private const RATE_BUCKETS = 64;
114 +
115 + /**
116 + * True while a caller is between state() and its own save().
117 + *
118 + * state() persists a self-heal prune on read-only paths, but a caller that
119 + * is about to save() anyway would then write twice — and the second write
120 + * would be against a $state it had already mutated. Callers that mutate
121 + * set this so the read-side write stands down. (QA F1 on #254)
122 + */
123 + private static $writing = false;
124 +
59 125 // -- URLs ------------------------------------------------------------
60 126
61 127 /** Base site URL used as the OAuth issuer (no trailing slash). */
62 128 public static function issuer(): string {
@@ -134,12 +200,42 @@
134 200 * @param array<string,mixed> $body Parsed JSON registration request.
135 201 * @return array<string,mixed>|\WP_Error
136 202 */
137 203 public static function register_client( array $body ) {
138 - $redirect_uris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] )
139 - ? array_values( array_filter( array_map( 'strval', $body['redirect_uris'] ), array( self::class, 'is_valid_redirect_uri' ) ) )
204 + // CHECK the rate limit before any work — an over-limit caller must not
205 + // be able to make us read, mutate or reserialize the option at all.
206 + // The COUNT happens later, only once the payload has proven valid, so
207 + // a legitimate but buggy client sending malformed bodies is not locked
208 + // out for an hour over registrations that never stored anything.
209 + // (QA F3 on #254)
210 + if ( ! self::rate_limit_ok( false ) ) {
211 + return new \WP_Error(
212 + 'too_many_requests',
213 + __( 'Too many client registrations. Try again later.', 'xspeed' ),
214 + array( 'status' => 429 )
215 + );
216 + }
217 +
218 + $raw = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] )
219 + ? array_map( 'strval', $body['redirect_uris'] )
140 220 : array();
141 221
222 + // Cap the count before validating, so a huge array costs a count()
223 + // rather than a full validation pass.
224 + if ( count( $raw ) > self::MAX_REDIRECT_URIS ) {
225 + return new \WP_Error(
226 + 'invalid_redirect_uri',
227 + sprintf(
228 + /* translators: %d: maximum number of redirect URIs. */
229 + __( 'At most %d redirect_uris are allowed.', 'xspeed' ),
230 + self::MAX_REDIRECT_URIS
231 + ),
232 + array( 'status' => 400 )
233 + );
234 + }
235 +
236 + $redirect_uris = array_values( array_filter( $raw, array( self::class, 'is_valid_redirect_uri' ) ) );
237 +
142 238 if ( empty( $redirect_uris ) ) {
143 239 return new \WP_Error(
144 240 'invalid_redirect_uri',
145 241 __( 'At least one valid redirect_uri is required.', 'xspeed' ),
@@ -146,19 +242,64 @@
146 242 array( 'status' => 400 )
147 243 );
148 244 }
149 245
150 - $name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'MCP Client';
246 + $name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'MCP Client';
247 + if ( strlen( $name ) > self::MAX_CLIENT_NAME_LEN ) {
248 + // mb_strcut, NOT substr: the bound is in BYTES (that is what the
249 + // storage limit is about), but cutting at byte 200 lands inside a
250 + // multi-byte character for any CJK or emoji name — 200 is not a
251 + // multiple of 3 — and the result is invalid UTF-8. MySQL then
252 + // refuses the whole option write, so the registration was silently
253 + // dropped while the endpoint still answered 201 with a client_id
254 + // that had never been stored. mb_strcut keeps the byte budget and
255 + // never splits a character. (QA on #254)
256 + $name = function_exists( 'mb_strcut' )
257 + ? mb_strcut( $name, 0, self::MAX_CLIENT_NAME_LEN, 'UTF-8' )
258 + : substr( $name, 0, self::MAX_CLIENT_NAME_LEN );
259 + }
260 +
261 + // The payload is valid, so this request counts against the window.
262 + // Deliberately AFTER validation (F3) but BEFORE the option read below,
263 + // so an over-limit caller still cannot make us touch the option.
264 + self::rate_limit_ok( true );
265 +
151 266 $client_id = 'xsc_' . bin2hex( random_bytes( 16 ) );
152 267
153 - $state = self::state();
268 + // state() has already pruned unused/expired clients on load.
269 + $state = self::state_for_write();
270 +
271 + // Hard cap. Pruning above already dropped unused and expired
272 + // registrations, so hitting this means MAX_CLIENTS clients are
273 + // genuinely in use — refuse rather than evict a live one, which would
274 + // break a working integration to satisfy an anonymous caller.
275 + if ( count( $state['clients'] ) >= self::MAX_CLIENTS ) {
276 + return new \WP_Error(
277 + 'too_many_clients',
278 + __( 'Client registration limit reached.', 'xspeed' ),
279 + array( 'status' => 429 )
280 + );
281 + }
282 +
154 283 $state['clients'][ $client_id ] = array(
155 284 'redirect_uris' => $redirect_uris,
156 285 'name' => $name,
157 286 'created' => time(),
158 287 );
159 - self::save( $state );
160 288
289 + // A freshly-minted client_id always changes the option, so a false here
290 + // is a genuine write failure and never the "value unchanged" case. Fail
291 + // loudly: handing back a 201 and a client_id that was never stored
292 + // leaves the caller holding a credential that can never authorize, and
293 + // the only symptom is a confusing "Unknown client_id" much later.
294 + if ( ! self::save( $state ) ) {
295 + return new \WP_Error(
296 + 'registration_failed',
297 + __( 'The client registration could not be stored.', 'xspeed' ),
298 + array( 'status' => 500 )
299 + );
300 + }
301 +
161 302 return array(
162 303 'client_id' => $client_id,
163 304 'client_id_issued_at' => time(),
164 305 'redirect_uris' => $redirect_uris,
@@ -226,9 +367,9 @@
226 367 * @return string The authorization code.
227 368 */
228 369 public static function issue_code( array $req, int $user_id ): string {
229 370 $code = bin2hex( random_bytes( 32 ) );
230 - $state = self::state();
371 + $state = self::state_for_write();
231 372 $state['codes'][ $code ] = array(
232 373 'client_id' => $req['client_id'],
233 374 'redirect_uri' => $req['redirect_uri'],
234 375 'challenge' => $req['code_challenge'],
@@ -273,9 +414,9 @@
273 414 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
274 415 $redirect_uri = isset( $body['redirect_uri'] ) ? (string) $body['redirect_uri'] : '';
275 416 $verifier = isset( $body['code_verifier'] ) ? (string) $body['code_verifier'] : '';
276 417
277 - $state = self::state();
418 + $state = self::state_for_write();
278 419 if ( '' === $code || ! isset( $state['codes'][ $code ] ) ) {
279 420 return self::oauth_error( 'invalid_grant', 'Unknown or expired authorization code.' );
280 421 }
281 422 $entry = $state['codes'][ $code ];
@@ -311,9 +452,9 @@
311 452 private static function grant_refresh_token( array $body ) {
312 453 $refresh = isset( $body['refresh_token'] ) ? (string) $body['refresh_token'] : '';
313 454 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
314 455
315 - $state = self::state();
456 + $state = self::state_for_write();
316 457 $rhash = self::hash( $refresh );
317 458 if ( '' === $refresh || ! isset( $state['refresh'][ $rhash ] ) ) {
318 459 return self::oauth_error( 'invalid_grant', 'Unknown refresh token.' );
319 460 }
@@ -346,9 +487,9 @@
346 487 $refresh = bin2hex( random_bytes( 32 ) );
347 488 $ahash = self::hash( $access );
348 489 $rhash = self::hash( $refresh );
349 490
350 - $state = self::state();
491 + $state = self::state_for_write();
351 492 $state['tokens'][ $ahash ] = array(
352 493 'client_id' => $client_id,
353 494 'scope' => $scope,
354 495 'user_id' => $user_id,
@@ -413,8 +554,19 @@
413 554 $parts = preg_split( '/\s+/', trim( $scope ) ) ?: array();
414 555 return ! in_array( 'write', $parts, true ) && ! in_array( 'mcp', $parts, true );
415 556 }
416 557
558 + /**
559 + * Whether a granted scope string may write credential/secret fields. Unlike
560 + * read/write, `configure` is never implied by the `mcp` umbrella — the
561 + * client must ask for it by name — so an ordinary read-write connection
562 + * cannot rewrite API tokens or passwords. (#116)
563 + */
564 + public static function scope_allows_configure( string $scope ): bool {
565 + $parts = preg_split( '/\s+/', trim( $scope ) ) ?: array();
566 + return in_array( 'configure', $parts, true );
567 + }
568 +
417 569 /** Revoke every OAuth token + client (used by disconnect). */
418 570 public static function revoke_all(): void {
419 571 delete_option( self::OPTION );
420 572 }
@@ -454,14 +606,62 @@
454 606 if ( isset( $v['expires'] ) && $v['expires'] < $now ) {
455 607 unset( $state['refresh'][ $k ] );
456 608 }
457 609 }
610 +
611 + // Clients were the one collection this method never pruned, which is
612 + // what let an anonymous caller grow the option without bound. Prune
613 + // them here too, AFTER the grant buckets above, so "in use" is
614 + // decided against live grants only.
615 + $before = count( $state['clients'] );
616 + $state['clients'] = self::prune_clients( $state );
617 +
618 + // Persist when pruning ACTUALLY removed something. Pruning in memory
619 + // alone left a bloated option on disk until the next write happened to
620 + // land — so a site whose flood had stopped kept carrying the weight
621 + // indefinitely, which is not the self-heal this was described as.
622 + // (QA F1 on #254)
623 + //
624 + // Guarded on a real reduction, so the common case — nothing to prune —
625 + // stays a pure read and adds no write to an OAuth request. $writing
626 + // stops a caller that is about to save() anyway from writing twice.
627 + if ( ! self::$writing && count( $state['clients'] ) < $before ) {
628 + self::save( $state );
629 + }
630 +
458 631 return $state;
459 632 }
460 633
634 + /**
635 + * Load state for a caller that intends to mutate and save() it.
636 + *
637 + * Identical to state(), except it suppresses the read-side self-heal
638 + * write — the caller's own save() persists the same prune moments later,
639 + * so doing it here would write twice per request. (QA F1 on #254)
640 + *
641 + * @return array<string,array<string,mixed>>
642 + */
643 + private static function state_for_write(): array {
644 + self::$writing = true;
645 + try {
646 + return self::state();
647 + } finally {
648 + self::$writing = false;
649 + }
650 + }
651 +
461 652 /** Persist state (autoload off -- this is a hot-write, request-scoped option). */
462 - private static function save( array $state ): void {
463 - update_option( self::OPTION, $state, false );
653 + private static function save( array $state ): bool {
654 + // Report the outcome rather than discarding it. update_option() returns
655 + // false when the DB refuses the write — e.g. a value MySQL rejects as
656 + // invalid — and swallowing that let register_client() answer 201 with a
657 + // client_id it had never stored. A caller that mints a credential must
658 + // be able to tell a real write from a silent no-op. (QA on #254)
659 + //
660 + // Note update_option() also returns false when the value is UNCHANGED,
661 + // so this is "did not write", not "failed" — only callers that just
662 + // added something to $state may treat false as an error.
663 + return (bool) update_option( self::OPTION, $state, false );
464 664 }
465 665
466 666 /**
467 667 * Look up a registered client.
@@ -514,10 +714,142 @@
514 714 $uri = trim( $uri );
515 715 if ( '' === $uri ) {
516 716 return false;
517 717 }
718 + // Bound the length: without this a single registration could carry
719 + // multi-megabyte URIs straight into the stored option.
720 + if ( strlen( $uri ) > self::MAX_REDIRECT_URI_LEN ) {
721 + return false;
722 + }
518 723 // Allow standard web redirect URIs and native-client custom schemes.
519 724 return (bool) preg_match( '#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $uri );
725 + }
726 +
727 + /**
728 + * Drop registered clients that are neither in use nor recent.
729 + *
730 + * "In use" means the client still owns a live authorization code, access
731 + * token or refresh token — state() has already pruned the expired ones, so
732 + * whatever remains is genuinely live. Those are kept whatever their age; a
733 + * connected client must never be collected out from under a working
734 + * integration.
735 + *
736 + * Everything else is a registration that never completed a flow. Those are
737 + * kept for UNUSED_CLIENT_TTL so a slow but legitimate authorization can
738 + * finish, then collected.
739 + *
740 + * @param array<string,array<string,mixed>> $state Loaded state.
741 + * @return array<string,mixed> The surviving clients.
742 + */
743 + private static function prune_clients( array $state ): array {
744 + $clients = isset( $state['clients'] ) && is_array( $state['clients'] ) ? $state['clients'] : array();
745 + if ( empty( $clients ) ) {
746 + return array();
747 + }
748 +
749 + // Which client ids still hold live grants?
750 + $in_use = array();
751 + foreach ( array( 'codes', 'tokens', 'refresh' ) as $bucket ) {
752 + if ( empty( $state[ $bucket ] ) || ! is_array( $state[ $bucket ] ) ) {
753 + continue;
754 + }
755 + foreach ( $state[ $bucket ] as $entry ) {
756 + if ( is_array( $entry ) && ! empty( $entry['client_id'] ) ) {
757 + $in_use[ (string) $entry['client_id'] ] = true;
758 + }
759 + }
760 + }
761 +
762 + $cutoff = time() - self::UNUSED_CLIENT_TTL;
763 + foreach ( $clients as $id => $client ) {
764 + if ( isset( $in_use[ (string) $id ] ) ) {
765 + continue;
766 + }
767 + $created = ( is_array( $client ) && isset( $client['created'] ) ) ? (int) $client['created'] : 0;
768 + if ( $created < $cutoff ) {
769 + unset( $clients[ $id ] );
770 + }
771 + }
772 +
773 + return $clients;
774 + }
775 +
776 + /**
777 + * Per-IP sliding-window rate limit for dynamic client registration.
778 + *
779 + * Deliberately mirrors Mcp_Rate_Limiter's approach (transient counter
780 + * keyed on a hashed IP) rather than adding a dependency: that class gates
781 + * failed *authentication*, while this gates *creation* of state, and the
782 + * two must be tunable apart.
783 + *
784 + * Fails OPEN when the IP is unavailable — a proxy that hides REMOTE_ADDR
785 + * must not lock every client out of registering.
786 + */
787 + private static function rate_limit_ok( bool $count = true ): bool {
788 + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
789 + if ( '' === $ip ) {
790 + return true;
791 + }
792 +
793 + /**
794 + * Filter the dynamic-client-registration rate limit.
795 + *
796 + * @param int $max Registrations allowed per IP inside the window.
797 + */
798 + $max = (int) apply_filters( 'xspeed_mcp_register_rate_limit', self::RATE_MAX );
799 + if ( $max <= 0 ) {
800 + return true;
801 + }
802 +
803 + // Bucket the IP into a FIXED key space instead of one transient per
804 + // IP. Per-IP keys meant a distributed flood grew the options TABLE
805 + // without bound — the same CWE-770 shape as the bug this class fixes,
806 + // just moved from the option value to the row count. RATE_BUCKETS
807 + // caps it at a constant: 64 counters, whatever the traffic.
808 + // (QA F2 on #254)
809 + //
810 + // Collisions make the limit stricter for the colliding IPs, never
811 + // looser, so the bound still holds. With 64 buckets a handful of
812 + // unrelated clients may share a counter; that is the deliberate trade
813 + // for a storage ceiling, and RATE_MAX is generous enough to absorb it.
814 + $bucket = hexdec( substr( md5( $ip ), 0, 4 ) ) % self::RATE_BUCKETS;
815 + $key = 'xspeed_mcp_reg_' . $bucket;
816 +
817 + $entry = get_transient( $key );
818 + $now = time();
819 +
820 + // Window START is stored with the counter, so the window is genuinely
821 + // FIXED rather than extending on every hit. set_transient()'s TTL was
822 + // previously reset on each accepted request, which quietly turned
823 + // "10 per hour" into "10, then locked until an hour after your LAST
824 + // attempt". (QA F4 on #254)
825 + if ( ! is_array( $entry ) || ! isset( $entry['start'], $entry['count'] ) || ( $now - (int) $entry['start'] ) >= self::RATE_WINDOW ) {
826 + $entry = array(
827 + 'start' => $now,
828 + 'count' => 0,
829 + );
830 + }
831 +
832 + if ( (int) $entry['count'] >= $max ) {
833 + return false;
834 + }
835 +
836 + // CHECK-only mode writes nothing. The caller re-invokes with $count
837 + // true once the payload has proven valid, so a malformed request
838 + // costs the caller nothing — it never consumed a slot and never
839 + // touched the database. (QA F3 on #254)
840 + if ( ! $count ) {
841 + return true;
842 + }
843 +
844 + ++$entry['count'];
845 +
846 + // TTL covers only the REMAINDER of the current window, so the entry
847 + // expires when the window does instead of being pushed forward.
848 + $remaining = self::RATE_WINDOW - ( $now - (int) $entry['start'] );
849 + set_transient( $key, $entry, max( 1, $remaining ) );
850 +
851 + return true;
520 852 }
521 853
522 854 /**
523 855 * Build a WP_Error whose data carries an OAuth 2.0 `error` code so the