PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.4
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / Mcp_Rate_Limiter.php

Mcp_Rate_Limiter.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.2.4, at includes/modules/Mcp/Mcp_Rate_Limiter.php

123 lines 4.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP rate limiter — a per-IP lockout on FAILED token authentication.
4 *
5 * The MCP connection token is a 256-bit secret, so online brute-forcing
6 * is already infeasible. This limiter exists to stop the cheaper abuse:
7 * a flood of bad-token requests burning CPU + filling access logs, and to
8 * give a leaked-then-rotated token's stale clients a hard wall instead of
9 * hammering the endpoint. It is a defence-in-depth layer, not the primary
10 * control (that's the token itself).
11 *
12 * Model: count consecutive FAILED attempts per client IP in a rolling
13 * window (transient-backed). At/after the threshold the IP is locked out
14 * for the window; a SUCCESSFUL auth clears the counter immediately so a
15 * legitimate client that fixed a typo isn't punished.
16 *
17 * Threshold + window are overridable:
18 * - XSPEED_MCP_MAX_FAILS / XSPEED_MCP_LOCKOUT_SECONDS constants, and
19 * - the `xspeed_mcp_rate_limit` filter ( [ max_fails, lockout_seconds ] ).
20 *
21 * @package XSpeed
22 */
23
24 declare(strict_types=1);
25
26 namespace XSpeed\Modules\Mcp;
27
28 defined( 'ABSPATH' ) || exit;
29
30 final class Mcp_Rate_Limiter {
31
32 /** Transient key prefix; the client-IP hash is appended. */
33 private const PREFIX = 'xspeed_mcp_rl_';
34
35 /** Default: lock out after this many failed attempts. */
36 private const DEFAULT_MAX_FAILS = 10;
37
38 /** Default: lockout / rolling-window length, in seconds. */
39 private const DEFAULT_LOCKOUT = 900; // 15 minutes.
40
41 /**
42 * Is the current client currently locked out? Call BEFORE comparing the
43 * token so a locked IP never even reaches the (constant-time) compare.
44 *
45 * @return bool
46 */
47 public static function is_locked(): bool {
48 list( $max ) = self::limits();
49 return self::attempts() >= $max;
50 }
51
52 /**
53 * Record a failed auth attempt for the current client and return whether
54 * the client is now locked out. Extends the rolling window on each fail.
55 *
56 * @return bool True if this failure crossed into a lockout.
57 */
58 public static function record_failure(): bool {
59 list( $max, $window ) = self::limits();
60 $count = self::attempts() + 1;
61 set_transient( self::key(), $count, $window );
62 return $count >= $max;
63 }
64
65 /**
66 * Clear the counter for the current client — call on a SUCCESSFUL auth so
67 * a legitimate client isn't held back by earlier fumbles.
68 */
69 public static function clear(): void {
70 delete_transient( self::key() );
71 }
72
73 /** Seconds a locked client must wait (approximate; the window length). */
74 public static function retry_after(): int {
75 return self::limits()[1];
76 }
77
78 // -- internals --
79
80 /** Current failed-attempt count for this client (0 when none). */
81 private static function attempts(): int {
82 $v = get_transient( self::key() );
83 return is_numeric( $v ) ? (int) $v : 0;
84 }
85
86 /** Transient key bound to the (hashed) client IP. */
87 private static function key(): string {
88 return self::PREFIX . md5( self::client_ip() );
89 }
90
91 /**
92 * Resolve [ max_fails, lockout_seconds ] from constants, then filter.
93 *
94 * @return array{0:int,1:int}
95 */
96 private static function limits(): array {
97 $max = defined( 'XSPEED_MCP_MAX_FAILS' ) ? (int) \XSPEED_MCP_MAX_FAILS : self::DEFAULT_MAX_FAILS;
98 $window = defined( 'XSPEED_MCP_LOCKOUT_SECONDS' ) ? (int) \XSPEED_MCP_LOCKOUT_SECONDS : self::DEFAULT_LOCKOUT;
99
100 /**
101 * Filter the MCP failed-auth rate limit.
102 *
103 * @param array{0:int,1:int} $limits [ max_fails, lockout_seconds ].
104 */
105 $limits = (array) apply_filters( 'xspeed_mcp_rate_limit', array( $max, $window ) );
106 $max = isset( $limits[0] ) ? max( 1, (int) $limits[0] ) : self::DEFAULT_MAX_FAILS;
107 $window = isset( $limits[1] ) ? max( 1, (int) $limits[1] ) : self::DEFAULT_LOCKOUT;
108 return array( $max, $window );
109 }
110
111 /**
112 * Best-effort client IP. REMOTE_ADDR only — we deliberately do NOT trust
113 * X-Forwarded-For here (spoofable → an attacker could dodge the limit or
114 * lock out a victim). Behind a known proxy the site should set
115 * REMOTE_ADDR upstream.
116 */
117 private static function client_ip(): string {
118 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- used only as a rate-limit bucket key (md5'd), never output or stored raw.
119 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) wp_unslash( $_SERVER['REMOTE_ADDR'] ) : '';
120 return '' !== $ip ? $ip : 'unknown';
121 }
122 }
123