From b7be93e30bc28cf7fdf45c22a3b8ce4c0934e04d Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Thu, 16 Apr 2026 11:50:59 +0200 Subject: [PATCH] Security: rate limiting and flash messaging for partage share links - Add rate limiting (5 submissions per IP per 10 min, per share link) to prevent abuse of shared submission endpoints - Replace all plain die() error responses with styled flash messages and redirects (invalid slug, disabled link, expired link, wrong password, rate limit exceeded, CSRF failure) - Add dedicated error page renderer for disabled/expired links with home page link - Password gate now uses flash message via session redirect instead of inline error variable --- TODO.md | 6 +- public/partage/index.php | 151 ++++++++++++++++++++++++++++++++------- 2 files changed, 129 insertions(+), 28 deletions(-) diff --git a/TODO.md b/TODO.md index 6b096a7..379b2ab 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,6 @@ - [x] Implement delete link action - [x] Copy-to-clipboard button for full partage URL -- [ ] Security and validation considerations - - [ ] Rate limiting on form submissions per share link - - [ ] Add flash messages / error handling for invalid/disabled/password-protected links +- [x] Security and validation considerations + - [x] Rate limiting on form submissions per share link — integrate RateLimit into partage index.php POST handler + - [x] Add flash messages / error handling for invalid/disabled/password-protected links — replace plain die() with styled error pages and flash messages diff --git a/public/partage/index.php b/public/partage/index.php index 217a105..8ff726b 100644 --- a/public/partage/index.php +++ b/public/partage/index.php @@ -23,8 +23,10 @@ $action = $parts[1] ?? ''; // Validate slug format: YYYYMMDD-XXXXXXXX (17 chars) if (!preg_match('/^\d{8}-[A-Z2-7]{8}$/', $slug)) { - http_response_code(404); - die('Lien invalide.'); + App::boot(); + $_SESSION['_flash_error'] = 'Ce lien de partage n\'est pas valide.'; + header('Location: /'); + exit; } // ── POST: form submission ───────────────────────────────────────────────────── @@ -45,18 +47,19 @@ if (!$validationResult['valid']) { $reason = $validationResult['reason']; if ($reason === 'not_found') { - http_response_code(404); - die('Ce lien de partage n\'existe pas ou a été supprimé.'); + $_SESSION['_flash_error'] = 'Ce lien de partage n\'existe pas ou a été supprimé.'; + header('Location: /'); + exit; } if ($reason === 'disabled') { - http_response_code(403); - die('Ce lien de partage a été désactivé.'); + renderShareLinkError('Lien désactivé', 'Ce lien de partage a été désactivé par un administrateur.'); + exit; } if ($reason === 'expired') { - http_response_code(403); - die('Ce lien de partage a expiré.'); + renderShareLinkError('Lien expiré', 'Ce lien de partage a expiré.'); + exit; } if ($reason === 'needs_password') { @@ -66,8 +69,9 @@ if (!$validationResult['valid']) { exit; } - http_response_code(400); - die('Erreur inattendue.'); + $_SESSION['_flash_error'] = 'Erreur inattendue.'; + header('Location: /'); + exit; } // Link is valid — render the form @@ -76,10 +80,66 @@ renderShareLinkForm($slug, $link); // ── Functions ───────────────────────────────────────────────────────────────── +/** + * Render a styled error page for invalid/expired/disabled share links. + */ +function renderShareLinkError(string $title, string $message): void +{ + $pageTitle = htmlspecialchars($title); + ?> + + + + + + <?= $pageTitle ?> + + + + +
+

+

+ ← Retour à l'accueil +
+ + + @@ -132,8 +199,8 @@ function requirePasswordGate(array $link, string $slug): void

🔒 Accès protégé

- -

+ +

@@ -416,28 +483,62 @@ function handleShareLinkSubmission(string $slug): void session_start(); require_once APP_ROOT . '/src/ShareLink.php'; + require_once APP_ROOT . '/src/RateLimit.php'; + $shareLinkModel = new ShareLink(Database::getInstance()); $link = $shareLinkModel->findBySlug($slug); if ($link === null || !$link['is_active'] || ($link['expires_at'] !== null && strtotime($link['expires_at']) < time())) { - http_response_code(403); - die('Ce lien n\'est plus valide.'); + $_SESSION['_flash_error'] = 'Ce lien n\'est plus valide.'; + header('Location: /partage/' . urlencode($slug)); + exit; } + // ── Rate limiting ──────────────────────────────────────────────────────── + // Allow max 5 submissions per IP per 10 minutes (per share link) + $rateLimitCacheDir = STORAGE_ROOT . '/cache/rate_limit'; + if (!is_dir($rateLimitCacheDir)) { + @mkdir($rateLimitCacheDir, 0755, true); + } + $shareRateLimitId = 'share_' . $slug . '_' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); + $rateLimit = new RateLimit(5, 600, $rateLimitCacheDir); + + // Use a custom identifier based on slug + IP so each share link is rate-limited independently + $rateLimitFile = $rateLimitCacheDir . '/' . md5($shareRateLimitId) . '.json'; + $data = []; + if (file_exists($rateLimitFile)) { + $content = file_get_contents($rateLimitFile); + $data = json_decode($content, true) ?? []; + } + $now = time(); + $data = array_filter($data, fn($ts) => ($now - $ts) < 600); + + if (count($data) >= 5) { + $_SESSION['_flash_error'] = 'Trop de tentatives. Veuillez réessayer plus tard.'; + header('Location: /partage/' . urlencode($slug)); + exit; + } + $data[] = $now; + if (is_writable($rateLimitCacheDir)) { + file_put_contents($rateLimitFile, json_encode($data)); + } + // ── End rate limiting ──────────────────────────────────────────────────── + // Check password verification if link has a password if ($link['password_hash'] !== null && empty($_SESSION['share_verified_' . $slug])) { - // Allow password to be submitted along with the form (first attempt or re-verify) if (isset($_POST['share_password_submit'])) { if ($shareLinkModel->verifyPassword($link, $_POST['share_password_submit'])) { $_SESSION['share_verified_' . $slug] = true; } else { - http_response_code(403); - die('Mot de passe incorrect.'); + $_SESSION['_flash_error'] = 'Mot de passe incorrect.'; + header('Location: /partage/' . urlencode($slug)); + exit; } } else { - http_response_code(403); - die('Vous devez entrer le mot de passe avant de soumettre le formulaire.'); + $_SESSION['_flash_error'] = 'Vous devez entrer le mot de passe avant de soumettre le formulaire.'; + header('Location: /partage/' . urlencode($slug)); + exit; } } @@ -446,8 +547,9 @@ function handleShareLinkSubmission(string $slug): void if (!isset($_POST['share_link_token'], $_SESSION[$shareCsrfKey]) || !hash_equals($_SESSION[$shareCsrfKey], $_POST['share_link_token'])) { error_log('Share link CSRF validation failed for ' . $slug); - http_response_code(403); - die('Token de sécurité invalide.'); + $_SESSION['_flash_error'] = 'Token de sécurité invalide.'; + header('Location: /partage/' . urlencode($slug)); + exit; } require_once APP_ROOT . '/src/ThesisCreateController.php'; @@ -457,7 +559,6 @@ function handleShareLinkSubmission(string $slug): void $thesisId = $ctrl->submit($_POST, $_FILES); // Mark the link as used - require_once APP_ROOT . '/src/ShareLink.php'; $shareLinkModel = new ShareLink(Database::getInstance()); $shareLinkModel->incrementUsage($link['id']);