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