PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / mcp-server / Auth / FailedAuthLimiter.php

FailedAuthLimiter.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/mcp-server/Auth/FailedAuthLimiter.php

223 lines 7.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Per-source lockout after repeated failed authentication (spec 044, FR-040).
4 *
5 * Checked BEFORE any secret comparison, so a locked source never reaches the
6 * comparison at all — it cannot be used as a timing or existence oracle.
7 *
8 * ## The forwarded-header decision
9 *
10 * The client address is taken from the connection only. `X-Forwarded-For` and
11 * friends are attacker-controlled: trusting them would let an attacker both
12 * evade their own limit (rotate the header) and lock out a victim (spoof theirs).
13 *
14 * ## Known limitation, accepted
15 *
16 * Transient-backed, so on a site with no persistent object cache this is
17 * best-effort across PHP workers. A single address bucket is also evadable from
18 * many addresses and over-broad behind an unconfigured CDN where many visitors
19 * share one address. This is a throttle against opportunistic abuse; the
20 * credential's own 256 bits of entropy is the actual control.
21 *
22 * @package Templately\Modules\McpServer\Auth
23 */
24
25 namespace Templately\Modules\McpServer\Auth;
26
27 class FailedAuthLimiter {
28
29 const PREFIX = 'templately_mcp_rl_';
30 const MAX_FAILS = 10;
31 const LOCKOUT = 900;
32
33 /** Credential presentation on the MCP endpoint. */
34 const BUCKET_AUTH = 'auth';
35
36 /** The public OAuth registration/token endpoints. */
37 const BUCKET_OAUTH = 'oauth';
38
39 /**
40 * Successful client registrations — a QUOTA, not a strike count.
41 *
42 * Separate from BUCKET_OAUTH and much larger, because the two measure
43 * different things. A failure is evidence of abuse; a successful
44 * registration is ordinary behaviour that merely needs a ceiling, and
45 * clients like Codex register afresh on every login attempt. Charging both
46 * to the same 10-strike bucket meant a handful of legitimate reconnections
47 * locked the user out of their own OAuth endpoints for 15 minutes.
48 */
49 const BUCKET_REGISTER = 'register';
50
51 /** Per-bucket ceilings; anything unlisted uses MAX_FAILS. */
52 const BUCKET_MAX = [
53 self::BUCKET_REGISTER => 60,
54 ];
55
56 /**
57 * @param string $bucket
58 * @return bool
59 */
60 public static function is_locked( string $bucket = self::BUCKET_AUTH ): bool {
61 return self::count( $bucket ) >= self::max_fails( $bucket );
62 }
63
64 /**
65 * Seconds until the lockout lapses — emitted as `Retry-After` (FR-040).
66 * The reference implementation computes this and never sends it.
67 *
68 * @return int
69 */
70 public static function retry_after( string $bucket = self::BUCKET_AUTH ): int {
71 $remaining = self::state( $bucket )['until'] - time();
72
73 return $remaining > 0 ? $remaining : self::lockout_seconds();
74 }
75
76 /**
77 * Record a failure WITHOUT extending an existing window.
78 *
79 * `set_transient` resets the TTL on every write, so re-arming it per failure
80 * let one caller hold a lockout open indefinitely at roughly a request a
81 * minute. The window runs from the FIRST failure and lapses on schedule.
82 *
83 * @param string $bucket
84 * @return void
85 */
86 public static function record_failure( string $bucket = self::BUCKET_AUTH ): void {
87 $state = self::state( $bucket );
88 $now = time();
89
90 // Keep the window opened by the first failure; only start a new one when
91 // the previous has lapsed.
92 $until = $state['until'] > $now ? $state['until'] : $now + self::lockout_seconds();
93
94 set_transient(
95 self::key( $bucket ),
96 [
97 'count' => $state['count'] + 1,
98 'until' => $until,
99 ],
100 max( 1, $until - $now )
101 );
102 }
103
104 public static function clear( string $bucket = self::BUCKET_AUTH ): void {
105 delete_transient( self::key( $bucket ) );
106 }
107
108 /**
109 * @param string $bucket
110 * @return int
111 */
112 public static function count( string $bucket = self::BUCKET_AUTH ): int {
113 return self::state( $bucket )['count'];
114 }
115
116 /**
117 * The bucket's `{count, until}`, with the window expiry carried INSIDE the
118 * transient value rather than read back off WordPress's timeout row.
119 *
120 * `_transient_timeout_<key>` is an implementation detail of the DATABASE
121 * backend only: with a persistent object cache installed, `set_transient()`
122 * stores value + TTL in the cache and writes no option rows at all. Reading
123 * the timeout row there always missed, which silently undid both behaviours
124 * that depend on knowing when the window started — `retry_after()` always
125 * reported the full window instead of the remaining one, and
126 * `record_failure()` treated every failure as the first, re-arming a fresh
127 * 15 minutes each time. That is the exact indefinite-lockout bug the
128 * "without extending an existing window" note above says was fixed; it was
129 * fixed only on sites with no object cache.
130 *
131 * A bare integer is still accepted so a bucket written by the previous
132 * format keeps counting instead of resetting to zero mid-window.
133 *
134 * @param string $bucket
135 * @return array{count:int,until:int}
136 */
137 private static function state( string $bucket ): array {
138 $raw = get_transient( self::key( $bucket ) );
139
140 if ( is_array( $raw ) ) {
141 return [
142 'count' => (int) ( $raw['count'] ?? 0 ),
143 'until' => (int) ( $raw['until'] ?? 0 ),
144 ];
145 }
146
147 return [
148 'count' => (int) $raw,
149 'until' => 0,
150 ];
151 }
152
153 /**
154 * Buckets are SEPARATE per endpoint class on purpose. With one shared
155 * bucket, ten malformed registrations — unauthenticated, free to send —
156 * locked out bearer authentication for every legitimate agent as well.
157 * Abuse of the public OAuth endpoints must not deny the credentialed one.
158 *
159 * @param string $bucket
160 * @return string
161 */
162 private static function key( string $bucket = self::BUCKET_AUTH ): string {
163 return self::PREFIX . $bucket . '_' . self::key_suffix();
164 }
165
166 private static function key_suffix(): string {
167 return md5( self::client_address() );
168 }
169
170 /**
171 * Connection-level address by default — never a forwarded header, because a
172 * forwarded header is attacker-controlled and trusting it blindly lets an
173 * attacker both evade their own limit and lock out a victim.
174 *
175 * BUT behind a reverse proxy or CDN that does not rewrite REMOTE_ADDR, every
176 * visitor shares one bucket, which turns the limiter into a self-inflicted
177 * denial of service. Such a site can opt in — deliberately, with its own
178 * knowledge of which hop is trustworthy — via this filter.
179 *
180 * @return string
181 */
182 private static function client_address(): string {
183 $address = isset( $_SERVER['REMOTE_ADDR'] )
184 ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
185 : 'unknown';
186
187 /**
188 * Resolve the real client address behind a trusted proxy.
189 *
190 * Return the connection address unchanged to keep the safe default. A
191 * site returning a forwarded header here is asserting that it strips and
192 * re-adds that header at a trusted edge.
193 *
194 * @param string $address Connection-level address.
195 */
196 $filtered = apply_filters( 'templately_mcp_client_address', $address );
197
198 return is_string( $filtered ) && '' !== $filtered ? $filtered : $address;
199 }
200
201 /**
202 * @return int
203 */
204 private static function max_fails( string $bucket = self::BUCKET_AUTH ): int {
205 $default = self::BUCKET_MAX[ $bucket ] ?? self::MAX_FAILS;
206
207 $max = ( self::BUCKET_AUTH === $bucket && defined( 'TEMPLATELY_MCP_MAX_AUTH_FAILS' ) )
208 ? (int) TEMPLATELY_MCP_MAX_AUTH_FAILS
209 : $default;
210
211 return (int) apply_filters( 'templately_mcp_max_auth_fails', max( 1, $max ), $bucket );
212 }
213
214 /**
215 * @return int
216 */
217 private static function lockout_seconds(): int {
218 $seconds = defined( 'TEMPLATELY_MCP_LOCKOUT_SECONDS' ) ? (int) TEMPLATELY_MCP_LOCKOUT_SECONDS : self::LOCKOUT;
219
220 return (int) apply_filters( 'templately_mcp_lockout_seconds', max( 1, $seconds ) );
221 }
222 }
223