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; } /** * Valide et assainit un User-Agent * * @param string $userAgent User-Agent à valider * @return bool True si valide */ function validateUserAgent($userAgent) { if (empty($userAgent)) { return false; } // Bloquer les User-Agents suspects $blockedPatterns = [ '/curl/i', '/wget/i', '/python/i', '/bot/i', '/scanner/i', '/sqlmap/i' ]; foreach ($blockedPatterns as $pattern) { if (preg_match($pattern, $userAgent)) { return false; } } return true; } /** * Valide les en-têtes HTTP pour détecter les tentatives d'attaque * * @return bool True si les en-têtes sont sûrs */ function validateHttpHeaders() { // Vérifier le User-Agent $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ''; if (!validateUserAgent($userAgent)) { error_log('SECURITY: Suspicious User-Agent detected: ' . $userAgent); return false; } // Vérifier les en-têtes suspects $suspiciousHeaders = [ 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP' ]; foreach ($suspiciousHeaders as $header) { if (isset($_SERVER[$header])) { $value = $_SERVER[$header]; // Bloquer les IPs privées dans les en-têtes de forwarding if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { error_log('SECURITY: Suspicious IP in header ' . $header . ': ' . $value); } } } return true; } /** * 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, CSRF_SECRET); 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, CSRF_SECRET); 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']; } /** * 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'); // Protection XSS basique header('X-XSS-Protection: 1; mode=block'); // 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 avec support Mastodon et PeerTube $nonce = getCspNonce(); $mastodonDomain = ''; $peertubeDomain = ''; // Extraire le domaine Mastodon si configuré if (defined('MASTODON_INSTANCE_URL')) { $mastodonParsed = parse_url(MASTODON_INSTANCE_URL); if ($mastodonParsed && isset($mastodonParsed['host'])) { $mastodonDomain = $mastodonParsed['scheme'] . '://' . $mastodonParsed['host']; } } // Extraire le domaine PeerTube si configuré if (defined('PEERTUBE_URL')) { $peertubeParsed = parse_url(PEERTUBE_URL); if ($peertubeParsed && isset($peertubeParsed['host'])) { $peertubeDomain = $peertubeParsed['scheme'] . '://' . $peertubeParsed['host']; } } // 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 : autoriser les domaines connus plus HTTPS général pour le contenu fédéré $imgSrc = "'self' data: " . ($mastodonDomain ? $mastodonDomain : '') . " " . ($peertubeDomain ? $peertubeDomain : ''); if ($isLocalDev) { $imgSrc .= " https: http:"; } else { $imgSrc .= " https:"; } $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 " . ($mastodonDomain ? $mastodonDomain : '') . " " . ($peertubeDomain ? $peertubeDomain : ''); if ($isLocalDev) { $connectSrc .= " ws: wss:"; // WebSockets pour le dev } $csp .= "connect-src " . $connectSrc . "; "; // Médias : autoriser 'self', Mastodon, PeerTube et S3 Mastodon $mediaSrc = "'self'"; if ($mastodonDomain) { $mediaSrc .= " " . $mastodonDomain; } if ($peertubeDomain) { $mediaSrc .= " " . $peertubeDomain; } // Ajouter l'URL S3 Mastodon si configurée (pour les médias externalisés) if (defined('MASTODON_S3_MEDIA_URL') && !empty(MASTODON_S3_MEDIA_URL)) { $s3Parsed = parse_url(MASTODON_S3_MEDIA_URL); if ($s3Parsed && isset($s3Parsed['host'])) { $s3Domain = $s3Parsed['scheme'] . '://' . $s3Parsed['host']; $mediaSrc .= " " . $s3Domain; } } if ($isLocalDev) { $mediaSrc .= " https: http:"; } else { $mediaSrc .= " https:"; } $csp .= "media-src " . $mediaSrc . "; "; $csp .= "object-src 'none'; "; $csp .= "base-uri 'self'; "; $csp .= "frame-ancestors 'self';"; header('Content-Security-Policy: ' . $csp); // 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; } ?>