2025-07-17 09:57:47 +04:00
|
|
|
<?php
|
|
|
|
|
/**
|
|
|
|
|
* Fonctions de sécurité pour la validation et l'assainissement des entrées
|
|
|
|
|
*/
|
|
|
|
|
|
2026-07-27 01:54:42 +04:00
|
|
|
/**
|
|
|
|
|
* Échappe une valeur pour une sortie HTML (texte ou attribut).
|
|
|
|
|
*
|
|
|
|
|
* Raccourci pour htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8') :
|
|
|
|
|
* les guillemets simples et doubles sont encodés, ce qui rend la sortie
|
|
|
|
|
* sûre aussi bien dans le contenu que dans les attributs.
|
|
|
|
|
*
|
|
|
|
|
* @param mixed $value Valeur à échapper
|
|
|
|
|
* @return string Valeur échappée
|
|
|
|
|
*/
|
|
|
|
|
function e($value) {
|
|
|
|
|
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-17 09:57:47 +04:00
|
|
|
/**
|
|
|
|
|
* Valide et assainit un ID de vidéo UUID
|
|
|
|
|
*
|
|
|
|
|
* @param string $id ID à valider
|
|
|
|
|
* @return string|false ID validé ou false si invalide
|
|
|
|
|
*/
|
|
|
|
|
function validateVideoId($id) {
|
|
|
|
|
if (empty($id)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Nettoyer l'entrée
|
|
|
|
|
$id = trim($id);
|
|
|
|
|
|
|
|
|
|
// Vérifier le format UUID (format PeerTube)
|
|
|
|
|
if (!preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $id)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Valide et assainit une requête de recherche
|
|
|
|
|
*
|
|
|
|
|
* @param string $query Requête à valider
|
|
|
|
|
* @return string|false Requête validée ou false si invalide
|
|
|
|
|
*/
|
|
|
|
|
function validateSearchQuery($query) {
|
|
|
|
|
if (empty($query)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Nettoyer l'entrée
|
|
|
|
|
$query = trim($query);
|
|
|
|
|
|
|
|
|
|
// Limiter la longueur
|
|
|
|
|
if (strlen($query) > 200) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Supprimer les caractères dangereux mais garder les caractères utiles pour la recherche
|
|
|
|
|
$query = preg_replace('/[<>"\']/', '', $query);
|
|
|
|
|
|
|
|
|
|
return $query;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Valide un numéro de page
|
|
|
|
|
*
|
|
|
|
|
* @param mixed $page Page à valider
|
|
|
|
|
* @return int Page validée (minimum 1)
|
|
|
|
|
*/
|
|
|
|
|
function validatePageNumber($page) {
|
|
|
|
|
$page = intval($page);
|
|
|
|
|
return max(1, $page);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Valide un ID de catégorie
|
|
|
|
|
*
|
|
|
|
|
* @param mixed $categoryId ID de catégorie à valider
|
|
|
|
|
* @return int|false ID validé ou false si invalide
|
|
|
|
|
*/
|
|
|
|
|
function validateCategoryId($categoryId) {
|
|
|
|
|
$categoryId = intval($categoryId);
|
|
|
|
|
|
|
|
|
|
// Les IDs de catégorie PeerTube sont entre 1 et 20
|
|
|
|
|
if ($categoryId < 1 || $categoryId > 20) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $categoryId;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 01:54:42 +04:00
|
|
|
/**
|
|
|
|
|
* Valeur par défaut livrée dans config.default.php : si CSRF_SECRET vaut
|
|
|
|
|
* encore cette valeur, le secret n'a pas été configuré pour l'instance.
|
|
|
|
|
*/
|
|
|
|
|
if (!defined('CSRF_SECRET_PLACEHOLDER')) {
|
|
|
|
|
define('CSRF_SECRET_PLACEHOLDER', 'change-me-in-config-local-php');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Retourne le secret CSRF effectif utilisé pour signer les tokens.
|
|
|
|
|
*
|
|
|
|
|
* Si CSRF_SECRET est absent, vide ou vaut encore la valeur par défaut, un
|
|
|
|
|
* avertissement critique est enregistré et un secret éphémère propre au
|
|
|
|
|
* processus est généré (bin2hex(random_bytes(32))) : les tokens restent
|
|
|
|
|
* signés, mais sont invalidés à chaque redémarrage du processus PHP.
|
|
|
|
|
*
|
|
|
|
|
* @return string Secret CSRF effectif
|
|
|
|
|
*/
|
|
|
|
|
function getCsrfSecret() {
|
|
|
|
|
static $secret = null;
|
|
|
|
|
|
|
|
|
|
if ($secret !== null) {
|
|
|
|
|
return $secret;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (defined('CSRF_SECRET') && CSRF_SECRET !== '' && CSRF_SECRET !== CSRF_SECRET_PLACEHOLDER) {
|
|
|
|
|
$secret = CSRF_SECRET;
|
|
|
|
|
return $secret;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
error_log('SECURITY CRITICAL: CSRF_SECRET is not configured (default value in use). '
|
|
|
|
|
. 'An ephemeral per-process secret was generated: CSRF tokens will be invalidated '
|
|
|
|
|
. 'on every process restart. Set CSRF_SECRET in config.local.php (bin2hex(random_bytes(32))).');
|
|
|
|
|
|
|
|
|
|
$secret = bin2hex(random_bytes(32));
|
|
|
|
|
return $secret;
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-17 09:57:47 +04:00
|
|
|
/**
|
2026-07-26 18:44:05 +04:00
|
|
|
* Génère un token CSRF stateless (HMAC + timestamp).
|
|
|
|
|
*
|
|
|
|
|
* Le token ne dépend pas de la session : il reste valide même si la page
|
|
|
|
|
* HTML est servie depuis un cache (Service Worker, CDN). Il expire après
|
|
|
|
|
* une durée limitée.
|
|
|
|
|
*
|
|
|
|
|
* @return string Token CSRF au format "timestamp:hash"
|
2025-07-17 09:57:47 +04:00
|
|
|
*/
|
|
|
|
|
function generateCSRFToken() {
|
2026-07-26 18:44:05 +04:00
|
|
|
$timestamp = time();
|
2026-07-27 01:54:42 +04:00
|
|
|
$hash = hash_hmac('sha256', (string) $timestamp, getCsrfSecret());
|
2026-07-26 18:44:05 +04:00
|
|
|
return $timestamp . ':' . $hash;
|
2025-07-17 09:57:47 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-26 18:44:05 +04:00
|
|
|
* Valide un token CSRF stateless
|
|
|
|
|
*
|
2025-07-17 09:57:47 +04:00
|
|
|
* @param string $token Token à valider
|
2026-07-26 18:44:05 +04:00
|
|
|
* @return bool True si le token est valide et non expiré
|
2025-07-17 09:57:47 +04:00
|
|
|
*/
|
|
|
|
|
function validateCSRFToken($token) {
|
2026-07-26 18:44:05 +04:00
|
|
|
if (empty($token) || !is_string($token)) {
|
|
|
|
|
return false;
|
2025-07-17 09:57:47 +04:00
|
|
|
}
|
2026-07-26 18:44:05 +04:00
|
|
|
|
|
|
|
|
$parts = explode(':', $token, 2);
|
|
|
|
|
if (count($parts) !== 2) {
|
2025-07-17 09:57:47 +04:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-07-26 18:44:05 +04:00
|
|
|
|
|
|
|
|
[$timestamp, $hash] = $parts;
|
|
|
|
|
|
|
|
|
|
// Vérifier que le timestamp est numérique et pas trop ancien (1 heure)
|
|
|
|
|
if (!ctype_digit($timestamp)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$age = abs(time() - (int) $timestamp);
|
|
|
|
|
if ($age > 3600) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 01:54:42 +04:00
|
|
|
$expectedHash = hash_hmac('sha256', $timestamp, getCsrfSecret());
|
2026-07-26 18:44:05 +04:00
|
|
|
return hash_equals($expectedHash, $hash);
|
2025-07-17 09:57:47 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-08 07:33:40 +04:00
|
|
|
/**
|
|
|
|
|
* Génère ou récupère le nonce CSP de la requête courante.
|
|
|
|
|
* Doit être appelé après setSecurityHeaders() pour que le nonce soit envoyé dans le header CSP.
|
|
|
|
|
*
|
|
|
|
|
* @return string Nonce CSP
|
|
|
|
|
*/
|
|
|
|
|
function getCspNonce() {
|
|
|
|
|
if (!isset($GLOBALS['csp_nonce'])) {
|
|
|
|
|
$GLOBALS['csp_nonce'] = base64_encode(random_bytes(16));
|
|
|
|
|
}
|
|
|
|
|
return $GLOBALS['csp_nonce'];
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-17 09:57:47 +04:00
|
|
|
/**
|
2026-07-27 09:14:52 +04:00
|
|
|
* Extrait l'origine « scheme://host » d'une URL configurée (pour la CSP).
|
|
|
|
|
*
|
|
|
|
|
* @param string $url URL à analyser
|
|
|
|
|
* @return string Origine normalisée, ou chaîne vide si l'URL est vide/invalide
|
2025-07-17 09:57:47 +04:00
|
|
|
*/
|
2026-07-27 09:14:52 +04:00
|
|
|
function cspOriginFromUrl($url) {
|
|
|
|
|
if (empty($url)) {
|
|
|
|
|
return '';
|
2025-07-17 09:57:47 +04:00
|
|
|
}
|
2026-07-27 09:14:52 +04:00
|
|
|
$parsed = parse_url($url);
|
|
|
|
|
if (!$parsed || !isset($parsed['scheme'], $parsed['host'])) {
|
|
|
|
|
return '';
|
2025-07-17 09:57:47 +04:00
|
|
|
}
|
2026-07-27 09:14:52 +04:00
|
|
|
return $parsed['scheme'] . '://' . $parsed['host'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Construit la Content Security Policy de la requête courante.
|
|
|
|
|
*
|
|
|
|
|
* img-src et media-src sont restreints aux domaines réellement utilisés :
|
|
|
|
|
* PeerTube (vignettes), Mastodon (avatars/médias) et son S3 éventuel,
|
|
|
|
|
* Castopod (pochettes/flux audio), Funkwhale (pochettes/flux audio) et
|
|
|
|
|
* WordPress (images mises en avant). En développement local, HTTP(S) général
|
|
|
|
|
* reste autorisé pour faciliter les tests avec du contenu fédéré.
|
|
|
|
|
*
|
|
|
|
|
* @param string $nonce Nonce CSP de la requête
|
|
|
|
|
* @return string Politique CSP complète
|
|
|
|
|
*/
|
|
|
|
|
function buildContentSecurityPolicy($nonce) {
|
|
|
|
|
$mastodonDomain = defined('MASTODON_INSTANCE_URL') ? cspOriginFromUrl(MASTODON_INSTANCE_URL) : '';
|
|
|
|
|
$peertubeDomain = defined('PEERTUBE_URL') ? cspOriginFromUrl(PEERTUBE_URL) : '';
|
|
|
|
|
$castopodDomain = (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL'))
|
|
|
|
|
? cspOriginFromUrl(CASTOPOD_URL) : '';
|
|
|
|
|
$funkwhaleDomain = (defined('FUNKWHALE_ENABLED') && FUNKWHALE_ENABLED && defined('FUNKWHALE_URL'))
|
|
|
|
|
? cspOriginFromUrl(FUNKWHALE_URL) : '';
|
|
|
|
|
$wordpressDomain = defined('WORDPRESS_URL') ? cspOriginFromUrl(WORDPRESS_URL) : '';
|
|
|
|
|
$s3Domain = defined('MASTODON_S3_MEDIA_URL') ? cspOriginFromUrl(MASTODON_S3_MEDIA_URL) : '';
|
2026-07-08 07:33:40 +04:00
|
|
|
|
2025-07-17 09:57:47 +04:00
|
|
|
// Détecter si on est en développement local
|
|
|
|
|
$isLocalDev = in_array($_SERVER['HTTP_HOST'] ?? '', ['127.0.0.1:8080', '127.0.0.1:8001', 'localhost:8080', 'localhost:8001', '127.0.0.1', 'localhost']);
|
2026-07-08 07:33:40 +04:00
|
|
|
|
2025-07-17 09:57:47 +04:00
|
|
|
$csp = "default-src 'self'; ";
|
2026-07-08 07:33:40 +04:00
|
|
|
$csp .= "style-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com; ";
|
|
|
|
|
$csp .= "script-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com https://plausible.io; ";
|
|
|
|
|
|
2026-07-27 09:14:52 +04:00
|
|
|
// Images : uniquement les services réellement affichés (https: général en dev uniquement)
|
|
|
|
|
$imgSrc = "'self' data:";
|
|
|
|
|
foreach ([$mastodonDomain, $peertubeDomain, $castopodDomain, $funkwhaleDomain, $wordpressDomain, $s3Domain] as $domain) {
|
|
|
|
|
if ($domain !== '') {
|
|
|
|
|
$imgSrc .= ' ' . $domain;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-07-17 09:57:47 +04:00
|
|
|
if ($isLocalDev) {
|
2026-07-27 09:14:52 +04:00
|
|
|
$imgSrc .= ' https: http:';
|
2025-07-17 09:57:47 +04:00
|
|
|
}
|
|
|
|
|
$csp .= "img-src " . $imgSrc . "; ";
|
2026-07-08 07:33:40 +04:00
|
|
|
|
2025-07-17 09:57:47 +04:00
|
|
|
$csp .= "font-src 'self' https://cdnjs.cloudflare.com; ";
|
2026-07-08 07:33:40 +04:00
|
|
|
|
|
|
|
|
// Frames : autoriser PeerTube uniquement
|
2026-07-27 09:14:52 +04:00
|
|
|
$frameSrc = "'self'" . ($peertubeDomain !== '' ? ' ' . $peertubeDomain : '');
|
2025-07-17 09:57:47 +04:00
|
|
|
if ($isLocalDev) {
|
2026-07-27 09:14:52 +04:00
|
|
|
$frameSrc .= ' https: http:';
|
2025-07-17 09:57:47 +04:00
|
|
|
}
|
|
|
|
|
$csp .= "frame-src " . $frameSrc . "; ";
|
2026-07-08 07:33:40 +04:00
|
|
|
|
|
|
|
|
// Connexions : autoriser Mastodon, PeerTube et Plausible
|
2026-07-27 09:14:52 +04:00
|
|
|
$connectSrc = "'self' https://plausible.io";
|
|
|
|
|
foreach ([$mastodonDomain, $peertubeDomain] as $domain) {
|
|
|
|
|
if ($domain !== '') {
|
|
|
|
|
$connectSrc .= ' ' . $domain;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-07-17 09:57:47 +04:00
|
|
|
if ($isLocalDev) {
|
2026-07-27 09:14:52 +04:00
|
|
|
$connectSrc .= ' ws: wss:'; // WebSockets pour le dev
|
2025-07-17 09:57:47 +04:00
|
|
|
}
|
|
|
|
|
$csp .= "connect-src " . $connectSrc . "; ";
|
2026-07-08 07:33:40 +04:00
|
|
|
|
2026-07-27 09:14:52 +04:00
|
|
|
// Médias : flux audio Castopod/Funkwhale, médias Mastodon (instance ou S3)
|
2025-09-29 18:58:14 +04:00
|
|
|
$mediaSrc = "'self'";
|
2026-07-27 09:14:52 +04:00
|
|
|
foreach ([$mastodonDomain, $peertubeDomain, $castopodDomain, $funkwhaleDomain, $s3Domain] as $domain) {
|
|
|
|
|
if ($domain !== '') {
|
|
|
|
|
$mediaSrc .= ' ' . $domain;
|
2025-09-29 18:58:14 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ($isLocalDev) {
|
2026-07-27 09:14:52 +04:00
|
|
|
$mediaSrc .= ' https: http:';
|
2025-09-29 18:58:14 +04:00
|
|
|
}
|
|
|
|
|
$csp .= "media-src " . $mediaSrc . "; ";
|
|
|
|
|
|
2025-07-17 09:57:47 +04:00
|
|
|
$csp .= "object-src 'none'; ";
|
2026-07-08 07:33:40 +04:00
|
|
|
$csp .= "base-uri 'self'; ";
|
|
|
|
|
$csp .= "frame-ancestors 'self';";
|
|
|
|
|
|
2026-07-27 09:14:52 +04:00
|
|
|
return $csp;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Applique des en-têtes de sécurité HTTP
|
|
|
|
|
*/
|
|
|
|
|
function setSecurityHeaders() {
|
|
|
|
|
// Protection contre le clickjacking (permettre les iframes du même site)
|
|
|
|
|
header('X-Frame-Options: SAMEORIGIN');
|
|
|
|
|
|
|
|
|
|
// Protection contre le MIME sniffing
|
|
|
|
|
header('X-Content-Type-Options: nosniff');
|
|
|
|
|
|
|
|
|
|
// X-XSS-Protection volontairement absent : en-tête obsolète, supplanté par
|
|
|
|
|
// la CSP et pouvant introduire des vulnérabilités dans les anciens navigateurs.
|
|
|
|
|
|
|
|
|
|
// Politique de référent
|
|
|
|
|
header('Referrer-Policy: strict-origin-when-cross-origin');
|
|
|
|
|
|
|
|
|
|
// Isolation cross-origin
|
|
|
|
|
header('Cross-Origin-Resource-Policy: same-origin');
|
|
|
|
|
header('Cross-Origin-Opener-Policy: same-origin');
|
|
|
|
|
// COEP retiré : require-corp bloque les iframes/images/vidéos cross-origin (PeerTube, Mastodon, Castopod, Funkwhale)
|
|
|
|
|
// car ces services ne renvoient pas le header CORP approprié.
|
|
|
|
|
|
|
|
|
|
// Permissions Policy (feature policy)
|
|
|
|
|
header('Permissions-Policy: accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(self), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()');
|
|
|
|
|
|
|
|
|
|
// Content Security Policy (domaines réellement utilisés uniquement)
|
|
|
|
|
header('Content-Security-Policy: ' . buildContentSecurityPolicy(getCspNonce()));
|
2026-07-08 07:33:40 +04:00
|
|
|
|
2025-07-17 09:57:47 +04:00
|
|
|
// HTTPS strict transport security (seulement si HTTPS)
|
|
|
|
|
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
|
2026-07-08 07:33:40 +04:00
|
|
|
header('Strict-Transport-Security: max-age=63072000; includeSubDomains; preload');
|
2025-07-17 09:57:47 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Valide l'origine de la requête pour les requêtes AJAX
|
2026-07-26 18:19:53 +04:00
|
|
|
*
|
|
|
|
|
* Accepte le header Origin, ou à défaut un Referer same-origin
|
|
|
|
|
* (certains navigateurs n'envoient pas Origin sur les requêtes same-origin).
|
|
|
|
|
*
|
2025-07-17 09:57:47 +04:00
|
|
|
* @return bool True si l'origine est valide
|
|
|
|
|
*/
|
|
|
|
|
function validateAjaxOrigin() {
|
|
|
|
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
2026-07-26 18:19:53 +04:00
|
|
|
if (empty($host)) {
|
2025-07-17 09:57:47 +04:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-07-26 18:19:53 +04:00
|
|
|
|
|
|
|
|
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http');
|
|
|
|
|
$expectedOrigin = $scheme . '://' . $host;
|
|
|
|
|
|
|
|
|
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
|
|
|
if (!empty($origin)) {
|
|
|
|
|
return $origin === $expectedOrigin;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback : vérifier le referer si Origin est absent
|
|
|
|
|
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
|
|
|
|
if (!empty($referer)) {
|
|
|
|
|
return strpos($referer, $expectedOrigin) === 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
2025-07-17 09:57:47 +04:00
|
|
|
}
|
2026-07-27 01:54:42 +04:00
|
|
|
|
2026-07-27 09:14:52 +04:00
|
|
|
/**
|
|
|
|
|
* Limitation de débit simple par identifiant (typiquement l'IP cliente).
|
|
|
|
|
*
|
|
|
|
|
* Fenêtre fixe stockée dans un fichier par identifiant (cache/rate-limit/),
|
|
|
|
|
* verrouillée par flock() pour rester cohérente entre requêtes concurrentes.
|
|
|
|
|
* En cas d'indisponibilité du stockage, la requête est autorisée (fail-open) :
|
|
|
|
|
* l'endpoint reste protégé par les gardes AJAX, Origin et CSRF.
|
|
|
|
|
*
|
|
|
|
|
* @param string $identifier Identifiant du client (ex. REMOTE_ADDR)
|
|
|
|
|
* @param int $maxRequests Nombre maximal de requêtes dans la fenêtre
|
|
|
|
|
* @param int $windowSeconds Durée de la fenêtre en secondes
|
|
|
|
|
* @param string|null $dir Répertoire de stockage (surtout pour les tests)
|
|
|
|
|
* @return bool True si la requête est autorisée, false si la limite est atteinte
|
|
|
|
|
*/
|
|
|
|
|
function checkRateLimit($identifier, $maxRequests = 30, $windowSeconds = 60, $dir = null) {
|
|
|
|
|
$dir = $dir ?? (__DIR__ . '/../cache/rate-limit');
|
|
|
|
|
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
|
|
|
|
|
error_log('SECURITY: rate limit storage unavailable: ' . $dir);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$file = $dir . '/rl_' . hash('sha256', (string) $identifier) . '.json';
|
|
|
|
|
$now = time();
|
|
|
|
|
|
|
|
|
|
$handle = fopen($file, 'c+');
|
|
|
|
|
if ($handle === false) {
|
|
|
|
|
error_log('SECURITY: rate limit file unavailable: ' . $file);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$allowed = true;
|
|
|
|
|
if (flock($handle, LOCK_EX)) {
|
|
|
|
|
$raw = stream_get_contents($handle);
|
|
|
|
|
$state = $raw !== false ? json_decode($raw, true) : null;
|
|
|
|
|
|
|
|
|
|
if (!is_array($state) || !isset($state['reset']) || $now >= $state['reset']) {
|
|
|
|
|
$state = ['count' => 0, 'reset' => $now + $windowSeconds];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$state['count']++;
|
|
|
|
|
if ($state['count'] > $maxRequests) {
|
|
|
|
|
$allowed = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rewind($handle);
|
|
|
|
|
ftruncate($handle, 0);
|
|
|
|
|
fwrite($handle, json_encode($state));
|
|
|
|
|
fflush($handle);
|
|
|
|
|
flock($handle, LOCK_UN);
|
|
|
|
|
}
|
|
|
|
|
fclose($handle);
|
|
|
|
|
|
|
|
|
|
return $allowed;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 01:54:42 +04:00
|
|
|
/**
|
|
|
|
|
* Valide une URL distante (PeerTube, Castopod, Funkwhale…) pour prévenir
|
|
|
|
|
* les attaques SSRF avant tout appel sortant.
|
|
|
|
|
*
|
|
|
|
|
* @param string $url URL à valider
|
|
|
|
|
* @return bool True si l'URL est valide et sûre
|
|
|
|
|
*/
|
|
|
|
|
function isValidRemoteUrl($url) {
|
|
|
|
|
// Vérifier que l'URL est bien formée
|
|
|
|
|
$parsed = parse_url($url);
|
|
|
|
|
if (!$parsed || !isset($parsed['scheme']) || !isset($parsed['host'])) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Autoriser uniquement HTTPS (ou HTTP en développement)
|
|
|
|
|
if (!in_array($parsed['scheme'], ['https', 'http'])) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bloquer les adresses IP privées et locales
|
|
|
|
|
$host = $parsed['host'];
|
|
|
|
|
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
|
|
|
|
if (!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bloquer localhost et autres domaines dangereux
|
|
|
|
|
$blockedHosts = ['localhost', '127.0.0.1', '::1', '0.0.0.0', 'metadata.google.internal'];
|
|
|
|
|
if (in_array(strtolower($host), $blockedHosts)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2025-07-17 09:57:47 +04:00
|
|
|
?>
|