|
|
|
@@ -3,21 +3,34 @@
|
|
|
|
|
/**
|
|
|
|
|
* Rate Limiting Middleware
|
|
|
|
|
*
|
|
|
|
|
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
|
|
|
|
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
|
|
|
|
* Implements session-based, IP-based, and (for Bearer-authenticated
|
|
|
|
|
* requests) API-key-based rate limiting to prevent abuse.
|
|
|
|
|
* IP-based limiting prevents attackers from bypassing limits by creating new
|
|
|
|
|
* sessions; API-key-based limiting keeps distinct Bearer clients from
|
|
|
|
|
* starving each other's shared IP bucket.
|
|
|
|
|
*/
|
|
|
|
|
class RateLimitMiddleware
|
|
|
|
|
{
|
|
|
|
|
// Default limits
|
|
|
|
|
// Fallback limits, used only if $GLOBALS['config'] isn't populated
|
|
|
|
|
// (e.g. very early in bootstrap, or a test harness). Normal requests read
|
|
|
|
|
// RATE_LIMIT_DEFAULT/RATE_LIMIT_API from config (backed by .env).
|
|
|
|
|
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
|
|
|
|
public const API_LIMIT = 60; // API requests per window (session)
|
|
|
|
|
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
|
|
|
|
|
public const IP_API_LIMIT = 120; // IP-based API requests per window
|
|
|
|
|
public const API_KEY_LIMIT = 120; // Per-Bearer-token requests per window
|
|
|
|
|
public const WINDOW_SECONDS = 60; // 1 minute window
|
|
|
|
|
|
|
|
|
|
// Directory for IP rate limit storage
|
|
|
|
|
private static ?string $rateLimitDir = null;
|
|
|
|
|
|
|
|
|
|
private static function sessionLimit(string $type): int
|
|
|
|
|
{
|
|
|
|
|
$configKey = $type === 'api' ? 'RATE_LIMIT_API' : 'RATE_LIMIT_DEFAULT';
|
|
|
|
|
$fallback = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
|
|
|
|
return (int)($GLOBALS['config'][$configKey] ?? $fallback);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the rate limit storage directory
|
|
|
|
|
*
|
|
|
|
@@ -69,24 +82,47 @@ class RateLimitMiddleware
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check IP-based rate limit
|
|
|
|
|
* Extract the raw Bearer token from the Authorization header, if present.
|
|
|
|
|
* Deliberately independent of ApiKeyAuth: rate limiting must be cheap and
|
|
|
|
|
* must not require a DB round-trip to validate the key before counting
|
|
|
|
|
* the request, and needs to run whether or not the token turns out to be
|
|
|
|
|
* valid. The raw token string (not the validated api_key_id) is hashed as
|
|
|
|
|
* the bucket identifier — good enough to isolate distinct keys/clients
|
|
|
|
|
* from each other without needing to authenticate first.
|
|
|
|
|
*
|
|
|
|
|
* @param string $type 'default' or 'api'
|
|
|
|
|
* @return bool True if request is allowed, false if rate limited
|
|
|
|
|
* @return string|null
|
|
|
|
|
*/
|
|
|
|
|
private static function checkIpRateLimit(string $type = 'default'): bool
|
|
|
|
|
private static function getBearerToken(): ?string
|
|
|
|
|
{
|
|
|
|
|
$ip = self::getClientIp();
|
|
|
|
|
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
|
|
|
|
$now = time();
|
|
|
|
|
$header = $_SERVER['HTTP_AUTHORIZATION']
|
|
|
|
|
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
|
|
|
|
?? null;
|
|
|
|
|
if ($header === null && function_exists('getallheaders')) {
|
|
|
|
|
$headers = getallheaders();
|
|
|
|
|
$header = $headers['Authorization'] ?? null;
|
|
|
|
|
}
|
|
|
|
|
if ($header && preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
|
|
|
|
|
return $m[1];
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create a hash of the IP for the filename (security + filesystem safety)
|
|
|
|
|
$ipHash = hash('sha256', $ip . '_' . $type);
|
|
|
|
|
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
|
|
|
|
/**
|
|
|
|
|
* Generic file-based sliding-window counter, shared by the IP-based and
|
|
|
|
|
* API-key-based buckets below.
|
|
|
|
|
*
|
|
|
|
|
* @param string $bucketKey Stable identifier for this bucket (already hashed)
|
|
|
|
|
* @param int $limit Max requests allowed per window
|
|
|
|
|
* @return bool True if this request is within the limit
|
|
|
|
|
*/
|
|
|
|
|
private static function checkCounter(string $bucketKey, int $limit): bool
|
|
|
|
|
{
|
|
|
|
|
$now = time();
|
|
|
|
|
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
|
|
|
|
|
|
|
|
|
|
// Hold an exclusive lock across the whole read-modify-write so concurrent
|
|
|
|
|
// requests from the same IP can't both read the same count and each write
|
|
|
|
|
// count+1 (which would undercount and let the limit be exceeded).
|
|
|
|
|
// requests from the same bucket can't both read the same count and each
|
|
|
|
|
// write count+1 (which would undercount and let the limit be exceeded).
|
|
|
|
|
$fh = @fopen($filePath, 'c+');
|
|
|
|
|
if ($fh === false) {
|
|
|
|
|
// Can't open the counter file — fail open (don't block legitimate traffic).
|
|
|
|
@@ -122,10 +158,60 @@ class RateLimitMiddleware
|
|
|
|
|
flock($fh, LOCK_UN);
|
|
|
|
|
fclose($fh);
|
|
|
|
|
|
|
|
|
|
// Check if over limit
|
|
|
|
|
return $rateData['count'] <= $limit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Read (without incrementing) the current state of a counter bucket, for
|
|
|
|
|
* status/header reporting.
|
|
|
|
|
*/
|
|
|
|
|
private static function peekCounter(string $bucketKey, int $limit): array
|
|
|
|
|
{
|
|
|
|
|
$now = time();
|
|
|
|
|
$filePath = self::getRateLimitDir() . '/' . $bucketKey . '.json';
|
|
|
|
|
|
|
|
|
|
$rateData = null;
|
|
|
|
|
$content = @file_get_contents($filePath);
|
|
|
|
|
if ($content !== false && $content !== '') {
|
|
|
|
|
$decoded = json_decode($content, true);
|
|
|
|
|
if (is_array($decoded)) {
|
|
|
|
|
$rateData = $decoded;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($rateData === null || $now - ($rateData['window_start'] ?? $now) >= self::WINDOW_SECONDS) {
|
|
|
|
|
return ['limit' => $limit, 'remaining' => $limit, 'reset' => $now + self::WINDOW_SECONDS];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'limit' => $limit,
|
|
|
|
|
'remaining' => max(0, $limit - $rateData['count']),
|
|
|
|
|
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function ipBucketKey(string $type): string
|
|
|
|
|
{
|
|
|
|
|
return hash('sha256', self::getClientIp() . '_' . $type);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static function apiKeyBucketKey(string $token): string
|
|
|
|
|
{
|
|
|
|
|
return hash('sha256', 'apikey_' . $token);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check IP-based rate limit
|
|
|
|
|
*
|
|
|
|
|
* @param string $type 'default' or 'api'
|
|
|
|
|
* @return bool True if request is allowed, false if rate limited
|
|
|
|
|
*/
|
|
|
|
|
private static function checkIpRateLimit(string $type = 'default'): bool
|
|
|
|
|
{
|
|
|
|
|
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
|
|
|
|
return self::checkCounter(self::ipBucketKey($type), $limit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clean up old rate limit files (call periodically)
|
|
|
|
|
*
|
|
|
|
@@ -185,7 +271,14 @@ class RateLimitMiddleware
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check rate limit for current request (both session and IP)
|
|
|
|
|
* Check rate limit for current request.
|
|
|
|
|
*
|
|
|
|
|
* Bearer-authenticated requests (Authorization: Bearer ...) are limited
|
|
|
|
|
* by a per-token bucket instead of a session — a stateless API client
|
|
|
|
|
* never sends a session cookie back, so the session-based counter never
|
|
|
|
|
* accumulates and starting a session for it is pure overhead. The
|
|
|
|
|
* IP-based bucket still applies underneath as defense-in-depth against
|
|
|
|
|
* volumetric abuse from one network path.
|
|
|
|
|
*
|
|
|
|
|
* @param string $type 'default' or 'api'
|
|
|
|
|
* @return bool True if request is allowed, false if rate limited
|
|
|
|
@@ -197,12 +290,17 @@ class RateLimitMiddleware
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$token = self::getBearerToken();
|
|
|
|
|
if ($token !== null) {
|
|
|
|
|
return self::checkCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Then check session-based rate limit
|
|
|
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
|
|
|
session_start();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
|
|
|
|
$limit = self::sessionLimit($type);
|
|
|
|
|
$key = 'rate_limit_' . $type;
|
|
|
|
|
$now = time();
|
|
|
|
|
|
|
|
|
@@ -270,18 +368,28 @@ class RateLimitMiddleware
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current rate limit status
|
|
|
|
|
* Get current rate limit status.
|
|
|
|
|
*
|
|
|
|
|
* For a Bearer-authenticated request, reports the per-API-key bucket
|
|
|
|
|
* (the one that actually governs it) rather than the session-based
|
|
|
|
|
* counter, which is meaningless for a client that never sends a session
|
|
|
|
|
* cookie back.
|
|
|
|
|
*
|
|
|
|
|
* @param string $type 'default' or 'api'
|
|
|
|
|
* @return array Rate limit status
|
|
|
|
|
*/
|
|
|
|
|
public static function getStatus(string $type = 'default'): array
|
|
|
|
|
{
|
|
|
|
|
$token = self::getBearerToken();
|
|
|
|
|
if ($token !== null) {
|
|
|
|
|
return self::peekCounter(self::apiKeyBucketKey($token), self::API_KEY_LIMIT);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
|
|
|
session_start();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
|
|
|
|
$limit = self::sessionLimit($type);
|
|
|
|
|
$key = 'rate_limit_' . $type;
|
|
|
|
|
$now = time();
|
|
|
|
|
|
|
|
|
|