Rate Limiter

Overview

The RateLimiter class automatically enforces a per-IP request quota on every request before any application code executes. When a visitor exceeds the configured limit it throws an HttpException(429 Too Many Requests) — optionally banning repeat offenders for a configurable duration.

  • Class: JiFramework\Core\Security\RateLimiter
  • Access: $app->rateLimiter
  • Runs: automatically inside App::__construct() — no manual call needed
  • Storage: SQLite file at storage/RateLimit/rate_limit.db

Rate limiting is opt-in — it does nothing until you set rate_limit_enabled = true in your config. When disabled, the class is fully inert: no database is opened and every method is a safe no-op.

How it works:

  • Each incoming request is checked against a sliding time window (default: 60 seconds).
  • If the visitor's request count exceeds the limit and banning is enabled, their IP is written to the bans table and all subsequent requests are rejected until the ban expires.
  • If the ProxyCheck API or SQLite backend becomes unavailable, the class disables itself and fails open — all requests are allowed rather than crashing the app.
  • Garbage collection (cleaning up expired rows) runs probabilistically on roughly 1% of requests so there is no fixed overhead on every call.
// Rate limiting runs automatically — no code needed in index.php.
// The App constructor handles everything:
$app = new App(); // 429 thrown here if visitor is rate-limited or banned

// You can also interact with the limiter directly:
$remaining = $app->rateLimiter->getRemainingRequests();

Configuration

config/jiconfig.php keys

All rate-limiter settings live in your config/jiconfig.php. None are required — every key has a safe default.

  • rate_limit_enabled(bool) Master switch. Set to true to activate. Default: false
  • rate_limit_requests(int) Maximum number of requests allowed per IP per window. Default: 500
  • rate_limit_time_window(int) Sliding window length in seconds. Default: 60
  • rate_limit_ban_enabled(bool) Ban IPs that exceed the limit instead of just rejecting the single request. Default: true
  • rate_limit_ban_duration(int) How long a ban lasts in seconds. Default: 3600 (1 hour)
  • rate_limit_database_path(string) Custom path for the SQLite database file. Default: storage/RateLimit/rate_limit.db
// config/jiconfig.php
return [
    'rate_limit_enabled'     => true,
    'rate_limit_requests'    => 100,   // 100 requests ...
    'rate_limit_time_window' => 60,    // ... per 60 seconds
    'rate_limit_ban_enabled' => true,
    'rate_limit_ban_duration'=> 3600,  // ban for 1 hour
];

Tip: Start with a generous limit (e.g. 500 requests per minute) and tighten it once you have real traffic data. A limit that is too low will frustrate legitimate users.

enforceRateLimit()

enforceRateLimit(): void

Checks the current visitor's IP against the request log and ban table, then either allows the request or throws HttpException(429). This method is called automatically by the framework — you do not need to call it yourself.

Behaviour:

  • Does nothing when rate_limit_enabled = false or when the SQLite backend is unavailable (fail open).
  • If the IP is actively banned, throws HttpException(429) with the remaining ban time in seconds.
  • If the IP has exceeded the request limit and banning is enabled, bans the IP then throws HttpException(429).
  • If the IP has exceeded the request limit and banning is disabled, throws HttpException(429) for this request only (no ban recorded).
  • If the request is allowed, records a timestamp in the requests table and returns.

Throws: HttpException(429)

// Called automatically — no code needed:
$app = new App(); // enforceRateLimit() runs here

// The ErrorHandler catches the exception and renders the 429 page.
// In production mode the generic "Too Many Requests" page is shown.
// In development mode the full message is displayed.

isBannedIp()

isBannedIp(string $ip): bool

Check whether a specific IP address is currently under an active ban. Returns false when the ban has expired, the IP was never banned, or the backend is unavailable.

  • $ip(string) The IP address to check.

Returns: booltrue if an active (non-expired) ban exists, false otherwise.

if ($app->rateLimiter->isBannedIp('203.0.113.42')) {
    echo 'This IP is currently banned.';
}

banIp()

banIp(string $ip, ?int $duration = null): bool

Manually ban an IP address for a given duration. If the IP is already banned the existing ban is replaced (effectively extending or shortening it). Useful for admin panels, login-failure handlers, or automated abuse detection.

  • $ip(string) The IP address to ban.
  • $duration(int|null) Ban length in seconds. Defaults to Config::$rateLimitBanDuration when null.

Returns: booltrue on success, false when the backend is unavailable.

// Ban for the default duration (from config)
$app->rateLimiter->banIp('203.0.113.42');

// Ban for a specific duration
$app->rateLimiter->banIp('203.0.113.42', 86400); // 24 hours

unbanIp()

unbanIp(string $ip): bool

Remove an active ban for an IP address. This operation is idempotent — calling it on an IP that is not banned returns true without error.

  • $ip(string) The IP address to unban.

Returns: booltrue on success, false when the backend is unavailable.

// Admin lifts a ban
$app->rateLimiter->unbanIp('203.0.113.42');

// Safe to call even if the IP is not currently banned
$app->rateLimiter->unbanIp('10.0.0.1'); // returns true, does nothing

getBanInfo()

getBanInfo(string $ip): array|null

Return detailed information about an active ban. Returns null if the IP is not currently banned or the backend is unavailable.

  • $ip(string) The IP address to query.

Returns: array|null — associative array on an active ban, null otherwise.

The returned array contains:

  • ip(string) The banned IP address.
  • ban_expires(int) Unix timestamp when the ban expires.
  • seconds_remaining(int) Seconds until the ban lifts. Always >= 0.
$info = $app->rateLimiter->getBanInfo('203.0.113.42');

if ($info !== null) {
    echo 'Banned until ' . date('Y-m-d H:i:s', $info['ban_expires']);
    echo ' (' . $info['seconds_remaining'] . ' seconds remaining)';
} else {
    echo 'IP is not banned.';
}

getRemainingRequests()

getRemainingRequests(?string $ip = null): int

Return how many requests the given IP can still make within the current time window. When no IP is supplied, the current request's IP is used. Returns the full limit when the backend is unavailable.

  • $ip(string|null) IP address to check. Uses the current visitor's IP when null.

Returns: int — remaining requests in the current window. Always >= 0.

// Current visitor
$left = $app->rateLimiter->getRemainingRequests();
header('X-RateLimit-Remaining: ' . $left);

// Specific IP (e.g. for an admin dashboard)
$left = $app->rateLimiter->getRemainingRequests('203.0.113.42');
echo '203.0.113.42 has ' . $left . ' requests remaining.';

resetIp()

resetIp(string $ip): bool

Clear all rate limit data for an IP address in one atomic operation. Deletes both the request history and any active ban. Useful for admin panels that need to give a visitor a clean slate.

  • $ip(string) The IP address to reset.

Returns: booltrue on success, false when the backend is unavailable or a database error occurs.

// Fully reset a visitor (removes ban + clears request history)
$ok = $app->rateLimiter->resetIp('203.0.113.42');

if ($ok) {
    echo 'Rate limit data cleared. Visitor can make ' . Config::$rateLimitRequests . ' fresh requests.';
}

Note: resetIp() uses a database transaction — either both tables are cleared or neither is, preventing partial state.

Localhost Testing

Testing IP features on localhost

When developing locally REMOTE_ADDR is always 127.0.0.1 or ::1, which makes it impossible to test IP banning, rate limiting per user, or access control rules without deploying to a real server.

The debug_ip config key solves this. Set it to any IP address and the framework will use that IP for all IP-based checks — rate limiting, access control, and any direct calls to $app->request->getClientIp().

// config/jiconfig.php
return [
    'app_mode' => 'development',
    'debug_ip'  => '203.0.113.42',  // your real public IP or any test IP
];

Safety rules:

  • debug_ip is only honoured when app_mode = development. In production it is silently ignored and a warning is written to the log.
  • Never commit a jiconfig.php with debug_ip set to a real IP address.
  • Use a documentation-range IP (e.g. 203.0.113.x from RFC 5737) during development so it is clearly fictional.

You can also use it alongside trusted_proxies to simulate running behind a load balancer:

// config/jiconfig.php
return [
    'app_mode'        => 'development',
    'trusted_proxies' => ['127.0.0.1'],     // simulate: request arrives via local proxy
    'debug_ip'        => '203.0.113.42',   // the "real" client IP you want to test
];

Examples

Add rate-limit headers to every response

// index.php — after $app = new App()
$remaining = $app->rateLimiter->getRemainingRequests();
header('X-RateLimit-Limit: '     . Config::$rateLimitRequests);
header('X-RateLimit-Remaining: ' . $remaining);

Auto-ban after repeated failed logins

// pages/login.php
$ip = $app->request->getClientIp();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $ok = $app->auth->loginUser($_POST['email'], $_POST['password']);

    if (!$ok) {
        $cacheKey = 'login_fails_' . md5($ip);
        $fails    = (int) ($app->cache->get($cacheKey) ?? 0) + 1;
        $app->cache->set($cacheKey, $fails, 900); // track for 15 minutes

        if ($fails >= 5) {
            $app->rateLimiter->banIp($ip, 3600); // ban for 1 hour
            $app->logger->warning('IP banned after {n} failed logins: {ip}', [
                'n'  => $fails,
                'ip' => $ip,
            ]);
            $app->abort(429, 'Too many failed attempts. Try again in 1 hour.');
        }
    }
}

Admin panel — view and manage bans

// pages/admin/rate-limits.php
$action = $_POST['action'] ?? '';
$ip     = trim($_POST['ip'] ?? '');

if ($action === 'ban' && $ip) {
    $app->rateLimiter->banIp($ip, 86400);
    $msg = "$ip banned for 24 hours.";
}

if ($action === 'unban' && $ip) {
    $app->rateLimiter->unbanIp($ip);
    $msg = "$ip has been unbanned.";
}

if ($action === 'reset' && $ip) {
    $app->rateLimiter->resetIp($ip);
    $msg = "All rate limit data cleared for $ip.";
}

// Show ban info for a looked-up IP
if ($ip) {
    $info = $app->rateLimiter->getBanInfo($ip);
    if ($info) {
        echo "$ip is banned for another {$info['seconds_remaining']}s.";
    } else {
        $left = $app->rateLimiter->getRemainingRequests($ip);
        echo "$ip has $left requests remaining in the current window.";
    }
}

Tighter limits on a public API endpoint

// api/search.php — stricter limit than the global default
use JiFrameworkConfigConfig;

// Temporarily tighten the limit for this endpoint only
$origRequests = Config::$rateLimitRequests;
$origWindow   = Config::$rateLimitTimeWindow;

Config::$rateLimitRequests   = 10;  // only 10 search requests ...
Config::$rateLimitTimeWindow = 60;  // ... per minute

$app->rateLimiter->enforceRateLimit();

// Restore global settings
Config::$rateLimitRequests   = $origRequests;
Config::$rateLimitTimeWindow = $origWindow;

// ... handle the search request