| @@ -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 |