From 94dbb795cc3fe9799d34beb5d6bfa052eba81b0c Mon Sep 17 00:00:00 2001 From: Thomas Vanbesien Date: Sun, 22 Mar 2026 13:57:45 +0100 Subject: Add rate limiting on login and password reset endpoints Track attempts per IP in a rate_limits table with a sliding time window. Login allows 5 failed attempts per 15 min, password reset allows 3 requests per 15 min. Old entries are purged automatically. --- README.md | 14 ++++++++ docker/mariadb/init.sql | 10 ++++++ src/app/Controllers/AuthController.php | 32 ++++++++++++++++++ src/app/RateLimiter.php | 60 ++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+) create mode 100644 src/app/RateLimiter.php diff --git a/README.md b/README.md index 57a2e2a..9056897 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,20 @@ Nginx adds HTTP response headers that instruct the browser to enforce additional When a login fails, our error message says "Invalid username or password" rather than "Username not found" or "Wrong password". Similarly, the forgot-password form always says "If that email is registered, a reset link has been sent." This is deliberate: if the server told you *which* part was wrong, an attacker could probe the system to build a list of valid usernames or emails, then target those accounts specifically with brute-force or phishing attacks. +### Rate limiting (brute-force protection) + +Without rate limiting, an attacker can try thousands of passwords per second against a login form (a brute-force attack), or flood the password reset endpoint to spam a victim's inbox. Even strong passwords can be cracked given unlimited attempts — an 8-character password has a finite number of combinations, and automated tools can cycle through common passwords and variations very quickly. + +Camagru tracks attempts per IP address using a `rate_limits` database table. Each row records which IP performed which action and when. Before processing a login or password reset request, the `RateLimiter` class counts how many attempts that IP has made within a sliding time window. If the count exceeds the threshold, the request is rejected immediately — the password isn't even checked. + +**Limits enforced:** +- **Login:** 5 failed attempts per IP per 15 minutes. Only failed attempts are recorded — a successful login doesn't count against you. This means a legitimate user who knows their password is never blocked, but an attacker guessing passwords hits the wall after 5 tries. +- **Password reset:** 3 requests per IP per 15 minutes. Unlike login, *every* attempt is recorded (not just failures), because the reset endpoint doesn't reveal whether the email exists. If only "successful" resets counted, an attacker could probe for valid emails by watching which requests get rate-limited and which don't. + +**Why per-IP and not per-username?** If rate limiting were per-username, an attacker could lock out any user by deliberately failing logins against their account (a denial-of-service). Per-IP limiting means only the attacker's own IP gets blocked — the real user can still log in from their own network. The trade-off is that a distributed attack from many IPs can bypass this, but that level of attack is beyond the scope of a school project and would require infrastructure-level solutions (WAF, fail2ban, etc.). + +**Table cleanup:** to prevent the `rate_limits` table from growing indefinitely, old entries are purged on every check. Each call to `isLimited()` first deletes rows older than the time window, so only recent attempts are kept. This is a simple approach that works well for low-traffic applications — high-traffic production systems would typically use an in-memory store like Redis instead of a database table. + ### Upload security (preventing unwanted content) A classic web vulnerability is allowing users to upload arbitrary files — an attacker might upload a `.php` script disguised as an image and then request it directly to execute server-side code. Camagru prevents this with multiple layers: diff --git a/docker/mariadb/init.sql b/docker/mariadb/init.sql index 2cc0d6f..29f0733 100644 --- a/docker/mariadb/init.sql +++ b/docker/mariadb/init.sql @@ -28,6 +28,16 @@ CREATE TABLE IF NOT EXISTS likes ( FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE ); +-- Tracks actions per IP for rate limiting (e.g. failed logins, password resets). +-- Old rows are cleaned up on each check so the table doesn't grow unbounded. +CREATE TABLE IF NOT EXISTS rate_limits ( + id INT AUTO_INCREMENT PRIMARY KEY, + ip VARCHAR(45) NOT NULL, + action VARCHAR(30) NOT NULL, + attempted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_rate_limits_lookup (ip, action, attempted_at) +); + CREATE TABLE IF NOT EXISTS comments ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, diff --git a/src/app/Controllers/AuthController.php b/src/app/Controllers/AuthController.php index aad40be..d1ac746 100644 --- a/src/app/Controllers/AuthController.php +++ b/src/app/Controllers/AuthController.php @@ -9,14 +9,24 @@ use App\Csrf; use App\Flash; use App\Mail; use App\Models\User; +use App\RateLimiter; class AuthController { + // 5 failed logins per 15 minutes per IP + private const LOGIN_MAX_ATTEMPTS = 5; + private const LOGIN_WINDOW = 900; + // 3 password reset requests per 15 minutes per IP + private const RESET_MAX_ATTEMPTS = 3; + private const RESET_WINDOW = 900; + private User $user; + private RateLimiter $limiter; public function __construct() { $this->user = new User(); + $this->limiter = new RateLimiter(); } public function registerForm(): void @@ -107,6 +117,14 @@ class AuthController return; } + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + + if ($this->limiter->isLimited($ip, 'login', self::LOGIN_MAX_ATTEMPTS, self::LOGIN_WINDOW)) { + Flash::set('error', 'Too many login attempts. Please try again later.'); + header('Location: /login'); + return; + } + $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; @@ -115,6 +133,8 @@ class AuthController // Use the same error message for wrong username or wrong password // to avoid revealing which usernames exist (user enumeration) if (!$user || !password_verify($password, $user['password_hash'])) { + // Only record failed attempts — successful logins don't count + $this->limiter->record($ip, 'login'); Flash::set('error', 'Invalid username or password.'); header('Location: /login'); return; @@ -154,6 +174,18 @@ class AuthController return; } + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + + if ($this->limiter->isLimited($ip, 'reset', self::RESET_MAX_ATTEMPTS, self::RESET_WINDOW)) { + Flash::set('error', 'Too many reset requests. Please try again later.'); + header('Location: /forgot-password'); + return; + } + + // Record every attempt — even for non-existent emails, to prevent + // an attacker from probing email addresses at high speed + $this->limiter->record($ip, 'reset'); + $email = trim($_POST['email'] ?? ''); // Always show the same message whether the email exists or not diff --git a/src/app/RateLimiter.php b/src/app/RateLimiter.php new file mode 100644 index 0000000..07b46ba --- /dev/null +++ b/src/app/RateLimiter.php @@ -0,0 +1,60 @@ +pdo = Database::getInstance()->getPdo(); + } + + /** + * Check whether the given IP has exceeded the allowed number of attempts + * for an action within the time window. + */ + public function isLimited(string $ip, string $action, int $maxAttempts, int $windowSeconds): bool + { + // Clean up old entries first so the table doesn't grow forever + $this->purge($action, $windowSeconds); + + $stmt = $this->pdo->prepare( + 'SELECT COUNT(*) FROM rate_limits + WHERE ip = :ip AND action = :action + AND attempted_at > DATE_SUB(NOW(), INTERVAL :window SECOND)' + ); + $stmt->execute(['ip' => $ip, 'action' => $action, 'window' => $windowSeconds]); + + return (int) $stmt->fetchColumn() >= $maxAttempts; + } + + /** + * Record an attempt for the given IP and action. + */ + public function record(string $ip, string $action): void + { + $stmt = $this->pdo->prepare( + 'INSERT INTO rate_limits (ip, action) VALUES (:ip, :action)' + ); + $stmt->execute(['ip' => $ip, 'action' => $action]); + } + + /** + * Remove entries older than the window so the table stays small. + */ + private function purge(string $action, int $windowSeconds): void + { + $stmt = $this->pdo->prepare( + 'DELETE FROM rate_limits + WHERE action = :action + AND attempted_at <= DATE_SUB(NOW(), INTERVAL :window SECOND)' + ); + $stmt->execute(['action' => $action, 'window' => $windowSeconds]); + } +} -- cgit v1.2.3