PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.1
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / mcp / class-mcp-rate-limiter.php

class-mcp-rate-limiter.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.1, at includes/mcp/class-mcp-rate-limiter.php

248 lines 8.1 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 is
6 * already infeasible. This limiter stops the cheaper abuse: a flood of
7 * bad-token requests burning CPU + filling logs, and gives a rotated token's
8 * stale clients a hard wall. Defence-in-depth, not the primary control.
9 *
10 * Model: count consecutive FAILED attempts per client IP in a rolling window
11 * (transient-backed). At/after the threshold the IP is locked out for the
12 * window; a SUCCESSFUL auth clears the counter immediately.
13 *
14 * "Failed" means a credential was PRESENTED and rejected. A request with no
15 * Authorization header never reaches the counter — that is the first step of
16 * the OAuth handshake (client asks for the RFC 9728 challenge), so counting it
17 * would lock out every OAuth client during normal discovery.
18 *
19 * Threshold + window are overridable via the THINKRANK_MCP_MAX_FAILS /
20 * THINKRANK_MCP_LOCKOUT_SECONDS constants and the `thinkrank_mcp_rate_limit`
21 * filter ( [ max_fails, lockout_seconds ] ).
22 *
23 * @package ThinkRank\Mcp
24 */
25
26 declare(strict_types=1);
27
28 namespace ThinkRank\Mcp;
29
30 if ( ! defined( 'ABSPATH' ) ) {
31 exit; // Exit if accessed directly.
32 }
33
34 /**
35 * Per-IP failed-auth lockout for the MCP endpoint.
36 */
37 final class Mcp_Rate_Limiter {
38
39 /**
40 * Transient key prefix; the client-IP hash is appended.
41 */
42 private const PREFIX = 'thinkrank_mcp_rl_';
43
44 /**
45 * Default: lock out after this many failed attempts.
46 */
47 private const DEFAULT_MAX_FAILS = 10;
48
49 /**
50 * Default: lockout / rolling-window length, in seconds.
51 */
52 private const DEFAULT_LOCKOUT = 900; // 15 minutes.
53
54 /**
55 * Is the current client currently locked out? Call BEFORE comparing the
56 * token so a locked IP never even reaches the (constant-time) compare.
57 *
58 * @return bool
59 */
60 public static function is_locked(): bool {
61 list( $max ) = self::limits();
62 return self::attempts() >= $max;
63 }
64
65 /**
66 * Record a failed auth attempt for the current client and return whether
67 * the client is now locked out.
68 *
69 * The window is FIXED from the first failure — recording a failure never
70 * extends it. The previous behaviour reset the transient's expiry on every
71 * increment, so a stranded client that retried every few minutes (exactly
72 * what a connector configured with a rotated-away token does, and exactly
73 * what support kept telling a customer to do) re-armed its own lockout
74 * forever. A lockout must be escapable by simply waiting out one window.
75 *
76 * @return bool True if this failure crossed into a lockout.
77 */
78 public static function record_failure(): bool {
79 list( $max, $window ) = self::limits();
80
81 $key = self::key();
82 $entry = get_transient( $key );
83
84 if ( is_array( $entry ) && isset( $entry['count'], $entry['until'] ) ) {
85 $entry['count']++;
86 // Preserve the ORIGINAL window end: TTL = remaining time only.
87 $remaining = max( 1, (int) $entry['until'] - time() );
88 set_transient( $key, $entry, $remaining );
89 return $entry['count'] >= $max;
90 }
91
92 // First failure in a window (also migrates any legacy integer entry —
93 // a stale int simply restarts as a fresh window of 1).
94 $entry = [
95 'count' => 1,
96 'until' => time() + $window,
97 ];
98 set_transient( $key, $entry, $window );
99 return 1 >= $max;
100 }
101
102 /**
103 * Clear the counter for the current client — call on a SUCCESSFUL auth.
104 *
105 * @return void
106 */
107 public static function clear(): void {
108 delete_transient( self::key() );
109 }
110
111 /**
112 * Seconds until the current client's window ends. Falls back to the full
113 * window length when no entry exists — honest now that the window is fixed,
114 * where before this always reported the full length no matter how long the
115 * client had already waited.
116 *
117 * @return int
118 */
119 public static function retry_after(): int {
120 $entry = get_transient( self::key() );
121 if ( is_array( $entry ) && isset( $entry['until'] ) ) {
122 return max( 1, (int) $entry['until'] - time() );
123 }
124 return self::limits()[1];
125 }
126
127 /**
128 * How many clients are currently locked out, across all IPs.
129 *
130 * Diagnostic for the self-test: a healthy loopback plus a locked-out remote
131 * client is exactly the state a stranded connector (rotated-away token,
132 * still retrying) produces, and it was invisible — support couldn't tell
133 * "server broken" from "client walled itself off".
134 *
135 * Returns null when a persistent object cache is in use — transients don't
136 * live in the options table there, so the count is unknowable and claiming
137 * zero would be a lie.
138 *
139 * @return int|null Locked-out client count, or null when unknowable.
140 */
141 public static function active_lockouts(): ?int {
142 if ( wp_using_ext_object_cache() ) {
143 return null;
144 }
145
146 global $wpdb;
147 if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_col' ) ) {
148 return null;
149 }
150 list( $max ) = self::limits();
151
152 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- diagnostic scan over transient rows; no core API enumerates them.
153 $rows = $wpdb->get_col(
154 $wpdb->prepare(
155 "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
156 $wpdb->esc_like( '_transient_' . self::PREFIX ) . '%'
157 )
158 );
159
160 $locked = 0;
161 foreach ( (array) $rows as $row ) {
162 $entry = maybe_unserialize( $row );
163 $count = is_array( $entry ) && isset( $entry['count'] )
164 ? (int) $entry['count']
165 : ( is_numeric( $entry ) ? (int) $entry : 0 );
166 if ( $count >= $max ) {
167 $locked++;
168 }
169 }
170
171 return $locked;
172 }
173
174 // -- internals --
175
176 /**
177 * Current failed-attempt count for this client (0 when none).
178 *
179 * @return int
180 */
181 private static function attempts(): int {
182 $v = get_transient( self::key() );
183 if ( is_array( $v ) && isset( $v['count'] ) ) {
184 return (int) $v['count'];
185 }
186 // Legacy integer entries from before the fixed-window format.
187 return is_numeric( $v ) ? (int) $v : 0;
188 }
189
190 /**
191 * Transient key bound to the (hashed) client IP.
192 *
193 * @return string
194 */
195 private static function key(): string {
196 return self::PREFIX . md5( self::client_ip() );
197 }
198
199 /**
200 * Resolve [ max_fails, lockout_seconds ] from constants, then filter.
201 *
202 * @return array{0:int,1:int}
203 */
204 private static function limits(): array {
205 $max = defined( 'THINKRANK_MCP_MAX_FAILS' ) ? (int) \THINKRANK_MCP_MAX_FAILS : self::DEFAULT_MAX_FAILS;
206 $window = defined( 'THINKRANK_MCP_LOCKOUT_SECONDS' ) ? (int) \THINKRANK_MCP_LOCKOUT_SECONDS : self::DEFAULT_LOCKOUT;
207
208 /**
209 * Filter the MCP failed-auth rate limit.
210 *
211 * @param array{0:int,1:int} $limits [ max_fails, lockout_seconds ].
212 */
213 $limits = (array) apply_filters( 'thinkrank_mcp_rate_limit', [ $max, $window ] );
214 $max = isset( $limits[0] ) ? max( 1, (int) $limits[0] ) : self::DEFAULT_MAX_FAILS;
215 $window = isset( $limits[1] ) ? max( 1, (int) $limits[1] ) : self::DEFAULT_LOCKOUT;
216 return [ $max, $window ];
217 }
218
219 /**
220 * Best-effort client IP. REMOTE_ADDR only — we deliberately do NOT trust
221 * X-Forwarded-For (spoofable → an attacker could dodge the limit or lock
222 * out a victim). Behind a reverse proxy or tunnel every client shares one
223 * REMOTE_ADDR and therefore one bucket; such a site should either set
224 * REMOTE_ADDR upstream or opt in via the filter below, which is safe only
225 * when the proxy is known to overwrite the forwarded header.
226 *
227 * @return string
228 */
229 private static function client_ip(): string {
230 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- used only as a rate-limit bucket key (md5'd), never output or stored raw.
231 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) wp_unslash( $_SERVER['REMOTE_ADDR'] ) : '';
232
233 /**
234 * Filter the IP used as the MCP rate-limit bucket key.
235 *
236 * Opt-in escape hatch for sites behind a trusted reverse proxy, where
237 * REMOTE_ADDR is the proxy and every client would otherwise collapse
238 * into a single bucket. Only return a forwarded header's value when
239 * the proxy is known to overwrite it.
240 *
241 * @param string $ip Resolved REMOTE_ADDR ('' when unavailable).
242 */
243 $ip = (string) apply_filters( 'thinkrank_mcp_client_ip', $ip );
244
245 return '' !== $ip ? $ip : 'unknown';
246 }
247 }
248