= $limits[0]; } /** * Record a failed attempt for the current client. * * @since 4.9.0 * * @return bool True when this failure crossed into a lockout. */ public static function record_failure() { $limits = self::limits(); $max = $limits[0]; $window = $limits[1]; $key = self::key(); $entry = get_transient( $key ); if ( is_array( $entry ) && isset( $entry['count'], $entry['until'] ) ) { ++$entry['count']; // Preserve the ORIGINAL window end: the TTL is the remaining time // only, never a fresh full window. $remaining = max( 1, (int) $entry['until'] - time() ); set_transient( $key, $entry, $remaining ); return $entry['count'] >= $max; } // First failure in a window. This also migrates a legacy integer entry: // a stale int simply restarts as a fresh window of one. $entry = [ 'count' => 1, 'until' => time() + $window ]; set_transient( $key, $entry, $window ); return 1 >= $max; } /** * Clear the counter for the current client — call on a successful auth. * * @since 4.9.0 * * @return void */ public static function clear() { delete_transient( self::key() ); } /** * Seconds until the current client's window ends. * * Falls back to the full window length when no entry exists — honest, now * that the window is fixed. * * @since 4.9.0 * * @return int */ public static function retry_after() { $entry = get_transient( self::key() ); if ( is_array( $entry ) && isset( $entry['until'] ) ) { return max( 1, (int) $entry['until'] - time() ); } $limits = self::limits(); return $limits[1]; } /** * How many clients are currently locked out, across all IPs. * * A diagnostic for the self-test: a healthy loopback plus a locked-out * remote client is exactly the state a stranded connector produces — a * rotated-away token, still retrying — and it is otherwise invisible. * * Returns null under a persistent object cache: transients do not live in * the options table there, so the count is unknowable and claiming zero * would be a lie. * * @since 4.9.0 * * @return int|null Locked-out client count, or null when unknowable. */ public static function active_lockouts() { if ( wp_using_ext_object_cache() ) { return null; } global $wpdb; if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_col' ) ) { return null; } $limits = self::limits(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- read-only diagnostic scan over transient rows; no core API enumerates them. $rows = $wpdb->get_col( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( '_transient_' . self::PREFIX ) . '%' ) ); $locked = 0; foreach ( (array) $rows as $row ) { $entry = maybe_unserialize( $row ); $count = is_array( $entry ) && isset( $entry['count'] ) ? (int) $entry['count'] : ( is_numeric( $entry ) ? (int) $entry : 0 ); if ( $count >= $limits[0] ) { ++$locked; } } return $locked; } /** * Current failed-attempt count for this client (0 when none). * * @since 4.9.0 * * @return int */ private static function attempts() { $value = get_transient( self::key() ); if ( is_array( $value ) && isset( $value['count'] ) ) { return (int) $value['count']; } // Legacy integer entries, from before the fixed-window format. return is_numeric( $value ) ? (int) $value : 0; } /** * Transient key bound to the hashed client IP. * * @since 4.9.0 * * @return string */ private static function key() { return self::PREFIX . md5( self::client_ip() ); } /** * Resolve `[ max_fails, lockout_seconds ]` from the constants, then filter. * * @since 4.9.0 * * @return array */ private static function limits() { $max = defined( 'BETTERDOCS_MCP_MAX_FAILS' ) ? (int) BETTERDOCS_MCP_MAX_FAILS : self::DEFAULT_MAX_FAILS; $window = defined( 'BETTERDOCS_MCP_LOCKOUT_SECONDS' ) ? (int) BETTERDOCS_MCP_LOCKOUT_SECONDS : self::DEFAULT_LOCKOUT; /** * Filter the MCP failed-auth rate limit. * * @since 4.9.0 * * @param array $limits `[ max_fails, lockout_seconds ]`. */ $limits = (array) apply_filters( 'betterdocs_mcp_rate_limit', [ $max, $window ] ); $max = isset( $limits[0] ) ? max( 1, (int) $limits[0] ) : self::DEFAULT_MAX_FAILS; $window = isset( $limits[1] ) ? max( 1, (int) $limits[1] ) : self::DEFAULT_LOCKOUT; return [ $max, $window ]; } /** * Best-effort client IP. * * `REMOTE_ADDR` only — `X-Forwarded-For` is spoofable, so trusting it would * let an attacker dodge the limit or lock out a victim. Behind a reverse * proxy every client shares one `REMOTE_ADDR` and therefore one bucket; such * a site should set `REMOTE_ADDR` upstream, or opt in through the filter * below, which is safe only when the proxy is known to overwrite the * forwarded header. * * @since 4.9.0 * * @return string */ private static function client_ip() { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- used only as a rate-limit bucket key (md5'd), never output or stored raw. $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) wp_unslash( $_SERVER['REMOTE_ADDR'] ) : ''; /** * Filter the IP used as the MCP rate-limit bucket key. * * An opt-in escape hatch for sites behind a trusted reverse proxy, where * `REMOTE_ADDR` is the proxy and every client would otherwise collapse * into a single bucket. Only return a forwarded header's value when the * proxy is known to overwrite it. * * @since 4.9.0 * * @param string $ip Resolved `REMOTE_ADDR` ('' when unavailable). */ $ip = (string) apply_filters( 'betterdocs_mcp_client_ip', $ip ); return '' !== $ip ? $ip : 'unknown'; } }