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:
@@ -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']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user