PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | wp-mcp-server/class-metasync-mcp-server.php +711 -64 2.6.13 → 2.7.1 View file →
@@ -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