PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.11.0 2.10.0 2.9.0 2.8.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 All 52 releases
← All changes | includes/mcp/class-mcp-rate-limiter.php +109 -7 1.25.0 → 2.11.0 View file →
@@ -10,8 +10,13 @@
10 10 * Model: count consecutive FAILED attempts per client IP in a rolling window
11 11 * (transient-backed). At/after the threshold the IP is locked out for the
12 12 * window; a SUCCESSFUL auth clears the counter immediately.
13 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 + *
14 19 * Threshold + window are overridable via the THINKRANK_MCP_MAX_FAILS /
15 20 * THINKRANK_MCP_LOCKOUT_SECONDS constants and the `thinkrank_mcp_rate_limit`
16 21 * filter ( [ max_fails, lockout_seconds ] ).
17 22 *
@@ -58,17 +63,41 @@
58 63 }
59 64
60 65 /**
61 66 * 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.
67 + * the client is now locked out.
63 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 + *
64 76 * @return bool True if this failure crossed into a lockout.
65 77 */
66 78 public static function record_failure(): bool {
67 79 list( $max, $window ) = self::limits();
68 - $count = self::attempts() + 1;
69 - set_transient( self::key(), $count, $window );
70 - return $count >= $max;
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;
71 100 }
72 101
73 102 /**
74 103 * Clear the counter for the current client — call on a SUCCESSFUL auth.
@@ -79,16 +108,70 @@
79 108 delete_transient( self::key() );
80 109 }
81 110
82 111 /**
83 - * Seconds a locked client must wait (approximate; the window length).
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.
84 116 *
85 117 * @return int
86 118 */
87 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 + }
88 124 return self::limits()[1];
89 125 }
90 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 +
91 174 // -- internals --
92 175
93 176 /**
94 177 * Current failed-attempt count for this client (0 when none).
@@ -96,8 +179,12 @@
96 179 * @return int
97 180 */
98 181 private static function attempts(): int {
99 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.
100 187 return is_numeric( $v ) ? (int) $v : 0;
101 188 }
102 189
103 190 /**
@@ -131,10 +218,12 @@
131 218
132 219 /**
133 220 * Best-effort client IP. REMOTE_ADDR only — we deliberately do NOT trust
134 221 * 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.
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.
137 226 *
138 227 * @return string
139 228 */
140 229 private static function client_ip(): string {
@@ -139,7 +228,20 @@
139 228 */
140 229 private static function client_ip(): string {
141 230 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- used only as a rate-limit bucket key (md5'd), never output or stored raw.
142 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 +
143 245 return '' !== $ip ? $ip : 'unknown';
144 246 }
145 247 }