PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 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.3.7, at includes/modules/Mcp/Mcp_Rate_Limiter.php

152 lines 5.4 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 * A new credential starts every client from zero (reset_all()). Failures
18 * earned with the old token say nothing about the new one, and the client
19 * that most needs to get through then is xSpeed Hub: its checks with the
20 * old token are what locked it out, and its check of the new token is what
21 * would clear the lock.
22 *
23 * Threshold + window are overridable:
24 * - XSPEED_MCP_MAX_FAILS / XSPEED_MCP_LOCKOUT_SECONDS constants, and
25 * - the `xspeed_mcp_rate_limit` filter ( [ max_fails, lockout_seconds ] ).
26 *
27 * @package XSpeed
28 */
29
30 declare(strict_types=1);
31
32 namespace XSpeed\Modules\Mcp;
33
34 defined( 'ABSPATH' ) || exit;
35
36 final class Mcp_Rate_Limiter {
37
38 /** Transient key prefix; the client-IP hash is appended. */
39 private const PREFIX = 'xspeed_mcp_rl_';
40
41 /** Option holding the lockout generation; part of every transient key. */
42 private const GENERATION_OPTION = 'xspeed_mcp_rl_gen';
43
44 /** Default: lock out after this many failed attempts. */
45 private const DEFAULT_MAX_FAILS = 10;
46
47 /** Default: lockout / rolling-window length, in seconds. */
48 private const DEFAULT_LOCKOUT = 900; // 15 minutes.
49
50 /**
51 * Is the current client currently locked out? Call BEFORE comparing the
52 * token so a locked IP never even reaches the (constant-time) compare.
53 *
54 * @return bool
55 */
56 public static function is_locked(): bool {
57 list( $max ) = self::limits();
58 return self::attempts() >= $max;
59 }
60
61 /**
62 * Record a failed auth attempt for the current client and return whether
63 * the client is now locked out. Extends the rolling window on each fail.
64 *
65 * @return bool True if this failure crossed into a lockout.
66 */
67 public static function record_failure(): bool {
68 list( $max, $window ) = self::limits();
69 $count = self::attempts() + 1;
70 set_transient( self::key(), $count, $window );
71 return $count >= $max;
72 }
73
74 /**
75 * Clear the counter for the current client — call on a SUCCESSFUL auth so
76 * a legitimate client isn't held back by earlier fumbles.
77 */
78 public static function clear(): void {
79 delete_transient( self::key() );
80 }
81
82 /**
83 * Clear every client's counter at once. Call when the site hands out a
84 * credential: a valid attach, or a token sent to the Hub.
85 *
86 * The counters are per-IP transients, which cannot be listed, so this
87 * moves every key to a new generation instead. The old transients expire
88 * on their own within the window.
89 */
90 public static function reset_all(): void {
91 update_option( self::GENERATION_OPTION, self::generation() + 1, false );
92 }
93
94 /** Seconds a locked client must wait (approximate; the window length). */
95 public static function retry_after(): int {
96 return self::limits()[1];
97 }
98
99 // -- internals --
100
101 /** Current failed-attempt count for this client (0 when none). */
102 private static function attempts(): int {
103 $v = get_transient( self::key() );
104 return is_numeric( $v ) ? (int) $v : 0;
105 }
106
107 /** Transient key bound to the (hashed) client IP and the generation. */
108 private static function key(): string {
109 $generation = self::generation();
110 $suffix = md5( self::client_ip() );
111 return self::PREFIX . ( $generation > 0 ? $generation . '_' : '' ) . $suffix;
112 }
113
114 /** Current lockout generation (0 until the first reset_all()). */
115 private static function generation(): int {
116 $v = get_option( self::GENERATION_OPTION, 0 );
117 return is_numeric( $v ) ? (int) $v : 0;
118 }
119
120 /**
121 * Resolve [ max_fails, lockout_seconds ] from constants, then filter.
122 *
123 * @return array{0:int,1:int}
124 */
125 private static function limits(): array {
126 $max = defined( 'XSPEED_MCP_MAX_FAILS' ) ? (int) \XSPEED_MCP_MAX_FAILS : self::DEFAULT_MAX_FAILS;
127 $window = defined( 'XSPEED_MCP_LOCKOUT_SECONDS' ) ? (int) \XSPEED_MCP_LOCKOUT_SECONDS : self::DEFAULT_LOCKOUT;
128
129 /**
130 * Filter the MCP failed-auth rate limit.
131 *
132 * @param array{0:int,1:int} $limits [ max_fails, lockout_seconds ].
133 */
134 $limits = (array) apply_filters( 'xspeed_mcp_rate_limit', array( $max, $window ) );
135 $max = isset( $limits[0] ) ? max( 1, (int) $limits[0] ) : self::DEFAULT_MAX_FAILS;
136 $window = isset( $limits[1] ) ? max( 1, (int) $limits[1] ) : self::DEFAULT_LOCKOUT;
137 return array( $max, $window );
138 }
139
140 /**
141 * Best-effort client IP. REMOTE_ADDR only — we deliberately do NOT trust
142 * X-Forwarded-For here (spoofable → an attacker could dodge the limit or
143 * lock out a victim). Behind a known proxy the site should set
144 * REMOTE_ADDR upstream.
145 */
146 private static function client_ip(): string {
147 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- used only as a rate-limit bucket key (md5'd), never output or stored raw.
148 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) wp_unslash( $_SERVER['REMOTE_ADDR'] ) : '';
149 return '' !== $ip ? $ip : 'unknown';
150 }
151 }
152