| @@ -65,8 +65,79 @@ | ||
| 65 | 65 | */ |
| 66 | 66 | const DEFAULT_TOOL_CALL_WINDOW = 60; |
| 67 | 67 | |
| 68 | 68 | /** |
| 69 | + * Failed-authentication attempts allowed per source within the window. | |
| 70 | + */ | |
| 71 | + const AUTH_FAILURE_LIMIT = 10; | |
| 72 | + | |
| 73 | + /** | |
| 74 | + * Failed-authentication window in seconds (per source and site-wide). | |
| 75 | + */ | |
| 76 | + const AUTH_FAILURE_WINDOW = 900; | |
| 77 | + | |
| 78 | + /** | |
| 79 | + * Site-wide failed-authentication ceiling within the window. | |
| 80 | + * | |
| 81 | + * Backstop for attackers who rotate their source address: the per-source | |
| 82 | + * bucket alone cannot hold them, this one can. | |
| 83 | + */ | |
| 84 | + const AUTH_FAILURE_SITE_LIMIT = 100; | |
| 85 | + | |
| 86 | + /** | |
| 87 | + * Upper bound on the escalated lockout, in seconds. | |
| 88 | + */ | |
| 89 | + const AUTH_FAILURE_MAX_LOCKOUT = 3600; | |
| 90 | + | |
| 91 | + /** | |
| 92 | + * Bucket name used for the site-wide failed-authentication counter. | |
| 93 | + */ | |
| 94 | + const AUTH_FAILURE_SITE_BUCKET = '__site__'; | |
| 95 | + | |
| 96 | + /** | |
| 97 | + * Prefix for failed-authentication counter transients. | |
| 98 | + */ | |
| 99 | + const AUTH_FAILURE_PREFIX = 'metasync_mcp_auth_'; | |
| 100 | + | |
| 101 | + /** | |
| 102 | + * Evaluate every failed-authentication bucket, including the site-wide one. | |
| 103 | + */ | |
| 104 | + const AUTH_SCOPE_ALL = 'all'; | |
| 105 | + | |
| 106 | + /** | |
| 107 | + * Evaluate only the caller's own bucket, ignoring the site-wide ceiling. | |
| 108 | + * | |
| 109 | + * Used for the pre-authentication gate: the site-wide ceiling must never | |
| 110 | + * decide whether a *valid* credential is accepted. | |
| 111 | + */ | |
| 112 | + const AUTH_SCOPE_SOURCE = 'source'; | |
| 113 | + | |
| 114 | + /** | |
| 115 | + * The request whose permission decision is memoised below. | |
| 116 | + * | |
| 117 | + * WordPress calls a route's permission_callback twice per request: once | |
| 118 | + * from WP_REST_Server::respond_to_request() to authorise the call, and | |
| 119 | + * again from rest_send_allow_header() on rest_post_dispatch to build the | |
| 120 | + * Allow header. A callback with side effects therefore fires twice, which | |
| 121 | + * would spend the caller's failure budget at double the documented rate. | |
| 122 | + * | |
| 123 | + * The request object itself is held rather than an spl_object_id: ids are | |
| 124 | + * recycled once an object is freed, so a later request could collide with | |
| 125 | + * a released one and silently inherit its decision. Keeping the reference | |
| 126 | + * makes identity comparison exact. | |
| 127 | + * | |
| 128 | + * @var WP_REST_Request|null | |
| 129 | + */ | |
| 130 | + private $permission_request = null; | |
| 131 | + | |
| 132 | + /** | |
| 133 | + * Memoised decision for $permission_request. | |
| 134 | + * | |
| 135 | + * @var bool|WP_Error|null | |
| 136 | + */ | |
| 137 | + private $permission_decision = null; | |
| 138 | + | |
| 139 | + /** | |
| 69 | 140 | * Constructor |
| 70 | 141 | */ |
| 71 | 142 | public function __construct() { |
| 72 | 143 | // Load dependencies |
| @@ -98,8 +169,9 @@ | ||
| 98 | 169 | */ |
| 99 | 170 | private function register_json_rpc_handlers() { |
| 100 | 171 | $this->json_rpc_handler->register_handler('tools/list', [$this, 'handle_tools_list']); |
| 101 | 172 | $this->json_rpc_handler->register_handler('tools/call', [$this, 'handle_tools_call']); |
| 173 | + $this->json_rpc_handler->register_handler('notifications/initialized', [$this, 'handle_initialized_notification']); | |
| 102 | 174 | } |
| 103 | 175 | |
| 104 | 176 | /** |
| 105 | 177 | * Register REST routes |
| @@ -132,15 +204,87 @@ | ||
| 132 | 204 | * @param WP_REST_Request $request Request object |
| 133 | 205 | * @return WP_REST_Response |
| 134 | 206 | */ |
| 135 | 207 | public function handle_rest_request($request) { |
| 136 | - $request_body = $request->get_body(); | |
| 208 | + $result = $this->process_json_rpc_request($request->get_body()); | |
| 209 | + $response = new WP_REST_Response($result['body'], $result['status']); | |
| 137 | 210 | |
| 211 | + foreach ($result['headers'] as $name => $value) { | |
| 212 | + $response->header($name, $value); | |
| 213 | + } | |
| 214 | + | |
| 215 | + return $response; | |
| 216 | + } | |
| 217 | + | |
| 218 | + /** | |
| 219 | + * Authenticate a PHP bridge and establish its rate-limit identity. | |
| 220 | + * | |
| 221 | + * HTTP bridges pass the request header. Stdio bridges pass the key from | |
| 222 | + * their private process environment. | |
| 223 | + * | |
| 224 | + * @param string|false $provided_key Bridge API key. | |
| 225 | + * @return true|WP_Error | |
| 226 | + */ | |
| 227 | + public function authenticate_bridge_request($provided_key) { | |
| 228 | + $this->authenticated_identity = null; | |
| 229 | + $configured_key = getenv('WP_MCP_API_KEY'); | |
| 230 | + | |
| 231 | + if (!is_string($configured_key) || $configured_key === '') { | |
| 232 | + return new WP_Error( | |
| 233 | + 'bridge_auth_not_configured', | |
| 234 | + 'MCP bridge authentication is not configured.', | |
| 235 | + ['status' => 401] | |
| 236 | + ); | |
| 237 | + } | |
| 238 | + | |
| 239 | + if (!is_string($provided_key) || !hash_equals($configured_key, $provided_key)) { | |
| 240 | + return new WP_Error( | |
| 241 | + 'bridge_authentication_failed', | |
| 242 | + 'Invalid or missing API key.', | |
| 243 | + ['status' => 401] | |
| 244 | + ); | |
| 245 | + } | |
| 246 | + | |
| 247 | + $this->authenticated_identity = [ | |
| 248 | + 'type' => 'api_key', | |
| 249 | + 'id' => hash('sha256', $configured_key), | |
| 250 | + ]; | |
| 251 | + | |
| 252 | + if (!defined('METASYNC_MCP_API_KEY_AUTH')) { | |
| 253 | + define('METASYNC_MCP_API_KEY_AUTH', true); | |
| 254 | + } | |
| 255 | + | |
| 256 | + return true; | |
| 257 | + } | |
| 258 | + | |
| 259 | + /** | |
| 260 | + * Process authenticated JSON-RPC consistently for every transport. | |
| 261 | + * | |
| 262 | + * @param string $request_body Raw JSON-RPC request body. | |
| 263 | + * @return array{body: array|null, status: int, headers: array} | |
| 264 | + */ | |
| 265 | + public function process_json_rpc_request($request_body) { | |
| 266 | + if ($this->authenticated_identity === null) { | |
| 267 | + return [ | |
| 268 | + 'body' => [ | |
| 269 | + 'jsonrpc' => '2.0', | |
| 270 | + 'id' => null, | |
| 271 | + 'error' => [ | |
| 272 | + 'code' => MCP_JSON_RPC_Handler::ERROR_SERVER_ERROR, | |
| 273 | + 'message' => 'Authentication required', | |
| 274 | + ], | |
| 275 | + ], | |
| 276 | + 'status' => 401, | |
| 277 | + 'headers' => [], | |
| 278 | + ]; | |
| 279 | + } | |
| 280 | + | |
| 138 | 281 | // Only rate-limit tools/call requests, not tools/list or other methods. |
| 139 | 282 | $decoded = json_decode($request_body, true); |
| 140 | 283 | $is_tool_call = is_array($decoded) |
| 141 | 284 | && isset($decoded['method']) |
| 142 | 285 | && $decoded['method'] === 'tools/call'; |
| 286 | + $is_notification = is_array($decoded) && !array_key_exists('id', $decoded); | |
| 143 | 287 | |
| 144 | 288 | if ($is_tool_call && $this->authenticated_identity !== null) { |
| 145 | 289 | $default_limits = [ |
| 146 | 290 | 'max' => self::DEFAULT_TOOL_CALL_LIMIT, |
| @@ -175,18 +319,21 @@ | ||
| 175 | 319 | ], |
| 176 | 320 | ], |
| 177 | 321 | ]; |
| 178 | 322 | |
| 179 | - $response = new WP_REST_Response($error_body, 429); | |
| 180 | - $response->header('Retry-After', (string) $retry_after); | |
| 181 | - return $response; | |
| 323 | + return [ | |
| 324 | + 'body' => $is_notification ? null : $error_body, | |
| 325 | + 'status' => 429, | |
| 326 | + 'headers' => ['Retry-After' => (string) $retry_after], | |
| 327 | + ]; | |
| 182 | 328 | } |
| 183 | 329 | } |
| 184 | 330 | |
| 185 | - // Process through JSON-RPC handler | |
| 186 | - $response = $this->json_rpc_handler->handle_request($request_body); | |
| 187 | - | |
| 188 | - return new WP_REST_Response($response, 200); | |
| 331 | + return [ | |
| 332 | + 'body' => $this->json_rpc_handler->handle_request($request_body), | |
| 333 | + 'status' => 200, | |
| 334 | + 'headers' => [], | |
| 335 | + ]; | |
| 189 | 336 | } |
| 190 | 337 | |
| 191 | 338 | /** |
| 192 | 339 | * Handle health check |
| @@ -204,18 +351,87 @@ | ||
| 204 | 351 | |
| 205 | 352 | /** |
| 206 | 353 | * Check permissions |
| 207 | 354 | * |
| 355 | + * Failed authentication is rate limited here rather than inside | |
| 356 | + * authenticate_request(): this is the permission_callback, so it is the | |
| 357 | + * only seam that runs before WordPress decides whether to invoke the | |
| 358 | + * route callback at all. Every rejected attempt is recorded before the | |
| 359 | + * response is chosen, so the request that trips the limit is itself | |
| 360 | + * counted. | |
| 361 | + * | |
| 208 | 362 | * @param WP_REST_Request $request Request object |
| 209 | 363 | * @return bool|WP_Error |
| 210 | 364 | */ |
| 211 | 365 | public function check_permissions($request) { |
| 366 | + // Core asks twice per request (authorisation, then the Allow header). | |
| 367 | + // Only the first pass may record anything. | |
| 368 | + if ($this->permission_request !== null && $this->permission_request === $request) { | |
| 369 | + return $this->permission_decision; | |
| 370 | + } | |
| 371 | + | |
| 372 | + $this->permission_request = $request; | |
| 373 | + $this->permission_decision = $this->evaluate_permissions($request); | |
| 374 | + | |
| 375 | + return $this->permission_decision; | |
| 376 | + } | |
| 377 | + | |
| 378 | + /** | |
| 379 | + * Evaluate the permission decision for a request. | |
| 380 | + * | |
| 381 | + * Side effects (recording a failure, clearing a counter) belong here, and | |
| 382 | + * check_permissions() guarantees this runs at most once per request. | |
| 383 | + * | |
| 384 | + * @param WP_REST_Request $request Request object | |
| 385 | + * @return bool|WP_Error | |
| 386 | + */ | |
| 387 | + private function evaluate_permissions($request) { | |
| 388 | + $source = $this->get_auth_source_key(); | |
| 389 | + | |
| 390 | + // Already locked out: refuse before verifying anything, otherwise the | |
| 391 | + // caller could keep guessing credentials while "rate limited". | |
| 392 | + // | |
| 393 | + // Only this caller's own bucket gates the pre-authentication path. | |
| 394 | + // The site-wide ceiling is deliberately left out here and applied to | |
| 395 | + // failures below instead: were it enforced before the credential is | |
| 396 | + // read, an attacker filling the site bucket from throwaway addresses | |
| 397 | + // would lock out the legitimate client holding the correct key, and | |
| 398 | + // every poll that client made would escalate its own bucket on the | |
| 399 | + // way past. That turns a brute-force defence into a denial-of-service | |
| 400 | + // lever, so the ceiling only ever rejects an attempt that has already | |
| 401 | + // failed to authenticate. | |
| 402 | + $lockout = $this->check_rate_limit($source, false, self::AUTH_SCOPE_SOURCE); | |
| 403 | + if (is_wp_error($lockout)) { | |
| 404 | + $this->escalate_auth_lockout($source); | |
| 405 | + return $lockout; | |
| 406 | + } | |
| 407 | + | |
| 212 | 408 | // Check authentication |
| 213 | 409 | $auth_result = $this->authenticate_request($request); |
| 214 | 410 | if (is_wp_error($auth_result)) { |
| 411 | + // A revoked token is not a guess. Producing one takes a valid | |
| 412 | + // signature over the current secret, so the caller held a real | |
| 413 | + // credential and simply needs to re-authenticate after a key | |
| 414 | + // rotation, a disconnect, or an upgrade that rebound tokens. | |
| 415 | + // Charging it would drain the very bucket the auth endpoint | |
| 416 | + // consults, locking a legitimate client out of its own recovery. | |
| 417 | + if ($auth_result->get_error_code() !== 'token_revoked') { | |
| 418 | + // Record this failure, then re-evaluate. Recording first means | |
| 419 | + // the attempt that crosses the threshold is counted rather | |
| 420 | + // than given away for free. | |
| 421 | + $limited = $this->check_rate_limit($source, true); | |
| 422 | + if (is_wp_error($limited)) { | |
| 423 | + return $limited; | |
| 424 | + } | |
| 425 | + } | |
| 426 | + | |
| 215 | 427 | return $auth_result; |
| 216 | 428 | } |
| 217 | 429 | |
| 430 | + // Successful authentication clears the source counter so legitimate | |
| 431 | + // clients are never penalised for an earlier typo or key rotation. | |
| 432 | + $this->clear_auth_attempts($source); | |
| 433 | + | |
| 218 | 434 | // If authenticated via API key, skip capability check |
| 219 | 435 | // API key already proves admin-level access |
| 220 | 436 | $api_key = $request->get_header('X-API-Key'); |
| 221 | 437 | if ($api_key && $this->verify_plugin_auth_token($api_key)) { |
| @@ -291,9 +507,10 @@ | ||
| 291 | 507 | // Method 3: JWT Bearer token (industry-standard authentication) |
| 292 | 508 | $auth_header = $request->get_header('Authorization'); |
| 293 | 509 | if ($auth_header && preg_match('/Bearer\s+(.+)/i', $auth_header, $matches)) { |
| 294 | 510 | $jwt_token = trim($matches[1]); |
| 295 | - $jwt_result = $this->verify_jwt_token($jwt_token); | |
| 511 | + $jwt_revoked = false; | |
| 512 | + $jwt_result = $this->verify_jwt_token($jwt_token, $jwt_revoked); | |
| 296 | 513 | if ($jwt_result !== false) { |
| 297 | 514 | // If user_id is 0, this is an API key-based JWT token (system-level access) |
| 298 | 515 | // Treat it like API key authentication (no user context needed) |
| 299 | 516 | if ($jwt_result['user_id'] === 0) { |
| @@ -319,8 +536,16 @@ | ||
| 319 | 536 | ]; |
| 320 | 537 | } |
| 321 | 538 | return true; |
| 322 | 539 | } |
| 540 | + | |
| 541 | + if ($jwt_revoked) { | |
| 542 | + return new WP_Error( | |
| 543 | + 'token_revoked', | |
| 544 | + 'This token is no longer valid for the current API key. Request a new one from the auth endpoint.', | |
| 545 | + ['status' => 401] | |
| 546 | + ); | |
| 547 | + } | |
| 323 | 548 | } |
| 324 | 549 | |
| 325 | 550 | return new WP_Error( |
| 326 | 551 | 'authentication_failed', |
| @@ -360,8 +585,20 @@ | ||
| 360 | 585 | ]; |
| 361 | 586 | } |
| 362 | 587 | |
| 363 | 588 | /** |
| 589 | + * Acknowledge the client initialization notification. | |
| 590 | + * | |
| 591 | + * JSON-RPC notifications intentionally produce no response body. | |
| 592 | + * | |
| 593 | + * @param array $params Notification parameters. | |
| 594 | + * @return null | |
| 595 | + */ | |
| 596 | + public function handle_initialized_notification($params) { | |
| 597 | + return null; | |
| 598 | + } | |
| 599 | + | |
| 600 | + /** | |
| 364 | 601 | * Handle tools/call method |
| 365 | 602 | * |
| 366 | 603 | * @param array $params Request parameters |
| 367 | 604 | * @return array |
| @@ -447,12 +684,15 @@ | ||
| 447 | 684 | * @param WP_REST_Request $request Request object |
| 448 | 685 | * @return WP_REST_Response|WP_Error |
| 449 | 686 | */ |
| 450 | 687 | public function handle_jwt_auth($request) { |
| 451 | - $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0'; | |
| 688 | + $source = $this->get_auth_source_key(); | |
| 452 | 689 | |
| 453 | - // Enforce brute-force rate limit before any further processing | |
| 454 | - $rate_limit_result = $this->check_rate_limit($ip); | |
| 690 | + // Enforce brute-force rate limit before any further processing. | |
| 691 | + // Source-scoped for the same reason as check_permissions(): a valid | |
| 692 | + // key must still be able to mint a token while the site-wide bucket | |
| 693 | + // is full of somebody else's failures. | |
| 694 | + $rate_limit_result = $this->check_rate_limit($source, false, self::AUTH_SCOPE_SOURCE); | |
| 455 | 695 | if (is_wp_error($rate_limit_result)) { |
| 456 | 696 | return $rate_limit_result; |
| 457 | 697 | } |
| 458 | 698 | |
| @@ -466,9 +706,13 @@ | ||
| 466 | 706 | $api_key = $request->get_header('X-API-Key'); |
| 467 | 707 | } |
| 468 | 708 | |
| 469 | 709 | if (empty($api_key)) { |
| 470 | - $this->record_failed_auth_attempt($ip); | |
| 710 | + $limited = $this->check_rate_limit($source, true); | |
| 711 | + if (is_wp_error($limited)) { | |
| 712 | + return $limited; | |
| 713 | + } | |
| 714 | + | |
| 471 | 715 | return new WP_Error( |
| 472 | 716 | 'missing_api_key', |
| 473 | 717 | 'API key is required. Provide in request body as "api_key" or in X-API-Key header.', |
| 474 | 718 | ['status' => 400] |
| @@ -476,9 +720,13 @@ | ||
| 476 | 720 | } |
| 477 | 721 | |
| 478 | 722 | // Verify API key |
| 479 | 723 | if (!$this->verify_plugin_auth_token($api_key)) { |
| 480 | - $this->record_failed_auth_attempt($ip); | |
| 724 | + $limited = $this->check_rate_limit($source, true); | |
| 725 | + if (is_wp_error($limited)) { | |
| 726 | + return $limited; | |
| 727 | + } | |
| 728 | + | |
| 481 | 729 | return new WP_Error( |
| 482 | 730 | 'invalid_api_key', |
| 483 | 731 | 'Invalid API key', |
| 484 | 732 | ['status' => 401] |
| @@ -485,9 +733,9 @@ | ||
| 485 | 733 | ); |
| 486 | 734 | } |
| 487 | 735 | |
| 488 | 736 | // Successful authentication — clear any accumulated failure counters |
| 489 | - $this->clear_auth_attempts($ip); | |
| 737 | + $this->clear_auth_attempts($source); | |
| 490 | 738 | |
| 491 | 739 | // Generate JWT token |
| 492 | 740 | // Use 0 as user_id to indicate API key authentication (system-level access) |
| 493 | 741 | $token = $this->generate_jwt_token(0); |
| @@ -525,8 +773,16 @@ | ||
| 525 | 773 | 'alg' => 'HS256', |
| 526 | 774 | 'typ' => 'JWT' |
| 527 | 775 | ]; |
| 528 | 776 | |
| 777 | + // Bind the token to the API key it is minted against. Without this | |
| 778 | + // claim a token outlives the key that authorised it and cannot be | |
| 779 | + // revoked before its expiry. | |
| 780 | + $fingerprint = $this->get_api_key_fingerprint(); | |
| 781 | + if ($fingerprint === '') { | |
| 782 | + return false; | |
| 783 | + } | |
| 784 | + | |
| 529 | 785 | // JWT payload |
| 530 | 786 | // user_id = 0 indicates API key authentication (system-level access) |
| 531 | 787 | $payload = [ |
| 532 | 788 | 'sub' => $user_id === 0 ? 'api_key' : 'user:' . $user_id, |
| @@ -533,8 +789,9 @@ | ||
| 533 | 789 | 'user_id' => $user_id, |
| 534 | 790 | 'iat' => $issued_at, |
| 535 | 791 | 'exp' => $expiration, |
| 536 | 792 | 'iss' => get_site_url(), |
| 793 | + 'akf' => $fingerprint, | |
| 537 | 794 | 'scope' => 'mcp:full_access' |
| 538 | 795 | ]; |
| 539 | 796 | |
| 540 | 797 | // Encode header and payload |
| @@ -555,12 +812,19 @@ | ||
| 555 | 812 | |
| 556 | 813 | /** |
| 557 | 814 | * Verify JWT token |
| 558 | 815 | * |
| 559 | - * @param string $token JWT token | |
| 816 | + * @param string $token JWT token | |
| 817 | + * @param bool $revoked Set true when the token was well-formed, correctly | |
| 818 | + * signed and unexpired but no longer matches the | |
| 819 | + * current API key. Reaching that state requires the | |
| 820 | + * signing secret, so it is a revocation to recover | |
| 821 | + * from rather than a credential guess to punish. | |
| 560 | 822 | * @return array|false Decoded payload or false on failure |
| 561 | 823 | */ |
| 562 | - private function verify_jwt_token($token) { | |
| 824 | + private function verify_jwt_token($token, &$revoked = false) { | |
| 825 | + $revoked = false; | |
| 826 | + | |
| 563 | 827 | // Split token into parts |
| 564 | 828 | $parts = explode('.', $token); |
| 565 | 829 | |
| 566 | 830 | if (count($parts) !== 3) { |
| @@ -616,8 +880,27 @@ | ||
| 616 | 880 | if (!isset($payload['user_id'])) { |
| 617 | 881 | return false; |
| 618 | 882 | } |
| 619 | 883 | |
| 884 | + // Check the API key binding. Tokens are minted against an API key | |
| 885 | + // identity, so a rotated key must reject every token issued under the | |
| 886 | + // old one, and a disconnected site — which leaves no key at all — | |
| 887 | + // must reject them all. This runs before the user_id === 0 branch so | |
| 888 | + // API key tokens cannot skip it. | |
| 889 | + $fingerprint = $this->get_api_key_fingerprint(); | |
| 890 | + if ($fingerprint === '') { | |
| 891 | + $revoked = true; | |
| 892 | + return false; | |
| 893 | + } | |
| 894 | + if (!isset($payload['akf']) || !is_string($payload['akf'])) { | |
| 895 | + $revoked = true; | |
| 896 | + return false; | |
| 897 | + } | |
| 898 | + if (!hash_equals($fingerprint, $payload['akf'])) { | |
| 899 | + $revoked = true; | |
| 900 | + return false; | |
| 901 | + } | |
| 902 | + | |
| 620 | 903 | // If user_id is 0, this is an API key-based token (system-level access) |
| 621 | 904 | // No need to validate user existence |
| 622 | 905 | if ($payload['user_id'] === 0) { |
| 623 | 906 | return $payload; |
| @@ -642,82 +925,446 @@ | ||
| 642 | 925 | * @return string |
| 643 | 926 | */ |
| 644 | 927 | private function get_jwt_secret() { |
| 645 | 928 | $secret = get_option('metasync_jwt_secret'); |
| 646 | - if (empty($secret)) { | |
| 647 | - $secret = wp_generate_password(64, true, true); | |
| 648 | - update_option('metasync_jwt_secret', $secret, false); | |
| 929 | + if (!empty($secret)) { | |
| 930 | + return $secret; | |
| 649 | 931 | } |
| 650 | - return $secret; | |
| 932 | + return $this->create_jwt_secret(); | |
| 651 | 933 | } |
| 652 | 934 | |
| 653 | 935 | /** |
| 654 | - * Check whether the given IP has exceeded the authentication rate limit. | |
| 936 | + * Persist the JWT secret on first use, first writer wins. | |
| 655 | 937 | * |
| 656 | - * Allows up to 10 failed attempts within any 15-minute window. Returns a | |
| 657 | - * WP_Error with HTTP 429 if the limit is exceeded, or true otherwise. | |
| 938 | + * Two requests arriving before the secret exists both see an empty option. | |
| 939 | + * add_option() and update_option() would each overwrite the other — core's | |
| 940 | + * add_option() is an INSERT ... ON DUPLICATE KEY UPDATE — leaving the | |
| 941 | + * loser's freshly signed token unverifiable. The insert below is a no-op | |
| 942 | + * once the row exists, so every request adopts the same secret. | |
| 658 | 943 | * |
| 659 | - * @param string $ip Client IP address | |
| 660 | - * @return true|WP_Error | |
| 944 | + * @return string | |
| 661 | 945 | */ |
| 662 | - private function check_rate_limit($ip) { | |
| 663 | - $key = 'metasync_auth_attempts_' . md5($ip); | |
| 664 | - $attempts = (int) get_transient($key); | |
| 665 | - if ($attempts >= 10) { | |
| 946 | + private function create_jwt_secret() { | |
| 947 | + global $wpdb; | |
| 948 | + | |
| 949 | + $candidate = wp_generate_password(64, true, true); | |
| 950 | + | |
| 951 | + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- MCP session storage — direct option surgery is the session primitive | |
| 952 | + $wpdb->prepare( | |
| 953 | + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)" | |
| 954 | + . " VALUES (%s, %s, %s)" | |
| 955 | + . " ON DUPLICATE KEY UPDATE option_id = option_id", | |
| 956 | + 'metasync_jwt_secret', | |
| 957 | + $candidate, | |
| 958 | + 'no' | |
| 959 | + ) | |
| 960 | + ); | |
| 961 | + | |
| 962 | + // Read the row back rather than trusting the candidate: on a race the | |
| 963 | + // winner's secret is the one that was persisted. | |
| 964 | + $stored = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- MCP session storage — direct option surgery is the session primitive | |
| 965 | + $wpdb->prepare( | |
| 966 | + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", | |
| 967 | + 'metasync_jwt_secret' | |
| 968 | + ) | |
| 969 | + ); | |
| 970 | + | |
| 971 | + if (is_string($stored) && $stored !== '') { | |
| 972 | + // The options cache recorded a miss moments ago; drop it so later | |
| 973 | + // get_option() calls in this request see the persisted secret. | |
| 974 | + wp_cache_delete('metasync_jwt_secret', 'options'); | |
| 975 | + wp_cache_delete('notoptions', 'options'); | |
| 976 | + return $stored; | |
| 977 | + } | |
| 978 | + | |
| 979 | + // The insert did not take for a reason other than the row already | |
| 980 | + // existing. Fall back to the original write path so the request can | |
| 981 | + // still mint a usable token. | |
| 982 | + update_option('metasync_jwt_secret', $candidate, false); | |
| 983 | + return $candidate; | |
| 984 | + } | |
| 985 | + | |
| 986 | + /** | |
| 987 | + * Fingerprint the API key that MCP tokens are minted against. | |
| 988 | + * | |
| 989 | + * HMACs the key with the JWT secret so the claim carried inside a token is | |
| 990 | + * not an offline-crackable hash of the key itself. | |
| 991 | + * | |
| 992 | + * @return string Fingerprint, or an empty string when no API key is set. | |
| 993 | + */ | |
| 994 | + private function get_api_key_fingerprint() { | |
| 995 | + $options = get_option('metasync_options', []); | |
| 996 | + $api_key = isset($options['general']['apikey']) ? $options['general']['apikey'] : ''; | |
| 997 | + | |
| 998 | + if (!is_string($api_key) || $api_key === '') { | |
| 999 | + return ''; | |
| 1000 | + } | |
| 1001 | + | |
| 1002 | + return hash_hmac('sha256', $api_key, $this->get_jwt_secret()); | |
| 1003 | + } | |
| 1004 | + | |
| 1005 | + /** | |
| 1006 | + * Resolve a stable, hard-to-forge key identifying the caller. | |
| 1007 | + * | |
| 1008 | + * REMOTE_ADDR alone is wrong behind a reverse proxy (every client | |
| 1009 | + * collapses into the proxy's bucket), but blindly trusting | |
| 1010 | + * CF-Connecting-IP / X-Forwarded-For is worse: any caller could mint a | |
| 1011 | + * fresh bucket per request, or poison somebody else's, simply by setting | |
| 1012 | + * a header. Forwarded headers are therefore honoured only when the TCP | |
| 1013 | + * peer is itself a configured trusted proxy, and the resolved client | |
| 1014 | + * address is bound to the proxy that vouched for it so two proxies can | |
| 1015 | + * never share a bucket. | |
| 1016 | + * | |
| 1017 | + * Sites behind Cloudflare/nginx/a load balancer should publish their | |
| 1018 | + * proxy ranges through the 'metasync_mcp_trusted_proxies' filter, e.g. | |
| 1019 | + * | |
| 1020 | + * add_filter( 'metasync_mcp_trusted_proxies', function ( $proxies ) { | |
| 1021 | + * $proxies[] = '173.245.48.0/20'; | |
| 1022 | + * return $proxies; | |
| 1023 | + * } ); | |
| 1024 | + * | |
| 1025 | + * The default is an empty list, i.e. only the un-forgeable TCP peer is | |
| 1026 | + * used. Combined with the site-wide bucket in check_rate_limit(), a | |
| 1027 | + * caller who rotates addresses still meets a ceiling. | |
| 1028 | + * | |
| 1029 | + * @return string Opaque source key (never empty). | |
| 1030 | + */ | |
| 1031 | + private function get_auth_source_key() { | |
| 1032 | + $remote = isset($_SERVER['REMOTE_ADDR']) && is_string($_SERVER['REMOTE_ADDR']) | |
| 1033 | + ? trim($_SERVER['REMOTE_ADDR']) | |
| 1034 | + : ''; | |
| 1035 | + | |
| 1036 | + if (!filter_var($remote, FILTER_VALIDATE_IP)) { | |
| 1037 | + $remote = ''; | |
| 1038 | + } | |
| 1039 | + | |
| 1040 | + $trusted = apply_filters('metasync_mcp_trusted_proxies', []); | |
| 1041 | + if (!is_array($trusted)) { | |
| 1042 | + $trusted = []; | |
| 1043 | + } | |
| 1044 | + | |
| 1045 | + if ($remote !== '' && !empty($trusted) && $this->is_trusted_proxy($remote, $trusted)) { | |
| 1046 | + $forwarded = $this->get_forwarded_ip(); | |
| 1047 | + if ($forwarded !== '') { | |
| 1048 | + return 'fwd:' . $remote . '|' . $forwarded; | |
| 1049 | + } | |
| 1050 | + } | |
| 1051 | + | |
| 1052 | + return $remote === '' ? 'unknown' : 'ip:' . $remote; | |
| 1053 | + } | |
| 1054 | + | |
| 1055 | + /** | |
| 1056 | + * Read the client address a trusted proxy forwarded. | |
| 1057 | + * | |
| 1058 | + * Only called once the TCP peer has been confirmed as a trusted proxy. | |
| 1059 | + * X-Forwarded-For is walked right-to-left because the trusted proxy | |
| 1060 | + * appends to the right; anything further left may have been supplied by | |
| 1061 | + * the client. | |
| 1062 | + * | |
| 1063 | + * @return string Client IP, or '' when no usable value was forwarded. | |
| 1064 | + */ | |
| 1065 | + private function get_forwarded_ip() { | |
| 1066 | + if (!empty($_SERVER['HTTP_CF_CONNECTING_IP']) && is_string($_SERVER['HTTP_CF_CONNECTING_IP'])) { | |
| 1067 | + $cf_ip = trim($_SERVER['HTTP_CF_CONNECTING_IP']); | |
| 1068 | + if (filter_var($cf_ip, FILTER_VALIDATE_IP)) { | |
| 1069 | + return $cf_ip; | |
| 1070 | + } | |
| 1071 | + } | |
| 1072 | + | |
| 1073 | + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) && is_string($_SERVER['HTTP_X_FORWARDED_FOR'])) { | |
| 1074 | + $candidates = array_reverse(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])); | |
| 1075 | + foreach ($candidates as $candidate) { | |
| 1076 | + $candidate = trim($candidate); | |
| 1077 | + if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_IP)) { | |
| 1078 | + return $candidate; | |
| 1079 | + } | |
| 1080 | + } | |
| 1081 | + } | |
| 1082 | + | |
| 1083 | + if (!empty($_SERVER['HTTP_X_REAL_IP']) && is_string($_SERVER['HTTP_X_REAL_IP'])) { | |
| 1084 | + $real_ip = trim($_SERVER['HTTP_X_REAL_IP']); | |
| 1085 | + if (filter_var($real_ip, FILTER_VALIDATE_IP)) { | |
| 1086 | + return $real_ip; | |
| 1087 | + } | |
| 1088 | + } | |
| 1089 | + | |
| 1090 | + return ''; | |
| 1091 | + } | |
| 1092 | + | |
| 1093 | + /** | |
| 1094 | + * Test whether an address belongs to the configured trusted-proxy set. | |
| 1095 | + * | |
| 1096 | + * @param string $ip Address to test (already validated). | |
| 1097 | + * @param array $proxies Plain addresses and/or CIDR ranges. | |
| 1098 | + * @return bool | |
| 1099 | + */ | |
| 1100 | + private function is_trusted_proxy($ip, $proxies) { | |
| 1101 | + foreach ($proxies as $proxy) { | |
| 1102 | + if (is_string($proxy) && $proxy !== '' && $this->ip_in_cidr($ip, trim($proxy))) { | |
| 1103 | + return true; | |
| 1104 | + } | |
| 1105 | + } | |
| 1106 | + | |
| 1107 | + return false; | |
| 1108 | + } | |
| 1109 | + | |
| 1110 | + /** | |
| 1111 | + * Match an address against a plain address or CIDR range. | |
| 1112 | + * | |
| 1113 | + * Works for both IPv4 and IPv6 by comparing packed addresses. | |
| 1114 | + * | |
| 1115 | + * @param string $ip Address to test. | |
| 1116 | + * @param string $range Plain address or CIDR range. | |
| 1117 | + * @return bool | |
| 1118 | + */ | |
| 1119 | + private function ip_in_cidr($ip, $range) { | |
| 1120 | + $ip_bin = @inet_pton($ip); | |
| 1121 | + if ($ip_bin === false) { | |
| 1122 | + return false; | |
| 1123 | + } | |
| 1124 | + | |
| 1125 | + if (strpos($range, '/') === false) { | |
| 1126 | + $range_bin = @inet_pton($range); | |
| 1127 | + return $range_bin !== false && $range_bin === $ip_bin; | |
| 1128 | + } | |
| 1129 | + | |
| 1130 | + list($subnet, $bits) = explode('/', $range, 2); | |
| 1131 | + | |
| 1132 | + $subnet_bin = @inet_pton(trim($subnet)); | |
| 1133 | + if ($subnet_bin === false || strlen($subnet_bin) !== strlen($ip_bin)) { | |
| 1134 | + // Unparseable subnet, or an IPv4/IPv6 family mismatch. | |
| 1135 | + return false; | |
| 1136 | + } | |
| 1137 | + | |
| 1138 | + if (!is_numeric($bits)) { | |
| 1139 | + return false; | |
| 1140 | + } | |
| 1141 | + | |
| 1142 | + $bits = (int) $bits; | |
| 1143 | + $max = strlen($ip_bin) * 8; | |
| 1144 | + if ($bits < 0 || $bits > $max) { | |
| 1145 | + return false; | |
| 1146 | + } | |
| 1147 | + | |
| 1148 | + $whole_bytes = intdiv($bits, 8); | |
| 1149 | + $spare_bits = $bits % 8; | |
| 1150 | + | |
| 1151 | + if ($whole_bytes > 0 && strncmp($ip_bin, $subnet_bin, $whole_bytes) !== 0) { | |
| 1152 | + return false; | |
| 1153 | + } | |
| 1154 | + | |
| 1155 | + if ($spare_bits === 0) { | |
| 1156 | + return true; | |
| 1157 | + } | |
| 1158 | + | |
| 1159 | + $mask = chr((0xFF << (8 - $spare_bits)) & 0xFF); | |
| 1160 | + | |
| 1161 | + return ($ip_bin[$whole_bytes] & $mask) === ($subnet_bin[$whole_bytes] & $mask); | |
| 1162 | + } | |
| 1163 | + | |
| 1164 | + /** | |
| 1165 | + * Resolve the failed-authentication limits, filterable per source. | |
| 1166 | + * | |
| 1167 | + * @param string $source Source key. | |
| 1168 | + * @return array{max:int,window:int,site_max:int,site_window:int} | |
| 1169 | + */ | |
| 1170 | + private function get_auth_failure_limits($source) { | |
| 1171 | + $defaults = [ | |
| 1172 | + 'max' => self::AUTH_FAILURE_LIMIT, | |
| 1173 | + 'window' => self::AUTH_FAILURE_WINDOW, | |
| 1174 | + 'site_max' => self::AUTH_FAILURE_SITE_LIMIT, | |
| 1175 | + 'site_window' => self::AUTH_FAILURE_WINDOW, | |
| 1176 | + ]; | |
| 1177 | + | |
| 1178 | + $limits = apply_filters('metasync_mcp_auth_failure_rate_limit', $defaults, $source); | |
| 1179 | + if (!is_array($limits)) { | |
| 1180 | + $limits = $defaults; | |
| 1181 | + } | |
| 1182 | + | |
| 1183 | + return [ | |
| 1184 | + 'max' => max(1, isset($limits['max']) ? (int) $limits['max'] : $defaults['max']), | |
| 1185 | + 'window' => max(1, isset($limits['window']) ? (int) $limits['window'] : $defaults['window']), | |
| 1186 | + 'site_max' => max(1, isset($limits['site_max']) ? (int) $limits['site_max'] : $defaults['site_max']), | |
| 1187 | + 'site_window' => max(1, isset($limits['site_window']) ? (int) $limits['site_window'] : $defaults['site_window']), | |
| 1188 | + ]; | |
| 1189 | + } | |
| 1190 | + | |
| 1191 | + /** | |
| 1192 | + * Transient name for a failed-authentication bucket. | |
| 1193 | + * | |
| 1194 | + * Hashing keeps the name within WordPress's length limit and keeps the | |
| 1195 | + * raw source address out of the options table. | |
| 1196 | + * | |
| 1197 | + * @param string $bucket Bucket identifier. | |
| 1198 | + * @return string | |
| 1199 | + */ | |
| 1200 | + private function get_auth_bucket_key($bucket) { | |
| 1201 | + return self::AUTH_FAILURE_PREFIX . substr(hash('sha256', $bucket), 0, 40); | |
| 1202 | + } | |
| 1203 | + | |
| 1204 | + /** | |
| 1205 | + * Read a failed-authentication bucket, treating an elapsed window as empty. | |
| 1206 | + * | |
| 1207 | + * @param string $bucket Bucket identifier. | |
| 1208 | + * @return array{attempts:int,expires_at:int} | |
| 1209 | + */ | |
| 1210 | + private function read_auth_bucket($bucket) { | |
| 1211 | + $data = get_transient($this->get_auth_bucket_key($bucket)); | |
| 1212 | + $now = time(); | |
| 1213 | + | |
| 1214 | + if (!is_array($data) | |
| 1215 | + || !isset($data['attempts'], $data['expires_at']) | |
| 1216 | + || (int) $data['expires_at'] <= $now | |
| 1217 | + ) { | |
| 1218 | + return ['attempts' => 0, 'expires_at' => 0]; | |
| 1219 | + } | |
| 1220 | + | |
| 1221 | + return [ | |
| 1222 | + 'attempts' => (int) $data['attempts'], | |
| 1223 | + 'expires_at' => (int) $data['expires_at'], | |
| 1224 | + ]; | |
| 1225 | + } | |
| 1226 | + | |
| 1227 | + /** | |
| 1228 | + * Increment a failed-authentication bucket, escalating the lockout. | |
| 1229 | + * | |
| 1230 | + * Once the threshold is crossed the window is extended, doubling for each | |
| 1231 | + * further block of failures and capped at AUTH_FAILURE_MAX_LOCKOUT, so a | |
| 1232 | + * caller that keeps hammering waits progressively longer. | |
| 1233 | + * | |
| 1234 | + * @param string $bucket Bucket identifier. | |
| 1235 | + * @param int $limit Attempts allowed within the window. | |
| 1236 | + * @param int $window Base window in seconds. | |
| 1237 | + * @return void | |
| 1238 | + */ | |
| 1239 | + private function bump_auth_bucket($bucket, $limit, $window) { | |
| 1240 | + $state = $this->read_auth_bucket($bucket); | |
| 1241 | + $now = time(); | |
| 1242 | + $attempts = $state['attempts'] + 1; | |
| 1243 | + $expires = $state['expires_at'] > $now ? $state['expires_at'] : $now + $window; | |
| 1244 | + | |
| 1245 | + if ($attempts >= $limit) { | |
| 1246 | + $tier = intdiv($attempts - $limit, $limit); | |
| 1247 | + $lockout = min($window * (1 << min($tier, 10)), self::AUTH_FAILURE_MAX_LOCKOUT); | |
| 1248 | + $expires = max($expires, $now + $lockout); | |
| 1249 | + } | |
| 1250 | + | |
| 1251 | + set_transient( | |
| 1252 | + $this->get_auth_bucket_key($bucket), | |
| 1253 | + ['attempts' => $attempts, 'expires_at' => $expires], | |
| 1254 | + max(1, $expires - $now) | |
| 1255 | + ); | |
| 1256 | + } | |
| 1257 | + | |
| 1258 | + /** | |
| 1259 | + * Record a failed authentication attempt. | |
| 1260 | + * | |
| 1261 | + * Increments both the per-source bucket and the site-wide bucket. The | |
| 1262 | + * site-wide counter is the backstop for a caller that rotates its source | |
| 1263 | + * address faster than the per-source bucket can hold it. | |
| 1264 | + * | |
| 1265 | + * @param string $source Source key from get_auth_source_key(). | |
| 1266 | + * @return void | |
| 1267 | + */ | |
| 1268 | + private function record_failed_auth_attempt($source) { | |
| 1269 | + $limits = $this->get_auth_failure_limits($source); | |
| 1270 | + | |
| 1271 | + $this->bump_auth_bucket($source, $limits['max'], $limits['window']); | |
| 1272 | + $this->bump_auth_bucket( | |
| 1273 | + self::AUTH_FAILURE_SITE_BUCKET, | |
| 1274 | + $limits['site_max'], | |
| 1275 | + $limits['site_window'] | |
| 1276 | + ); | |
| 1277 | + | |
| 1278 | + if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 1279 | + // Log the bucket digest only — never the presented credential. | |
| 1280 | + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 1281 | + 'MetaSync MCP: failed authentication attempt (source=%s)', | |
| 1282 | + substr(hash('sha256', $source), 0, 12) | |
| 1283 | + )); | |
| 1284 | + } | |
| 1285 | + } | |
| 1286 | + | |
| 1287 | + /** | |
| 1288 | + * Check whether the caller has exhausted the failed-authentication budget. | |
| 1289 | + * | |
| 1290 | + * When $record is true the attempt is written to both buckets *before* | |
| 1291 | + * the limit is evaluated, so the request that crosses the threshold is | |
| 1292 | + * itself counted rather than being handed out for free. | |
| 1293 | + * | |
| 1294 | + * The site-wide bucket is only consulted for AUTH_SCOPE_ALL. Recording, | |
| 1295 | + * when requested, always feeds both buckets regardless of scope. | |
| 1296 | + * | |
| 1297 | + * @param string $source Source key from get_auth_source_key(). | |
| 1298 | + * @param bool $record Record this attempt before evaluating. | |
| 1299 | + * @param string $scope AUTH_SCOPE_ALL or AUTH_SCOPE_SOURCE. | |
| 1300 | + * @return true|WP_Error WP_Error carrying HTTP 429 when locked out. | |
| 1301 | + */ | |
| 1302 | + private function check_rate_limit($source, $record = false, $scope = self::AUTH_SCOPE_ALL) { | |
| 1303 | + if ($record) { | |
| 1304 | + $this->record_failed_auth_attempt($source); | |
| 1305 | + } | |
| 1306 | + | |
| 1307 | + $limits = $this->get_auth_failure_limits($source); | |
| 1308 | + $now = time(); | |
| 1309 | + | |
| 1310 | + $buckets = [[$source, $limits['max']]]; | |
| 1311 | + if ($scope !== self::AUTH_SCOPE_SOURCE) { | |
| 1312 | + $buckets[] = [self::AUTH_FAILURE_SITE_BUCKET, $limits['site_max']]; | |
| 1313 | + } | |
| 1314 | + | |
| 1315 | + foreach ($buckets as $bucket) { | |
| 1316 | + list($name, $limit) = $bucket; | |
| 1317 | + | |
| 1318 | + $state = $this->read_auth_bucket($name); | |
| 1319 | + if ($state['attempts'] < $limit) { | |
| 1320 | + continue; | |
| 1321 | + } | |
| 1322 | + | |
| 1323 | + $retry_after = max(1, $state['expires_at'] - $now); | |
| 1324 | + | |
| 666 | 1325 | return new WP_Error( |
| 667 | 1326 | 'too_many_requests', |
| 668 | 1327 | 'Too many failed authentication attempts. Try again later.', |
| 669 | - ['status' => 429] | |
| 1328 | + [ | |
| 1329 | + 'status' => 429, | |
| 1330 | + 'retry_after' => $retry_after, | |
| 1331 | + ] | |
| 670 | 1332 | ); |
| 671 | 1333 | } |
| 1334 | + | |
| 672 | 1335 | return true; |
| 673 | 1336 | } |
| 674 | 1337 | |
| 675 | 1338 | /** |
| 676 | - * Increment the failed-authentication counter for the given IP. | |
| 1339 | + * Deepen an already-active lockout for a source that keeps knocking. | |
| 677 | 1340 | * |
| 678 | - * The counter expires automatically after 15 minutes. | |
| 1341 | + * Only the per-source bucket is advanced. Feeding the site-wide bucket | |
| 1342 | + * here would let a single blocked caller drive the site over its ceiling | |
| 1343 | + * and lock out everybody else, turning a brute-force defence into a | |
| 1344 | + * denial-of-service lever. | |
| 679 | 1345 | * |
| 680 | - * @param string $ip Client IP address | |
| 1346 | + * @param string $source Source key from get_auth_source_key(). | |
| 681 | 1347 | * @return void |
| 682 | 1348 | */ |
| 683 | - private function record_failed_auth_attempt($ip) { | |
| 684 | - global $wpdb; | |
| 685 | - $key = 'metasync_auth_attempts_' . md5($ip); | |
| 686 | - $window = 15 * MINUTE_IN_SECONDS; | |
| 687 | - | |
| 688 | - // Use get_transient to check existence first | |
| 689 | - $attempts = get_transient($key); | |
| 690 | - if ($attempts === false) { | |
| 691 | - // First attempt: set initial value atomically | |
| 692 | - set_transient($key, 1, $window); | |
| 693 | - } else { | |
| 694 | - // Increment atomically via direct SQL to avoid race conditions | |
| 695 | - $option_name = '_transient_' . $key; | |
| 696 | - $updated = $wpdb->query( | |
| 697 | - $wpdb->prepare( | |
| 698 | - "UPDATE {$wpdb->options} SET option_value = option_value + 1 WHERE option_name = %s", | |
| 699 | - $option_name | |
| 700 | - ) | |
| 701 | - ); | |
| 702 | - // If no rows updated (e.g. object cache backend), fall back to set | |
| 703 | - if ($updated === 0) { | |
| 704 | - set_transient($key, (int) $attempts + 1, $window); | |
| 705 | - } | |
| 706 | - } | |
| 1349 | + private function escalate_auth_lockout($source) { | |
| 1350 | + $limits = $this->get_auth_failure_limits($source); | |
| 1351 | + $this->bump_auth_bucket($source, $limits['max'], $limits['window']); | |
| 707 | 1352 | } |
| 708 | 1353 | |
| 709 | 1354 | /** |
| 710 | - * Clear the failed-authentication counter for the given IP. | |
| 1355 | + * Clear the failed-authentication counter for a source. | |
| 711 | 1356 | * |
| 712 | - * Called on successful authentication to reset brute-force tracking. | |
| 1357 | + * Called on successful authentication so a legitimate client is never | |
| 1358 | + * penalised for earlier failures. The site-wide counter is deliberately | |
| 1359 | + * left intact: one success must not wipe the evidence of a distributed | |
| 1360 | + * attack in progress. | |
| 713 | 1361 | * |
| 714 | - * @param string $ip Client IP address | |
| 1362 | + * @param string $source Source key from get_auth_source_key(). | |
| 715 | 1363 | * @return void |
| 716 | 1364 | */ |
| 717 | - private function clear_auth_attempts($ip) { | |
| 718 | - $key = 'metasync_auth_attempts_' . md5($ip); | |
| 719 | - delete_transient($key); | |
| 1365 | + private function clear_auth_attempts($source) { | |
| 1366 | + delete_transient($this->get_auth_bucket_key($source)); | |
| 720 | 1367 | } |
| 721 | 1368 | |
| 722 | 1369 | /** |
| 723 | 1370 | * Base64 URL encode |