PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.25.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.25.0
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 1.25.0, at includes/mcp/class-mcp-rate-limiter.php

146 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 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 * Threshold + window are overridable via the THINKRANK_MCP_MAX_FAILS /
15 * THINKRANK_MCP_LOCKOUT_SECONDS constants and the `thinkrank_mcp_rate_limit`
16 * filter ( [ max_fails, lockout_seconds ] ).
17 *
18 * @package ThinkRank\Mcp
19 */
20
21 declare(strict_types=1);
22
23 namespace ThinkRank\Mcp;
24
25 if ( ! defined( 'ABSPATH' ) ) {
26 exit; // Exit if accessed directly.
27 }
28
29 /**
30 * Per-IP failed-auth lockout for the MCP endpoint.
31 */
32 final class Mcp_Rate_Limiter {
33
34 /**
35 * Transient key prefix; the client-IP hash is appended.
36 */
37 private const PREFIX = 'thinkrank_mcp_rl_';
38
39 /**
40 * Default: lock out after this many failed attempts.
41 */
42 private const DEFAULT_MAX_FAILS = 10;
43
44 /**
45 * Default: lockout / rolling-window length, in seconds.
46 */
47 private const DEFAULT_LOCKOUT = 900; // 15 minutes.
48
49 /**
50 * Is the current client currently locked out? Call BEFORE comparing the
51 * token so a locked IP never even reaches the (constant-time) compare.
52 *
53 * @return bool
54 */
55 public static function is_locked(): bool {
56 list( $max ) = self::limits();
57 return self::attempts() >= $max;
58 }
59
60 /**
61 * Record a failed auth attempt for the current client and return whether
62 * the client is now locked out. Extends the rolling window on each fail.
63 *
64 * @return bool True if this failure crossed into a lockout.
65 */
66 public static function record_failure(): bool {
67 list( $max, $window ) = self::limits();
68 $count = self::attempts() + 1;
69 set_transient( self::key(), $count, $window );
70 return $count >= $max;
71 }
72
73 /**
74 * Clear the counter for the current client — call on a SUCCESSFUL auth.
75 *
76 * @return void
77 */
78 public static function clear(): void {
79 delete_transient( self::key() );
80 }
81
82 /**
83 * Seconds a locked client must wait (approximate; the window length).
84 *
85 * @return int
86 */
87 public static function retry_after(): int {
88 return self::limits()[1];
89 }
90
91 // -- internals --
92
93 /**
94 * Current failed-attempt count for this client (0 when none).
95 *
96 * @return int
97 */
98 private static function attempts(): int {
99 $v = get_transient( self::key() );
100 return is_numeric( $v ) ? (int) $v : 0;
101 }
102
103 /**
104 * Transient key bound to the (hashed) client IP.
105 *
106 * @return string
107 */
108 private static function key(): string {
109 return self::PREFIX . md5( self::client_ip() );
110 }
111
112 /**
113 * Resolve [ max_fails, lockout_seconds ] from constants, then filter.
114 *
115 * @return array{0:int,1:int}
116 */
117 private static function limits(): array {
118 $max = defined( 'THINKRANK_MCP_MAX_FAILS' ) ? (int) \THINKRANK_MCP_MAX_FAILS : self::DEFAULT_MAX_FAILS;
119 $window = defined( 'THINKRANK_MCP_LOCKOUT_SECONDS' ) ? (int) \THINKRANK_MCP_LOCKOUT_SECONDS : self::DEFAULT_LOCKOUT;
120
121 /**
122 * Filter the MCP failed-auth rate limit.
123 *
124 * @param array{0:int,1:int} $limits [ max_fails, lockout_seconds ].
125 */
126 $limits = (array) apply_filters( 'thinkrank_mcp_rate_limit', [ $max, $window ] );
127 $max = isset( $limits[0] ) ? max( 1, (int) $limits[0] ) : self::DEFAULT_MAX_FAILS;
128 $window = isset( $limits[1] ) ? max( 1, (int) $limits[1] ) : self::DEFAULT_LOCKOUT;
129 return [ $max, $window ];
130 }
131
132 /**
133 * Best-effort client IP. REMOTE_ADDR only — we deliberately do NOT trust
134 * X-Forwarded-For (spoofable → an attacker could dodge the limit or lock
135 * out a victim). Behind a known proxy the site should set REMOTE_ADDR
136 * upstream.
137 *
138 * @return string
139 */
140 private static function client_ip(): string {
141 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- used only as a rate-limit bucket key (md5'd), never output or stored raw.
142 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) wp_unslash( $_SERVER['REMOTE_ADDR'] ) : '';
143 return '' !== $ip ? $ip : 'unknown';
144 }
145 }
146