aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md14
-rw-r--r--docker/mariadb/init.sql10
-rw-r--r--src/app/Controllers/AuthController.php32
-rw-r--r--src/app/RateLimiter.php60
4 files changed, 116 insertions, 0 deletions
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 @@
+<?php
+
+declare(strict_types=1);
+// Rate limiter: prevents brute-force attacks by tracking how many times an IP
+// performs a given action (e.g. login, password reset) within a sliding time window.
+
+namespace App;
+
+class RateLimiter
+{
+ private \PDO $pdo;
+
+ public function __construct()
+ {
+ $this->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]);
+ }
+}