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
This commit is contained in:
Pontoporeia
2026-04-16 11:50:59 +02:00
parent 150099dc3c
commit b7be93e30b
2 changed files with 129 additions and 28 deletions

View File

@@ -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

View File

@@ -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);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $pageTitle ?></title>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/system.css') ?>">
<style>
.share-error {
max-width: 500px;
margin: 5rem auto;
padding: 2.5rem;
text-align: center;
background: #fff;
border: 2px solid #d00;
border-radius: 8px;
}
.share-error h1 {
color: #d00;
font-size: 1.5rem;
margin-bottom: 1rem;
}
.share-error p {
font-size: 1.1rem;
color: #444;
margin-bottom: 1.5rem;
}
.share-error a {
display: inline-block;
padding: 0.6rem 1.5rem;
background: #333;
color: white;
text-decoration: none;
border-radius: 4px;
}
.share-error a:hover {
background: #555;
}
</style>
</head>
<body>
<div class="share-error">
<h1><?= $pageTitle ?></h1>
<p><?= htmlspecialchars($message) ?></p>
<a href="/">← Retour à l'accueil</a>
</div>
</body>
</html>
<?php
}
function requirePasswordGate(array $link, string $slug): void
{
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_password'])) {
require_once APP_ROOT . '/src/ShareLink.php';
$shareLinkModel = new ShareLink(Database::getInstance());
@@ -91,11 +151,18 @@ function requirePasswordGate(array $link, string $slug): void
header('Location: /partage/' . $slug);
exit;
} else {
$error = 'Mot de passe incorrect.';
$_SESSION['_flash_error'] = 'Mot de passe incorrect.';
header('Location: /partage/' . $slug);
exit;
}
}
$pageTitle = 'Accès protégé';
// Consume flash errors from wrong-password redirects
$flashError = $_SESSION['_flash_error'] ?? null;
unset($_SESSION['_flash_error']);
?>
<!DOCTYPE html>
<html lang="fr">
@@ -132,8 +199,8 @@ function requirePasswordGate(array $link, string $slug): void
<body>
<div class="password-gate">
<h1>🔒 Accès protégé</h1>
<?php if ($error): ?>
<p class="password-error"><?= htmlspecialchars($error) ?></p>
<?php if ($flashError): ?>
<p class="password-error"><?= htmlspecialchars($flashError) ?></p>
<?php endif; ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
@@ -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']);