From d6a9fd1c32f07b993cb8ecc3c1b7c22f7a0ce848 Mon Sep 17 00:00:00 2001 From: Thomas Vanbesien Date: Sun, 22 Mar 2026 13:34:47 +0100 Subject: Add upload security: size limit, per-user and site-wide post caps Reject base64 payloads over 10 MB, limit users to 50 posts each, and cap total posts at 10,000 (~650 MB on disk). Document upload security model in README. --- README.md | 20 ++++++++++++++++++++ src/app/Controllers/EditorController.php | 28 ++++++++++++++++++++++++++++ src/app/Models/Post.php | 7 +++++++ 3 files changed, 55 insertions(+) diff --git a/README.md b/README.md index 5fff70c..9043f4b 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,26 @@ A session fixation attack works like this: the attacker visits the site and gets 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. +### 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: + +1. **No direct file uploads.** Users don't upload files via a traditional `` form submission. Instead, images (from webcam or file picker) are read client-side into a base64 data URL and sent as JSON. There's no `$_FILES` handling or `move_uploaded_file()` — so the usual file upload attack vector doesn't apply. + +2. **GD re-encoding.** The base64 data is decoded and passed to `imagecreatefromstring()`, which parses it as an actual image. If the data isn't a valid image (JPEG, PNG, GIF, etc.), GD rejects it and returns `false`. Even if a valid image contains embedded malicious data (e.g. PHP code hidden in EXIF metadata or a polyglot file), the image is then re-encoded through `imagejpeg()` — this produces a clean JPEG from pixel data only, stripping any non-image content. + +3. **Size limit.** The server rejects base64 payloads larger than 10 MB before any processing, preventing memory exhaustion from oversized uploads. + +4. **Server-controlled filenames.** Output filenames are generated server-side (`{user_id}_{timestamp}_{random}.jpg`) — the user has no control over the filename or extension, so they can't trick the server into creating a `.php` file. + +5. **Front controller architecture.** Nginx routes all requests to `index.php`. Even if an attacker somehow placed a malicious file in the uploads directory, Nginx wouldn't execute it as PHP — only the front controller entry point is configured for PHP processing. + +6. **Storage capacity limits.** Without limits, a malicious user could fill up the server's disk — either from one account or by creating many accounts. Two caps are enforced before any image processing begins: + - **Per-user:** 50 posts maximum (~3.2 MB per user at ~65 KB/image) + - **Site-wide:** 10,000 posts total (~650 MB on disk) + + The per-user limit prevents a single account from monopolizing storage. The site-wide limit is the real safety net — it caps total disk usage regardless of how many accounts exist. Both are checked before image processing starts, so hitting the limit doesn't waste server CPU. + ## Image format choices - **Overlays** are PNG (alpha channel required for transparency) diff --git a/src/app/Controllers/EditorController.php b/src/app/Controllers/EditorController.php index c7dd9fc..c8cae0d 100644 --- a/src/app/Controllers/EditorController.php +++ b/src/app/Controllers/EditorController.php @@ -12,6 +12,13 @@ use App\Models\Post; class EditorController { + // Per-user and site-wide caps prevent disk exhaustion — even if someone + // creates multiple accounts, the total limit still applies. + // Each composited image is a 640×640 JPEG at quality 85, averaging ~65 KB. + // 50 posts/user ≈ 3.2 MB per user, 10 000 total ≈ 650 MB on disk. + private const MAX_POSTS_PER_USER = 50; + private const MAX_POSTS_TOTAL = 10000; + private Post $post; public function __construct() @@ -60,7 +67,28 @@ class EditorController return; } + if ($this->post->countByUserId($_SESSION['user_id']) >= self::MAX_POSTS_PER_USER) { + http_response_code(400); + echo json_encode(['error' => 'You have reached the maximum number of posts.']); + return; + } + + if ($this->post->countAll() >= self::MAX_POSTS_TOTAL) { + http_response_code(400); + echo json_encode(['error' => 'The site has reached its storage limit.']); + return; + } + $imageData = $input['image_data'] ?? ''; + + // Reject oversized payloads before doing any processing — base64 adds ~33% + // overhead, so 10 MB base64 ≈ 7.5 MB image, which is more than enough + if (\strlen($imageData) > 10 * 1024 * 1024) { + http_response_code(400); + echo json_encode(['error' => 'Image too large.']); + return; + } + $overlayName = $input['overlay'] ?? ''; $overlayScale = (float) ($input['overlay_scale'] ?? 1.0); diff --git a/src/app/Models/Post.php b/src/app/Models/Post.php index 66c8c18..e82b0d9 100644 --- a/src/app/Models/Post.php +++ b/src/app/Models/Post.php @@ -42,6 +42,13 @@ class Post return $stmt->fetchAll(); } + public function countByUserId(int $userId): int + { + $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM posts WHERE user_id = :user_id'); + $stmt->execute(['user_id' => $userId]); + return (int) $stmt->fetchColumn(); + } + public function findAllPaginated(int $limit, int $offset): array { $stmt = $this->pdo->prepare( -- cgit v1.2.3