<?php
|
namespace JVBase\managers;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Simple rate limiter for AJAX requests (non-REST)
|
* Includes both hourly limits AND burst protection
|
*/
|
class AjaxRateLimiter
|
{
|
protected array $limits = [
|
'login' => [
|
'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;
|
}
|
}
|