Files
tinker_tickets/middleware/RateLimitMiddleware.php
T

435 lines
15 KiB
PHP
Raw Normal View History

<?php
/**
* Rate Limiting Middleware
*
* 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
{
// 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
*
* @return string Path to rate limit storage directory
*/
private static function getRateLimitDir(): string
{
if (self::$rateLimitDir === null) {
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
if (!is_dir(self::$rateLimitDir)) {
mkdir(self::$rateLimitDir, 0755, true);
}
}
return self::$rateLimitDir;
}
/**
* Get the client's IP address
*
* @return string Client IP address
*/
private static function getClientIp(): string
{
2026-06-30 12:00:36 -04:00
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
// Forwarded headers are client-controlled, so only believe them when the
// request actually came from a trusted reverse proxy. Otherwise a client
// could rotate X-Forwarded-For each request to escape the per-IP limit.
$trusted = $GLOBALS['config']['TRUSTED_PROXIES'] ?? [];
if (empty($trusted) || !in_array($remoteAddr, $trusted, true)) {
return $remoteAddr;
}
// The trusted proxy appends the connecting client to X-Forwarded-For, so
// the RIGHTMOST entry is the IP it observed (a client-supplied prefix is
// not trustworthy). X-Real-IP is set by the proxy itself.
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$ip = trim(end($ips));
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
2026-06-30 12:00:36 -04:00
if (!empty($_SERVER['HTTP_X_REAL_IP']) && filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)) {
return trim($_SERVER['HTTP_X_REAL_IP']);
}
return $remoteAddr;
}
/**
* 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.
*
* @return string|null
*/
private static function getBearerToken(): ?string
{
$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;
}
/**
* 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 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).
return true;
}
if (!flock($fh, LOCK_EX)) {
fclose($fh);
return true;
}
$content = stream_get_contents($fh);
$rateData = ['count' => 0, 'window_start' => $now];
if ($content !== false && $content !== '') {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$rateData = $decoded;
}
}
// Reset when the window has expired
if ($now - ($rateData['window_start'] ?? $now) >= self::WINDOW_SECONDS) {
$rateData = ['count' => 0, 'window_start' => $now];
}
$rateData['count']++;
// Rewrite the file in place while still holding the lock
rewind($fh);
ftruncate($fh, 0);
fwrite($fh, json_encode($rateData));
fflush($fh);
flock($fh, LOCK_UN);
fclose($fh);
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)
*
* Uses DirectoryIterator instead of glob() for better memory efficiency.
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
*/
public static function cleanupOldFiles(): void
{
$dir = self::getRateLimitDir();
$lockFile = $dir . '/.cleanup.lock';
$now = time();
$maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows
$maxLockAge = 60; // Release stale locks after 60 seconds
// Check for existing lock to prevent concurrent cleanups
if (file_exists($lockFile)) {
$lockAge = $now - filemtime($lockFile);
if ($lockAge < $maxLockAge) {
return; // Cleanup already in progress
}
@unlink($lockFile); // Stale lock
}
// Try to acquire lock
if (!@touch($lockFile)) {
return;
}
try {
$iterator = new DirectoryIterator($dir);
$deleted = 0;
$maxDeletes = 50; // Limit deletions per request to avoid blocking
foreach ($iterator as $file) {
if ($deleted >= $maxDeletes) {
break; // Let cron handle the rest
}
if ($file->isDot() || !$file->isFile()) {
continue;
}
$filename = $file->getFilename();
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
continue;
}
if ($now - $file->getMTime() > $maxAge) {
if (@unlink($file->getPathname())) {
$deleted++;
}
}
}
} finally {
@unlink($lockFile);
}
}
/**
* 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
*/
public static function check(string $type = 'default'): bool
{
// First check IP-based rate limit (prevents session bypass)
if (!self::checkIpRateLimit($type)) {
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 = self::sessionLimit($type);
$key = 'rate_limit_' . $type;
$now = time();
// Initialize rate limit tracking
if (!isset($_SESSION[$key])) {
$_SESSION[$key] = [
'count' => 0,
'window_start' => $now
];
}
$rateData = &$_SESSION[$key];
// Check if window has expired
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
// Reset for new window
$rateData['count'] = 0;
$rateData['window_start'] = $now;
}
// Increment request count
$rateData['count']++;
// Check if over limit
if ($rateData['count'] > $limit) {
return false;
}
return true;
}
/**
* Apply rate limiting and send error response if exceeded
*
* @param string $type 'default' or 'api'
* @param bool $addHeaders Whether to add rate limit headers to response
*/
public static function apply(string $type = 'default', bool $addHeaders = true): void
{
// Periodically clean up old rate limit files (2% chance per request)
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
if (mt_rand(1, 50) === 1) {
self::cleanupOldFiles();
}
if (!self::check($type)) {
http_response_code(429);
header('Content-Type: application/json');
header('Retry-After: ' . self::WINDOW_SECONDS);
if ($addHeaders) {
self::addHeaders($type);
}
echo json_encode([
'success' => false,
'error' => 'Rate limit exceeded. Please try again later.',
'retry_after' => self::WINDOW_SECONDS
]);
exit;
}
// Add rate limit headers to successful responses
if ($addHeaders) {
self::addHeaders($type);
}
}
/**
* 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 = self::sessionLimit($type);
$key = 'rate_limit_' . $type;
$now = time();
if (!isset($_SESSION[$key])) {
return [
'limit' => $limit,
'remaining' => $limit,
'reset' => $now + self::WINDOW_SECONDS
];
}
$rateData = $_SESSION[$key];
// Check if window has expired
if ($now - $rateData['window_start'] >= 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
];
}
/**
* Add rate limit headers to response
*
* @param string $type 'default' or 'api'
*/
public static function addHeaders(string $type = 'default'): void
{
$status = self::getStatus($type);
header('X-RateLimit-Limit: ' . $status['limit']);
header('X-RateLimit-Remaining: ' . $status['remaining']);
header('X-RateLimit-Reset: ' . $status['reset']);
}
}