[ 'count' => 20, // Hourly limit 'window' => 3600, // 1 hour 'burst_count' => 5, // Burst limit 'burst_window' => 60 // 1 minute ], 'register' => [ 'count' => 10, 'window' => 3600, 'burst_count' => 3, 'burst_window' => 60 ], 'lostpassword' => [ 'count' => 10, 'window' => 3600, 'burst_count' => 3, 'burst_window' => 60 ], 'resetpass' => [ 'count' => 10, 'window' => 3600, 'burst_count' => 3, 'burst_window' => 60 ], ]; /** * Check if action is within rate limits (both hourly and burst) * * @param string $action The action being performed (login, register, etc.) * @return bool True if within limits, false if exceeded */ public function checkLimit(string $action): bool { // Check burst protection first (stricter, prevents rapid-fire) if (!$this->checkBurstLimit($action)) { return false; } // Then check hourly limit return $this->checkHourlyLimit($action); } /** * Check burst protection (prevents rapid-fire attempts) * * Example: 5 login attempts in 10 seconds = blocked * * @param string $action The action being performed * @return bool True if within burst limits, false if exceeded */ protected function checkBurstLimit(string $action): bool { $limit = $this->getLimit($action); // Skip if no burst protection configured if (!isset($limit['burst_count'])) { return true; } $key = $this->getCacheKey($action) . '_burst'; $data = get_transient($key); if (!$data) { $data = ['count' => 0, 'first_attempt' => time()]; } // Check if burst window expired $elapsed = time() - $data['first_attempt']; if ($elapsed >= $limit['burst_window']) { // Window expired, reset $data = ['count' => 0, 'first_attempt' => time()]; } // Check if burst limit exceeded if ($data['count'] >= $limit['burst_count']) { // Log for security monitoring error_log(sprintf( 'Burst rate limit exceeded for %s from %s: %d attempts in %d seconds', $action, $this->getClientIp(), $data['count'], $elapsed )); return false; } // Increment and save $data['count']++; set_transient($key, $data, $limit['burst_window']); return true; } /** * Check hourly rate limit * * @param string $action The action being performed * @return bool True if within hourly limits, false if exceeded */ protected function checkHourlyLimit(string $action): bool { $key = $this->getCacheKey($action); $limit = $this->getLimit($action); // Get current count $data = get_transient($key); if (!$data) { $data = ['count' => 0, 'first_attempt' => time()]; } // Check if window has expired if (time() - $data['first_attempt'] >= $limit['window']) { // Window expired, reset $data = ['count' => 0, 'first_attempt' => time()]; } // Check if limit exceeded if ($data['count'] >= $limit['count']) { // Log for security monitoring error_log(sprintf( 'Hourly rate limit exceeded for %s from %s: %d attempts', $action, $this->getClientIp(), $data['count'] )); return false; } // Increment and save $data['count']++; set_transient($key, $data, $limit['window']); return true; } /** * Get remaining attempts for an action * * @param string $action The action being performed * @return array ['remaining' => int, 'reset_at' => int, 'burst_remaining' => int, 'burst_reset_at' => int] */ public function getRemaining(string $action): array { $limit = $this->getLimit($action); // Hourly remaining $key = $this->getCacheKey($action); $data = get_transient($key); $hourly_remaining = $limit['count']; $hourly_reset_at = time() + $limit['window']; if ($data) { $hourly_remaining = max(0, $limit['count'] - $data['count']); $hourly_reset_at = $data['first_attempt'] + $limit['window']; } // Burst remaining (if configured) $burst_remaining = $limit['burst_count'] ?? null; $burst_reset_at = null; if (isset($limit['burst_count'])) { $burst_key = $key . '_burst'; $burst_data = get_transient($burst_key); if ($burst_data) { $burst_remaining = max(0, $limit['burst_count'] - $burst_data['count']); $burst_reset_at = $burst_data['first_attempt'] + $limit['burst_window']; } else { $burst_reset_at = time() + $limit['burst_window']; } } return [ 'remaining' => $hourly_remaining, 'reset_at' => $hourly_reset_at, 'burst_remaining' => $burst_remaining, 'burst_reset_at' => $burst_reset_at ]; } /** * Generate cache key based on IP and action * * @param string $action The action being performed * @return string Cache key */ protected function getCacheKey(string $action): string { $ip = $this->getClientIp(); $user_id = get_current_user_id(); // 0 if not logged in return BASE . 'ajax_rate_limit_' . md5($ip . '_' . $user_id . '_' . $action); } /** * Get client IP address (supports proxies) * * @return string IP address */ protected function getClientIp(): string { // Check for proxy headers first if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { $ip = $_SERVER['HTTP_X_FORWARDED_FOR']; // X-Forwarded-For can contain multiple IPs, get the first one $ips = explode(',', $ip); return trim($ips[0]); } if (!empty($_SERVER['HTTP_CLIENT_IP'])) { return $_SERVER['HTTP_CLIENT_IP']; } return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } /** * Get limit configuration for an action * * @param string $action The action being performed * @return array Limit configuration */ protected function getLimit(string $action): array { return $this->limits[$action] ?? $this->limits['login']; } /** * Clear rate limit for a specific action (useful for testing) * * @param string $action The action to clear * @return bool True if cleared, false otherwise */ public function clearLimit(string $action): bool { $key = $this->getCacheKey($action); $burst_key = $key . '_burst'; $result1 = delete_transient($key); $result2 = delete_transient($burst_key); return $result1 || $result2; } /** * Update limit configuration * * @param string $action The action to update * @param int $count Max attempts per window * @param int $window Time window in seconds * @param int|null $burst_count Optional burst limit * @param int|null $burst_window Optional burst window */ public function setLimit( string $action, int $count, int $window, ?int $burst_count = null, ?int $burst_window = null ): void { $this->limits[$action] = [ 'count' => $count, 'window' => $window ]; if ($burst_count !== null && $burst_window !== null) { $this->limits[$action]['burst_count'] = $burst_count; $this->limits[$action]['burst_window'] = $burst_window; } } /** * Check if IP is currently rate limited * * @param string $action The action to check * @return bool True if rate limited, false otherwise */ public function isRateLimited(string $action): bool { // Check both burst and hourly without incrementing $limit = $this->getLimit($action); // Check burst if (isset($limit['burst_count'])) { $burst_key = $this->getCacheKey($action) . '_burst'; $burst_data = get_transient($burst_key); if ($burst_data) { $elapsed = time() - $burst_data['first_attempt']; if ($elapsed < $limit['burst_window'] && $burst_data['count'] >= $limit['burst_count']) { return true; } } } // Check hourly $key = $this->getCacheKey($action); $data = get_transient($key); if ($data) { $elapsed = time() - $data['first_attempt']; if ($elapsed < $limit['window'] && $data['count'] >= $limit['count']) { return true; } } return false; } }