PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.3 1.1.4 1.1.5 All 32 releases
← All changes | includes/modules/Mcp/Mcp_OAuth.php +387 -24 1.1.5 → 1.3.6 View file →
@@ -8,12 +8,17 @@
8 8 * authorization-code + PKCE flow themselves. See the security contract and
9 9 * the end-to-end flow notes below.
10 10 *
11 11 * Flow: unauthenticated MCP call -> 401 + WWW-Authenticate (Mcp_Server) ->
12 - * client fetches /.well-known/oauth-protected-resource + oauth-authorization-
13 - * server -> dynamic registration (RFC 7591) -> /authorize (admin consent +
14 - * PKCE) -> /token (code + verifier -> access + refresh) -> MCP calls with
15 - * `Authorization: Bearer <access>` validated by validate_token().
12 + * client fetches /.well-known/oauth-protected-resource/xspeed/mcp +
13 + * /.well-known/oauth-authorization-server/xspeed/mcp -> dynamic registration
14 + * (RFC 7591) -> /authorize (admin consent + PKCE) -> /token (code + verifier
15 + * -> access + refresh) -> MCP calls with `Authorization: Bearer <access>`
16 + * validated by validate_token(). Both canonical identifiers -- issuer and
17 + * resource -- are the MCP endpoint URL, which is what puts the documents
18 + * under that path rather than at the contested site root (#266). The root
19 + * URLs are still answered, with the legacy host-only issuer, on sites where
20 + * no other plugin has claimed them -- see legacy_issuer().
16 21 *
17 22 * Security contract:
18 23 * - PKCE S256 REQUIRED (OAuth 2.1 public clients); codes are single-use,
19 24 * 60 s TTL, bound to client_id + redirect_uri + challenge.
@@ -61,15 +66,107 @@
61 66 * on an ordinary connection. (#116)
62 67 */
63 68 private const SUPPORTED_SCOPES = array( 'mcp', 'read', 'write', 'configure' );
64 69
70 + /**
71 + * Resource bounds for dynamic client registration (RFC 7591).
72 + *
73 + * The register endpoint is public by design — that is what makes an MCP
74 + * client able to connect without an admin minting credentials first. What
75 + * was missing is a resource policy: every request appended a client to one
76 + * persistent option with no count, size, expiry or rate bound, so repeated
77 + * anonymous requests grew `xspeed_mcp_oauth` without limit. Every later
78 + * registration and OAuth operation then loaded and reserialized a larger
79 + * option, burning storage, memory and CPU until availability degraded.
80 + *
81 + * Codes, access tokens and refresh tokens were already pruned on expiry;
82 + * `clients` was the one collection retained forever.
83 + */
84 + private const MAX_CLIENTS = 100;
85 +
86 + /** Redirect URIs accepted per registration. */
87 + private const MAX_REDIRECT_URIS = 5;
88 +
89 + /** Longest accepted redirect URI, in bytes. */
90 + private const MAX_REDIRECT_URI_LEN = 2048;
91 +
92 + /** Longest accepted client_name, in bytes. */
93 + private const MAX_CLIENT_NAME_LEN = 200;
94 +
95 + /**
96 + * How long an UNUSED client survives. A client that never completes a
97 + * flow is almost always an abandoned or hostile registration, so it is
98 + * collected after this window. A client with a live code, access token or
99 + * refresh token is never collected on age — see prune_clients().
100 + */
101 + private const UNUSED_CLIENT_TTL = 86400;
102 +
103 + /** Registrations allowed per IP inside RATE_WINDOW. */
104 + private const RATE_MAX = 10;
105 +
106 + /** Window for the registration rate limit, in seconds. Fixed, not sliding. */
107 + private const RATE_WINDOW = 3600;
108 +
109 + /**
110 + * Fixed number of rate-limit counters.
111 + *
112 + * One transient per IP would let a distributed flood grow the options
113 + * TABLE without bound — the same CWE-770 shape this class exists to fix,
114 + * moved from the option value to the row count. Hashing the IP into a
115 + * fixed bucket space caps that at a constant, whatever the traffic.
116 + * (QA F2 on #254)
117 + */
118 + private const RATE_BUCKETS = 64;
119 +
120 + /**
121 + * True while a caller is between state() and its own save().
122 + *
123 + * state() persists a self-heal prune on read-only paths, but a caller that
124 + * is about to save() anyway would then write twice — and the second write
125 + * would be against a $state it had already mutated. Callers that mutate
126 + * set this so the read-side write stands down. (QA F1 on #254)
127 + */
128 + private static $writing = false;
129 +
65 130 // -- URLs ------------------------------------------------------------
66 131
67 - /** Base site URL used as the OAuth issuer (no trailing slash). */
132 + /**
133 + * The OAuth issuer identifier -- the MCP endpoint URL, identical to
134 + * resource().
135 + *
136 + * RFC 8414 §2 allows an issuer to carry a path, and §3.1 then moves its
137 + * metadata to /.well-known/oauth-authorization-server/xspeed/mcp, a URL
138 + * only this plugin answers. A bare-host issuer put the document at the
139 + * site root, which every other MCP-serving plugin on the same site also
140 + * wants, and WordPress hands that URL to whichever rewrite rule happens
141 + * to sit first in the table.
142 + *
143 + * Nothing stored carries the issuer -- access and refresh tokens are
144 + * opaque random strings, client records hold redirect_uris/name/created
145 + * -- so changing it invalidates no grant. A client that re-discovers
146 + * simply registers again and asks the admin for consent once more.
147 + * (#266)
148 + */
68 149 public static function issuer(): string {
69 - return untrailingslashit( home_url() );
150 + return self::resource();
70 151 }
71 152
153 + /**
154 + * The host-only issuer earlier builds used, still served at the bare
155 + * /.well-known/oauth-* URLs when no other plugin has claimed them.
156 + *
157 + * RFC 8414 §3.3 makes a client reject a document whose `issuer` is not
158 + * the value it inserted into the URL it fetched, and a client that
159 + * fetched the ROOT document inserted nothing -- it derived that URL from
160 + * `https://site`. Stamping the path issuer there would be the same RFC
161 + * violation this change set out to remove, pointed the other way. So the
162 + * two locations carry two identities, each self-consistent, and a client
163 + * ends up on whichever one it asked for. (#266)
164 + */
165 + public static function legacy_issuer(): string {
166 + return untrailingslashit( home_url( '/' ) );
167 + }
168 +
72 169 /** The protected resource identifier -- the MCP endpoint URL. */
73 170 public static function resource(): string {
74 171 return Mcp_Pairing::site_endpoint();
75 172 }
@@ -80,17 +177,17 @@
80 177 * round-trip — a REST route would see the cookie without a nonce and
81 178 * treat the admin as logged-out, looping back to login.
82 179 */
83 180 public static function authorize_url(): string {
84 - return home_url( '/xspeed/authorize' );
181 + return Mcp_Pairing::absolute( home_url( '/xspeed/authorize' ) );
85 182 }
86 183
87 184 public static function token_url(): string {
88 - return rest_url( 'xspeed/v1/mcp/oauth/token' );
185 + return Mcp_Pairing::absolute( rest_url( 'xspeed/v1/mcp/oauth/token' ) );
89 186 }
90 187
91 188 public static function register_url(): string {
92 - return rest_url( 'xspeed/v1/mcp/oauth/register' );
189 + return Mcp_Pairing::absolute( rest_url( 'xspeed/v1/mcp/oauth/register' ) );
93 190 }
94 191
95 192 // -- Discovery documents (RFC 8414 / RFC 9728) -----------------------
96 193
@@ -97,14 +194,20 @@
97 194 /**
98 195 * RFC 9728 protected-resource metadata -- tells the client which
99 196 * authorization server(s) protect the MCP endpoint (this site).
100 197 *
198 + * @param string|null $issuer The authorization server to name. Defaults
199 + * to the canonical path issuer; the root
200 + * /.well-known/ location passes the legacy
201 + * host-only one, so that the AS document a
202 + * client goes on to fetch is the one served
203 + * at the URL that issuer derives.
101 204 * @return array<string,mixed>
102 205 */
103 - public static function protected_resource_metadata(): array {
206 + public static function protected_resource_metadata( ?string $issuer = null ): array {
104 207 return array(
105 208 'resource' => self::resource(),
106 - 'authorization_servers' => array( self::issuer() ),
209 + 'authorization_servers' => array( $issuer ?? self::issuer() ),
107 210 'scopes_supported' => self::SUPPORTED_SCOPES,
108 211 'bearer_methods_supported' => array( 'header' ),
109 212 );
110 213 }
@@ -113,13 +216,18 @@
113 216 * RFC 8414 authorization-server metadata -- the endpoint map + the
114 217 * capabilities we actually implement (auth-code grant, PKCE S256,
115 218 * dynamic registration, refresh tokens).
116 219 *
220 + * @param string|null $issuer Which identity this copy of the document
221 + * speaks for; see protected_resource_metadata().
222 + * Every endpoint URL below is identical either
223 + * way, which is why a client that switches
224 + * identities keeps its cached endpoints.
117 225 * @return array<string,mixed>
118 226 */
119 - public static function authorization_server_metadata(): array {
227 + public static function authorization_server_metadata( ?string $issuer = null ): array {
120 228 return array(
121 - 'issuer' => self::issuer(),
229 + 'issuer' => $issuer ?? self::issuer(),
122 230 'authorization_endpoint' => self::authorize_url(),
123 231 'token_endpoint' => self::token_url(),
124 232 'registration_endpoint' => self::register_url(),
125 233 'scopes_supported' => self::SUPPORTED_SCOPES,
@@ -140,12 +248,42 @@
140 248 * @param array<string,mixed> $body Parsed JSON registration request.
141 249 * @return array<string,mixed>|\WP_Error
142 250 */
143 251 public static function register_client( array $body ) {
144 - $redirect_uris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] )
145 - ? array_values( array_filter( array_map( 'strval', $body['redirect_uris'] ), array( self::class, 'is_valid_redirect_uri' ) ) )
252 + // CHECK the rate limit before any work — an over-limit caller must not
253 + // be able to make us read, mutate or reserialize the option at all.
254 + // The COUNT happens later, only once the payload has proven valid, so
255 + // a legitimate but buggy client sending malformed bodies is not locked
256 + // out for an hour over registrations that never stored anything.
257 + // (QA F3 on #254)
258 + if ( ! self::rate_limit_ok( false ) ) {
259 + return new \WP_Error(
260 + 'too_many_requests',
261 + __( 'Too many client registrations. Try again later.', 'xspeed' ),
262 + array( 'status' => 429 )
263 + );
264 + }
265 +
266 + $raw = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] )
267 + ? array_map( 'strval', $body['redirect_uris'] )
146 268 : array();
147 269
270 + // Cap the count before validating, so a huge array costs a count()
271 + // rather than a full validation pass.
272 + if ( count( $raw ) > self::MAX_REDIRECT_URIS ) {
273 + return new \WP_Error(
274 + 'invalid_redirect_uri',
275 + sprintf(
276 + /* translators: %d: maximum number of redirect URIs. */
277 + __( 'At most %d redirect_uris are allowed.', 'xspeed' ),
278 + self::MAX_REDIRECT_URIS
279 + ),
280 + array( 'status' => 400 )
281 + );
282 + }
283 +
284 + $redirect_uris = array_values( array_filter( $raw, array( self::class, 'is_valid_redirect_uri' ) ) );
285 +
148 286 if ( empty( $redirect_uris ) ) {
149 287 return new \WP_Error(
150 288 'invalid_redirect_uri',
151 289 __( 'At least one valid redirect_uri is required.', 'xspeed' ),
@@ -152,19 +290,64 @@
152 290 array( 'status' => 400 )
153 291 );
154 292 }
155 293
156 - $name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'MCP Client';
294 + $name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'MCP Client';
295 + if ( strlen( $name ) > self::MAX_CLIENT_NAME_LEN ) {
296 + // mb_strcut, NOT substr: the bound is in BYTES (that is what the
297 + // storage limit is about), but cutting at byte 200 lands inside a
298 + // multi-byte character for any CJK or emoji name — 200 is not a
299 + // multiple of 3 — and the result is invalid UTF-8. MySQL then
300 + // refuses the whole option write, so the registration was silently
301 + // dropped while the endpoint still answered 201 with a client_id
302 + // that had never been stored. mb_strcut keeps the byte budget and
303 + // never splits a character. (QA on #254)
304 + $name = function_exists( 'mb_strcut' )
305 + ? mb_strcut( $name, 0, self::MAX_CLIENT_NAME_LEN, 'UTF-8' )
306 + : substr( $name, 0, self::MAX_CLIENT_NAME_LEN );
307 + }
308 +
309 + // The payload is valid, so this request counts against the window.
310 + // Deliberately AFTER validation (F3) but BEFORE the option read below,
311 + // so an over-limit caller still cannot make us touch the option.
312 + self::rate_limit_ok( true );
313 +
157 314 $client_id = 'xsc_' . bin2hex( random_bytes( 16 ) );
158 315
159 - $state = self::state();
316 + // state() has already pruned unused/expired clients on load.
317 + $state = self::state_for_write();
318 +
319 + // Hard cap. Pruning above already dropped unused and expired
320 + // registrations, so hitting this means MAX_CLIENTS clients are
321 + // genuinely in use — refuse rather than evict a live one, which would
322 + // break a working integration to satisfy an anonymous caller.
323 + if ( count( $state['clients'] ) >= self::MAX_CLIENTS ) {
324 + return new \WP_Error(
325 + 'too_many_clients',
326 + __( 'Client registration limit reached.', 'xspeed' ),
327 + array( 'status' => 429 )
328 + );
329 + }
330 +
160 331 $state['clients'][ $client_id ] = array(
161 332 'redirect_uris' => $redirect_uris,
162 333 'name' => $name,
163 334 'created' => time(),
164 335 );
165 - self::save( $state );
166 336
337 + // A freshly-minted client_id always changes the option, so a false here
338 + // is a genuine write failure and never the "value unchanged" case. Fail
339 + // loudly: handing back a 201 and a client_id that was never stored
340 + // leaves the caller holding a credential that can never authorize, and
341 + // the only symptom is a confusing "Unknown client_id" much later.
342 + if ( ! self::save( $state ) ) {
343 + return new \WP_Error(
344 + 'registration_failed',
345 + __( 'The client registration could not be stored.', 'xspeed' ),
346 + array( 'status' => 500 )
347 + );
348 + }
349 +
167 350 return array(
168 351 'client_id' => $client_id,
169 352 'client_id_issued_at' => time(),
170 353 'redirect_uris' => $redirect_uris,
@@ -232,9 +415,9 @@
232 415 * @return string The authorization code.
233 416 */
234 417 public static function issue_code( array $req, int $user_id ): string {
235 418 $code = bin2hex( random_bytes( 32 ) );
236 - $state = self::state();
419 + $state = self::state_for_write();
237 420 $state['codes'][ $code ] = array(
238 421 'client_id' => $req['client_id'],
239 422 'redirect_uri' => $req['redirect_uri'],
240 423 'challenge' => $req['code_challenge'],
@@ -279,9 +462,9 @@
279 462 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
280 463 $redirect_uri = isset( $body['redirect_uri'] ) ? (string) $body['redirect_uri'] : '';
281 464 $verifier = isset( $body['code_verifier'] ) ? (string) $body['code_verifier'] : '';
282 465
283 - $state = self::state();
466 + $state = self::state_for_write();
284 467 if ( '' === $code || ! isset( $state['codes'][ $code ] ) ) {
285 468 return self::oauth_error( 'invalid_grant', 'Unknown or expired authorization code.' );
286 469 }
287 470 $entry = $state['codes'][ $code ];
@@ -317,9 +500,9 @@
317 500 private static function grant_refresh_token( array $body ) {
318 501 $refresh = isset( $body['refresh_token'] ) ? (string) $body['refresh_token'] : '';
319 502 $client_id = isset( $body['client_id'] ) ? (string) $body['client_id'] : '';
320 503
321 - $state = self::state();
504 + $state = self::state_for_write();
322 505 $rhash = self::hash( $refresh );
323 506 if ( '' === $refresh || ! isset( $state['refresh'][ $rhash ] ) ) {
324 507 return self::oauth_error( 'invalid_grant', 'Unknown refresh token.' );
325 508 }
@@ -352,9 +535,9 @@
352 535 $refresh = bin2hex( random_bytes( 32 ) );
353 536 $ahash = self::hash( $access );
354 537 $rhash = self::hash( $refresh );
355 538
356 - $state = self::state();
539 + $state = self::state_for_write();
357 540 $state['tokens'][ $ahash ] = array(
358 541 'client_id' => $client_id,
359 542 'scope' => $scope,
360 543 'user_id' => $user_id,
@@ -471,14 +654,62 @@
471 654 if ( isset( $v['expires'] ) && $v['expires'] < $now ) {
472 655 unset( $state['refresh'][ $k ] );
473 656 }
474 657 }
658 +
659 + // Clients were the one collection this method never pruned, which is
660 + // what let an anonymous caller grow the option without bound. Prune
661 + // them here too, AFTER the grant buckets above, so "in use" is
662 + // decided against live grants only.
663 + $before = count( $state['clients'] );
664 + $state['clients'] = self::prune_clients( $state );
665 +
666 + // Persist when pruning ACTUALLY removed something. Pruning in memory
667 + // alone left a bloated option on disk until the next write happened to
668 + // land — so a site whose flood had stopped kept carrying the weight
669 + // indefinitely, which is not the self-heal this was described as.
670 + // (QA F1 on #254)
671 + //
672 + // Guarded on a real reduction, so the common case — nothing to prune —
673 + // stays a pure read and adds no write to an OAuth request. $writing
674 + // stops a caller that is about to save() anyway from writing twice.
675 + if ( ! self::$writing && count( $state['clients'] ) < $before ) {
676 + self::save( $state );
677 + }
678 +
475 679 return $state;
476 680 }
477 681
682 + /**
683 + * Load state for a caller that intends to mutate and save() it.
684 + *
685 + * Identical to state(), except it suppresses the read-side self-heal
686 + * write — the caller's own save() persists the same prune moments later,
687 + * so doing it here would write twice per request. (QA F1 on #254)
688 + *
689 + * @return array<string,array<string,mixed>>
690 + */
691 + private static function state_for_write(): array {
692 + self::$writing = true;
693 + try {
694 + return self::state();
695 + } finally {
696 + self::$writing = false;
697 + }
698 + }
699 +
478 700 /** Persist state (autoload off -- this is a hot-write, request-scoped option). */
479 - private static function save( array $state ): void {
480 - update_option( self::OPTION, $state, false );
701 + private static function save( array $state ): bool {
702 + // Report the outcome rather than discarding it. update_option() returns
703 + // false when the DB refuses the write — e.g. a value MySQL rejects as
704 + // invalid — and swallowing that let register_client() answer 201 with a
705 + // client_id it had never stored. A caller that mints a credential must
706 + // be able to tell a real write from a silent no-op. (QA on #254)
707 + //
708 + // Note update_option() also returns false when the value is UNCHANGED,
709 + // so this is "did not write", not "failed" — only callers that just
710 + // added something to $state may treat false as an error.
711 + return (bool) update_option( self::OPTION, $state, false );
481 712 }
482 713
483 714 /**
484 715 * Look up a registered client.
@@ -531,10 +762,142 @@
531 762 $uri = trim( $uri );
532 763 if ( '' === $uri ) {
533 764 return false;
534 765 }
766 + // Bound the length: without this a single registration could carry
767 + // multi-megabyte URIs straight into the stored option.
768 + if ( strlen( $uri ) > self::MAX_REDIRECT_URI_LEN ) {
769 + return false;
770 + }
535 771 // Allow standard web redirect URIs and native-client custom schemes.
536 772 return (bool) preg_match( '#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $uri );
773 + }
774 +
775 + /**
776 + * Drop registered clients that are neither in use nor recent.
777 + *
778 + * "In use" means the client still owns a live authorization code, access
779 + * token or refresh token — state() has already pruned the expired ones, so
780 + * whatever remains is genuinely live. Those are kept whatever their age; a
781 + * connected client must never be collected out from under a working
782 + * integration.
783 + *
784 + * Everything else is a registration that never completed a flow. Those are
785 + * kept for UNUSED_CLIENT_TTL so a slow but legitimate authorization can
786 + * finish, then collected.
787 + *
788 + * @param array<string,array<string,mixed>> $state Loaded state.
789 + * @return array<string,mixed> The surviving clients.
790 + */
791 + private static function prune_clients( array $state ): array {
792 + $clients = isset( $state['clients'] ) && is_array( $state['clients'] ) ? $state['clients'] : array();
793 + if ( empty( $clients ) ) {
794 + return array();
795 + }
796 +
797 + // Which client ids still hold live grants?
798 + $in_use = array();
799 + foreach ( array( 'codes', 'tokens', 'refresh' ) as $bucket ) {
800 + if ( empty( $state[ $bucket ] ) || ! is_array( $state[ $bucket ] ) ) {
801 + continue;
802 + }
803 + foreach ( $state[ $bucket ] as $entry ) {
804 + if ( is_array( $entry ) && ! empty( $entry['client_id'] ) ) {
805 + $in_use[ (string) $entry['client_id'] ] = true;
806 + }
807 + }
808 + }
809 +
810 + $cutoff = time() - self::UNUSED_CLIENT_TTL;
811 + foreach ( $clients as $id => $client ) {
812 + if ( isset( $in_use[ (string) $id ] ) ) {
813 + continue;
814 + }
815 + $created = ( is_array( $client ) && isset( $client['created'] ) ) ? (int) $client['created'] : 0;
816 + if ( $created < $cutoff ) {
817 + unset( $clients[ $id ] );
818 + }
819 + }
820 +
821 + return $clients;
822 + }
823 +
824 + /**
825 + * Per-IP sliding-window rate limit for dynamic client registration.
826 + *
827 + * Deliberately mirrors Mcp_Rate_Limiter's approach (transient counter
828 + * keyed on a hashed IP) rather than adding a dependency: that class gates
829 + * failed *authentication*, while this gates *creation* of state, and the
830 + * two must be tunable apart.
831 + *
832 + * Fails OPEN when the IP is unavailable — a proxy that hides REMOTE_ADDR
833 + * must not lock every client out of registering.
834 + */
835 + private static function rate_limit_ok( bool $count = true ): bool {
836 + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
837 + if ( '' === $ip ) {
838 + return true;
839 + }
840 +
841 + /**
842 + * Filter the dynamic-client-registration rate limit.
843 + *
844 + * @param int $max Registrations allowed per IP inside the window.
845 + */
846 + $max = (int) apply_filters( 'xspeed_mcp_register_rate_limit', self::RATE_MAX );
847 + if ( $max <= 0 ) {
848 + return true;
849 + }
850 +
851 + // Bucket the IP into a FIXED key space instead of one transient per
852 + // IP. Per-IP keys meant a distributed flood grew the options TABLE
853 + // without bound — the same CWE-770 shape as the bug this class fixes,
854 + // just moved from the option value to the row count. RATE_BUCKETS
855 + // caps it at a constant: 64 counters, whatever the traffic.
856 + // (QA F2 on #254)
857 + //
858 + // Collisions make the limit stricter for the colliding IPs, never
859 + // looser, so the bound still holds. With 64 buckets a handful of
860 + // unrelated clients may share a counter; that is the deliberate trade
861 + // for a storage ceiling, and RATE_MAX is generous enough to absorb it.
862 + $bucket = hexdec( substr( md5( $ip ), 0, 4 ) ) % self::RATE_BUCKETS;
863 + $key = 'xspeed_mcp_reg_' . $bucket;
864 +
865 + $entry = get_transient( $key );
866 + $now = time();
867 +
868 + // Window START is stored with the counter, so the window is genuinely
869 + // FIXED rather than extending on every hit. set_transient()'s TTL was
870 + // previously reset on each accepted request, which quietly turned
871 + // "10 per hour" into "10, then locked until an hour after your LAST
872 + // attempt". (QA F4 on #254)
873 + if ( ! is_array( $entry ) || ! isset( $entry['start'], $entry['count'] ) || ( $now - (int) $entry['start'] ) >= self::RATE_WINDOW ) {
874 + $entry = array(
875 + 'start' => $now,
876 + 'count' => 0,
877 + );
878 + }
879 +
880 + if ( (int) $entry['count'] >= $max ) {
881 + return false;
882 + }
883 +
884 + // CHECK-only mode writes nothing. The caller re-invokes with $count
885 + // true once the payload has proven valid, so a malformed request
886 + // costs the caller nothing — it never consumed a slot and never
887 + // touched the database. (QA F3 on #254)
888 + if ( ! $count ) {
889 + return true;
890 + }
891 +
892 + ++$entry['count'];
893 +
894 + // TTL covers only the REMAINDER of the current window, so the entry
895 + // expires when the window does instead of being pushed forward.
896 + $remaining = self::RATE_WINDOW - ( $now - (int) $entry['start'] );
897 + set_transient( $key, $entry, max( 1, $remaining ) );
898 +
899 + return true;
537 900 }
538 901
539 902 /**
540 903 * Build a WP_Error whose data carries an OAuth 2.0 `error` code so the