| 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 |
|