= $max; } /** * Record a failed auth attempt for the current client and return whether * the client is now locked out. Extends the rolling window on each fail. * * @return bool True if this failure crossed into a lockout. */ public static function record_failure(): bool { list( $max, $window ) = self::limits(); $count = self::attempts() + 1; set_transient( self::key(), $count, $window ); return $count >= $max; } /** * Clear the counter for the current client — call on a SUCCESSFUL auth so * a legitimate client isn't held back by earlier fumbles. */ public static function clear(): void { delete_transient( self::key() ); } /** Seconds a locked client must wait (approximate; the window length). */ public static function retry_after(): int { return self::limits()[1]; } // -- internals -- /** Current failed-attempt count for this client (0 when none). */ private static function attempts(): int { $v = get_transient( self::key() ); return is_numeric( $v ) ? (int) $v : 0; } /** Transient key bound to the (hashed) client IP. */ private static function key(): string { return self::PREFIX . md5( self::client_ip() ); } /** * Resolve [ max_fails, lockout_seconds ] from constants, then filter. * * @return array{0:int,1:int} */ private static function limits(): array { $max = defined( 'XSPEED_MCP_MAX_FAILS' ) ? (int) \XSPEED_MCP_MAX_FAILS : self::DEFAULT_MAX_FAILS; $window = defined( 'XSPEED_MCP_LOCKOUT_SECONDS' ) ? (int) \XSPEED_MCP_LOCKOUT_SECONDS : self::DEFAULT_LOCKOUT; /** * Filter the MCP failed-auth rate limit. * * @param array{0:int,1:int} $limits [ max_fails, lockout_seconds ]. */ $limits = (array) apply_filters( 'xspeed_mcp_rate_limit', array( $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 array( $max, $window ); } /** * Best-effort client IP. REMOTE_ADDR only — we deliberately do NOT trust * X-Forwarded-For here (spoofable → an attacker could dodge the limit or * lock out a victim). Behind a known proxy the site should set * REMOTE_ADDR upstream. */ private static function client_ip(): string { // 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'] ) : ''; return '' !== $ip ? $ip : 'unknown'; } }