mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 11:09:18 +02:00
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:
6
TODO.md
6
TODO.md
@@ -28,6 +28,6 @@
|
|||||||
- [x] Implement delete link action
|
- [x] Implement delete link action
|
||||||
- [x] Copy-to-clipboard button for full partage URL
|
- [x] Copy-to-clipboard button for full partage URL
|
||||||
|
|
||||||
- [ ] Security and validation considerations
|
- [x] Security and validation considerations
|
||||||
- [ ] Rate limiting on form submissions per share link
|
- [x] Rate limiting on form submissions per share link — integrate RateLimit into partage index.php POST handler
|
||||||
- [ ] Add flash messages / error handling for invalid/disabled/password-protected links
|
- [x] Add flash messages / error handling for invalid/disabled/password-protected links — replace plain die() with styled error pages and flash messages
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ $action = $parts[1] ?? '';
|
|||||||
|
|
||||||
// Validate slug format: YYYYMMDD-XXXXXXXX (17 chars)
|
// Validate slug format: YYYYMMDD-XXXXXXXX (17 chars)
|
||||||
if (!preg_match('/^\d{8}-[A-Z2-7]{8}$/', $slug)) {
|
if (!preg_match('/^\d{8}-[A-Z2-7]{8}$/', $slug)) {
|
||||||
http_response_code(404);
|
App::boot();
|
||||||
die('Lien invalide.');
|
$_SESSION['_flash_error'] = 'Ce lien de partage n\'est pas valide.';
|
||||||
|
header('Location: /');
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST: form submission ─────────────────────────────────────────────────────
|
// ── POST: form submission ─────────────────────────────────────────────────────
|
||||||
@@ -45,18 +47,19 @@ if (!$validationResult['valid']) {
|
|||||||
$reason = $validationResult['reason'];
|
$reason = $validationResult['reason'];
|
||||||
|
|
||||||
if ($reason === 'not_found') {
|
if ($reason === 'not_found') {
|
||||||
http_response_code(404);
|
$_SESSION['_flash_error'] = 'Ce lien de partage n\'existe pas ou a été supprimé.';
|
||||||
die('Ce lien de partage n\'existe pas ou a été supprimé.');
|
header('Location: /');
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($reason === 'disabled') {
|
if ($reason === 'disabled') {
|
||||||
http_response_code(403);
|
renderShareLinkError('Lien désactivé', 'Ce lien de partage a été désactivé par un administrateur.');
|
||||||
die('Ce lien de partage a été désactivé.');
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($reason === 'expired') {
|
if ($reason === 'expired') {
|
||||||
http_response_code(403);
|
renderShareLinkError('Lien expiré', 'Ce lien de partage a expiré.');
|
||||||
die('Ce lien de partage a expiré.');
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($reason === 'needs_password') {
|
if ($reason === 'needs_password') {
|
||||||
@@ -66,8 +69,9 @@ if (!$validationResult['valid']) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
http_response_code(400);
|
$_SESSION['_flash_error'] = 'Erreur inattendue.';
|
||||||
die('Erreur inattendue.');
|
header('Location: /');
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link is valid — render the form
|
// Link is valid — render the form
|
||||||
@@ -76,10 +80,66 @@ renderShareLinkForm($slug, $link);
|
|||||||
|
|
||||||
// ── Functions ─────────────────────────────────────────────────────────────────
|
// ── 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
|
function requirePasswordGate(array $link, string $slug): void
|
||||||
{
|
{
|
||||||
$error = null;
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_password'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_password'])) {
|
||||||
require_once APP_ROOT . '/src/ShareLink.php';
|
require_once APP_ROOT . '/src/ShareLink.php';
|
||||||
$shareLinkModel = new ShareLink(Database::getInstance());
|
$shareLinkModel = new ShareLink(Database::getInstance());
|
||||||
@@ -91,11 +151,18 @@ function requirePasswordGate(array $link, string $slug): void
|
|||||||
header('Location: /partage/' . $slug);
|
header('Location: /partage/' . $slug);
|
||||||
exit;
|
exit;
|
||||||
} else {
|
} else {
|
||||||
$error = 'Mot de passe incorrect.';
|
$_SESSION['_flash_error'] = 'Mot de passe incorrect.';
|
||||||
|
header('Location: /partage/' . $slug);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$pageTitle = 'Accès protégé';
|
$pageTitle = 'Accès protégé';
|
||||||
|
|
||||||
|
// Consume flash errors from wrong-password redirects
|
||||||
|
$flashError = $_SESSION['_flash_error'] ?? null;
|
||||||
|
unset($_SESSION['_flash_error']);
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
@@ -132,8 +199,8 @@ function requirePasswordGate(array $link, string $slug): void
|
|||||||
<body>
|
<body>
|
||||||
<div class="password-gate">
|
<div class="password-gate">
|
||||||
<h1>🔒 Accès protégé</h1>
|
<h1>🔒 Accès protégé</h1>
|
||||||
<?php if ($error): ?>
|
<?php if ($flashError): ?>
|
||||||
<p class="password-error"><?= htmlspecialchars($error) ?></p>
|
<p class="password-error"><?= htmlspecialchars($flashError) ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
|
||||||
@@ -416,28 +483,62 @@ function handleShareLinkSubmission(string $slug): void
|
|||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
require_once APP_ROOT . '/src/ShareLink.php';
|
require_once APP_ROOT . '/src/ShareLink.php';
|
||||||
|
require_once APP_ROOT . '/src/RateLimit.php';
|
||||||
|
|
||||||
$shareLinkModel = new ShareLink(Database::getInstance());
|
$shareLinkModel = new ShareLink(Database::getInstance());
|
||||||
|
|
||||||
$link = $shareLinkModel->findBySlug($slug);
|
$link = $shareLinkModel->findBySlug($slug);
|
||||||
|
|
||||||
if ($link === null || !$link['is_active'] || ($link['expires_at'] !== null && strtotime($link['expires_at']) < time())) {
|
if ($link === null || !$link['is_active'] || ($link['expires_at'] !== null && strtotime($link['expires_at']) < time())) {
|
||||||
http_response_code(403);
|
$_SESSION['_flash_error'] = 'Ce lien n\'est plus valide.';
|
||||||
die('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
|
// Check password verification if link has a password
|
||||||
if ($link['password_hash'] !== null && empty($_SESSION['share_verified_' . $slug])) {
|
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 (isset($_POST['share_password_submit'])) {
|
||||||
if ($shareLinkModel->verifyPassword($link, $_POST['share_password_submit'])) {
|
if ($shareLinkModel->verifyPassword($link, $_POST['share_password_submit'])) {
|
||||||
$_SESSION['share_verified_' . $slug] = true;
|
$_SESSION['share_verified_' . $slug] = true;
|
||||||
} else {
|
} else {
|
||||||
http_response_code(403);
|
$_SESSION['_flash_error'] = 'Mot de passe incorrect.';
|
||||||
die('Mot de passe incorrect.');
|
header('Location: /partage/' . urlencode($slug));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
http_response_code(403);
|
$_SESSION['_flash_error'] = 'Vous devez entrer le mot de passe avant de soumettre le formulaire.';
|
||||||
die('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])
|
if (!isset($_POST['share_link_token'], $_SESSION[$shareCsrfKey])
|
||||||
|| !hash_equals($_SESSION[$shareCsrfKey], $_POST['share_link_token'])) {
|
|| !hash_equals($_SESSION[$shareCsrfKey], $_POST['share_link_token'])) {
|
||||||
error_log('Share link CSRF validation failed for ' . $slug);
|
error_log('Share link CSRF validation failed for ' . $slug);
|
||||||
http_response_code(403);
|
$_SESSION['_flash_error'] = 'Token de sécurité invalide.';
|
||||||
die('Token de sécurité invalide.');
|
header('Location: /partage/' . urlencode($slug));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
require_once APP_ROOT . '/src/ThesisCreateController.php';
|
require_once APP_ROOT . '/src/ThesisCreateController.php';
|
||||||
@@ -457,7 +559,6 @@ function handleShareLinkSubmission(string $slug): void
|
|||||||
$thesisId = $ctrl->submit($_POST, $_FILES);
|
$thesisId = $ctrl->submit($_POST, $_FILES);
|
||||||
|
|
||||||
// Mark the link as used
|
// Mark the link as used
|
||||||
require_once APP_ROOT . '/src/ShareLink.php';
|
|
||||||
$shareLinkModel = new ShareLink(Database::getInstance());
|
$shareLinkModel = new ShareLink(Database::getInstance());
|
||||||
$shareLinkModel->incrementUsage($link['id']);
|
$shareLinkModel->incrementUsage($link['id']);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user