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; } /** * 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; } /** * 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" */ function generateCSRFToken() { $timestamp = time(); $hash = hash_hmac('sha256', (string) $timestamp, getCsrfSecret()); return $timestamp . ':' . $hash; } /** * Valide un token CSRF stateless * * @param string $token Token à valider * @return bool True si le token est valide et non expiré */ function validateCSRFToken($token) { if (empty($token) || !is_string($token)) { return false; } $parts = explode(':', $token, 2); if (count($parts) !== 2) { return false; } [$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; } $expectedHash = hash_hmac('sha256', $timestamp, getCsrfSecret()); return hash_equals($expectedHash, $hash); } /** * 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']; } /** * 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 */ function cspOriginFromUrl($url) { if (empty($url)) { return ''; } $parsed = parse_url($url); if (!$parsed || !isset($parsed['scheme'], $parsed['host'])) { return ''; } 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) : ''; // 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']); $csp = "default-src 'self'; "; $csp .= "style-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com; "; $csp .= "script-src 'self' 'nonce-{$nonce}' https://cdnjs.cloudflare.com https://plausible.io; "; // 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; } } if ($isLocalDev) { $imgSrc .= ' https: http:'; } $csp .= "img-src " . $imgSrc . "; "; $csp .= "font-src 'self' https://cdnjs.cloudflare.com; "; // Frames : autoriser PeerTube uniquement $frameSrc = "'self'" . ($peertubeDomain !== '' ? ' ' . $peertubeDomain : ''); if ($isLocalDev) { $frameSrc .= ' https: http:'; } $csp .= "frame-src " . $frameSrc . "; "; // Connexions : autoriser Mastodon, PeerTube et Plausible $connectSrc = "'self' https://plausible.io"; foreach ([$mastodonDomain, $peertubeDomain] as $domain) { if ($domain !== '') { $connectSrc .= ' ' . $domain; } } if ($isLocalDev) { $connectSrc .= ' ws: wss:'; // WebSockets pour le dev } $csp .= "connect-src " . $connectSrc . "; "; // Médias : flux audio Castopod/Funkwhale, médias Mastodon (instance ou S3) $mediaSrc = "'self'"; foreach ([$mastodonDomain, $peertubeDomain, $castopodDomain, $funkwhaleDomain, $s3Domain] as $domain) { if ($domain !== '') { $mediaSrc .= ' ' . $domain; } } if ($isLocalDev) { $mediaSrc .= ' https: http:'; } $csp .= "media-src " . $mediaSrc . "; "; $csp .= "object-src 'none'; "; $csp .= "base-uri 'self'; "; $csp .= "frame-ancestors 'self';"; 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())); // HTTPS strict transport security (seulement si HTTPS) if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { header('Strict-Transport-Security: max-age=63072000; includeSubDomains; preload'); } } /** * Valide l'origine de la requête pour les requêtes AJAX * * Accepte le header Origin, ou à défaut un Referer same-origin * (certains navigateurs n'envoient pas Origin sur les requêtes same-origin). * * @return bool True si l'origine est valide */ function validateAjaxOrigin() { $host = $_SERVER['HTTP_HOST'] ?? ''; if (empty($host)) { return false; } $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; } /** * 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; } /** * 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; } ?>