'), 'validateSearchQuery supprime les chevrons' ); assertEquals( 'recherche btest/b', validateSearchQuery('recherche test'), 'validateSearchQuery supprime les caractères dangereux <>"\'' ); assertEquals('terme', validateSearchQuery(' terme '), 'validateSearchQuery nettoie les espaces'); assertFalse(validateSearchQuery(''), 'validateSearchQuery refuse une chaîne vide'); assertFalse( validateSearchQuery(str_repeat('a', 201)), 'validateSearchQuery refuse plus de 200 caractères' ); assertEquals( str_repeat('a', 200), validateSearchQuery(str_repeat('a', 200)), 'validateSearchQuery accepte exactement 200 caractères' ); // --- validateCategoryId ----------------------------------------------------- assertEquals(1, validateCategoryId(1), 'validateCategoryId accepte la borne basse 1'); assertEquals(20, validateCategoryId(20), 'validateCategoryId accepte la borne haute 20'); assertEquals(5, validateCategoryId('5'), 'validateCategoryId convertit une chaîne numérique'); assertFalse(validateCategoryId(0), 'validateCategoryId refuse 0'); assertFalse(validateCategoryId(21), 'validateCategoryId refuse 21'); assertFalse(validateCategoryId(-3), 'validateCategoryId refuse un nombre négatif'); assertFalse(validateCategoryId('abc'), 'validateCategoryId refuse une chaîne non numérique'); // --- validatePageNumber ----------------------------------------------------- assertEquals(1, validatePageNumber(0), 'validatePageNumber remonte 0 à 1'); assertEquals(1, validatePageNumber(-5), 'validatePageNumber remonte un négatif à 1'); assertEquals(3, validatePageNumber(3), 'validatePageNumber conserve une page valide'); assertEquals(2, validatePageNumber('2'), 'validatePageNumber convertit une chaîne numérique'); assertEquals(1, validatePageNumber('abc'), 'validatePageNumber ramène une chaîne non numérique à 1'); // --- generateCSRFToken / validateCSRFToken ---------------------------------- $token = generateCSRFToken(); assertContains(':', $token, 'generateCSRFToken produit le format "timestamp:hash"'); [$timestamp, $hash] = explode(':', $token, 2); assertTrue(ctype_digit($timestamp), 'generateCSRFToken : la partie timestamp est numérique'); assertEquals( hash_hmac('sha256', $timestamp, CSRF_SECRET), $hash, 'generateCSRFToken : le hash est un HMAC-SHA256 du timestamp avec CSRF_SECRET' ); assertTrue(validateCSRFToken($token), 'validateCSRFToken accepte un token fraîchement généré'); assertFalse(validateCSRFToken(''), 'validateCSRFToken refuse une chaîne vide'); assertFalse(validateCSRFToken('sans-deux-points'), 'validateCSRFToken refuse un token sans séparateur'); assertFalse(validateCSRFToken('abc:' . $hash), 'validateCSRFToken refuse un timestamp non numérique'); assertFalse( validateCSRFToken($timestamp . ':' . hash_hmac('sha256', $timestamp, 'autre-secret')), 'validateCSRFToken refuse un hash signé avec un autre secret' ); assertFalse( validateCSRFToken($timestamp . ':0' . $hash), 'validateCSRFToken refuse un hash modifié' ); // Token expiré : horodatage d'il y a 2 heures (limite : 1 heure) $oldTimestamp = (string) (time() - 7200); $oldToken = $oldTimestamp . ':' . hash_hmac('sha256', $oldTimestamp, CSRF_SECRET); assertFalse(validateCSRFToken($oldToken), 'validateCSRFToken refuse un token expiré (> 1 heure)'); // Timestamp trop loin dans le futur (l'écart absolu est également plafonné) $futureTimestamp = (string) (time() + 7200); $futureToken = $futureTimestamp . ':' . hash_hmac('sha256', $futureTimestamp, CSRF_SECRET); assertFalse(validateCSRFToken($futureToken), 'validateCSRFToken refuse un timestamp trop futur'); // --- isValidPeerTubeUrl (includes/config.php) ------------------------------- assertTrue(isValidPeerTubeUrl('https://peertube.example.com'), 'isValidPeerTubeUrl accepte une URL HTTPS publique'); assertTrue(isValidPeerTubeUrl('http://peertube.example.com'), 'isValidPeerTubeUrl accepte HTTP (développement)'); assertTrue(isValidPeerTubeUrl('https://8.8.8.8'), 'isValidPeerTubeUrl accepte une IP publique'); assertFalse(isValidPeerTubeUrl('ftp://example.com'), 'isValidPeerTubeUrl refuse un schéma non HTTP(S)'); assertFalse(isValidPeerTubeUrl('pas-une-url'), 'isValidPeerTubeUrl refuse une chaîne mal formée'); assertFalse(isValidPeerTubeUrl('https://localhost'), 'isValidPeerTubeUrl refuse localhost'); assertFalse(isValidPeerTubeUrl('http://127.0.0.1'), 'isValidPeerTubeUrl refuse 127.0.0.1'); assertFalse(isValidPeerTubeUrl('http://192.168.1.1'), 'isValidPeerTubeUrl refuse une IP privée'); assertFalse(isValidPeerTubeUrl('http://10.0.0.5'), 'isValidPeerTubeUrl refuse une IP privée (10.x)'); assertFalse( isValidPeerTubeUrl('https://metadata.google.internal'), 'isValidPeerTubeUrl refuse metadata.google.internal' ); // --- isValidApiEndpoint (includes/config.php) ------------------------------- assertTrue(isValidApiEndpoint('videos'), 'isValidApiEndpoint accepte "videos"'); assertTrue(isValidApiEndpoint('videos/categories'), 'isValidApiEndpoint accepte "videos/categories"'); assertTrue(isValidApiEndpoint('search/videos'), 'isValidApiEndpoint accepte "search/videos"'); assertTrue(isValidApiEndpoint('accounts'), 'isValidApiEndpoint accepte "accounts"'); assertTrue( isValidApiEndpoint('videos/9c5de94d-8e1a-4b3c-9d2e-1234567890ab'), 'isValidApiEndpoint accepte un endpoint vidéo dynamique' ); assertTrue( isValidApiEndpoint('videos/abc/comment-threads'), 'isValidApiEndpoint accepte les fils de commentaires' ); assertTrue( isValidApiEndpoint('accounts/annu_kute_ced/videos'), 'isValidApiEndpoint accepte les vidéos d\'un compte' ); assertTrue( isValidApiEndpoint('video-channels/annu_kute_ced/videos'), 'isValidApiEndpoint accepte les vidéos d\'une chaîne' ); assertFalse( isValidApiEndpoint('video-channels/annu_kute_ced'), 'isValidApiEndpoint refuse une chaîne sans sous-chemin /videos' ); assertFalse( isValidApiEndpoint('video-channels'), 'isValidApiEndpoint refuse "video-channels" seul' ); assertFalse(isValidApiEndpoint('../config'), 'isValidApiEndpoint refuse le path traversal'); assertFalse(isValidApiEndpoint('videos/../x'), 'isValidApiEndpoint refuse ".." dans le chemin'); assertFalse(isValidApiEndpoint('videos//categories'), 'isValidApiEndpoint refuse un double slash'); assertFalse(isValidApiEndpoint('wp-admin'), 'isValidApiEndpoint refuse un endpoint hors liste blanche'); assertFalse(isValidApiEndpoint('videos.json'), 'isValidApiEndpoint refuse les caractères non autorisés (.)'); assertFalse(isValidApiEndpoint('videos?count=1'), 'isValidApiEndpoint refuse une query string'); assertFalse(isValidApiEndpoint(''), 'isValidApiEndpoint refuse une chaîne vide'); // --- getCspNonce ------------------------------------------------------------ $nonce = getCspNonce(); assertTrue(is_string($nonce) && $nonce !== '', 'getCspNonce retourne une chaîne non vide'); assertEquals(16, strlen(base64_decode($nonce, true)), 'getCspNonce est un base64 de 16 octets aléatoires'); assertEquals($nonce, getCspNonce(), 'getCspNonce retourne le même nonce durant toute la requête'); // --- getCsrfSecret ------------------------------------------------------------ assertEquals( CSRF_SECRET, getCsrfSecret(), 'getCsrfSecret retourne CSRF_SECRET quand il est configuré' ); // Repli éphémère : impossible à tester dans ce processus (CSRF_SECRET est déjà // défini par le bootstrap), on passe par un sous-processus PHP isolé dont la // sortie d'erreur est redirigée vers stdout pour capter l'avertissement. $securityFile = dirname(__DIR__, 2) . '/includes/security.php'; $snippet = <<<'PHP' require $argv[1]; define('CSRF_SECRET', CSRF_SECRET_PLACEHOLDER); $s1 = getCsrfSecret(); $s2 = getCsrfSecret(); echo 'len=' . strlen($s1) . "\n"; echo 'same=' . ($s1 === $s2 ? 'yes' : 'no') . "\n"; echo 'placeholder=' . ($s1 === CSRF_SECRET ? 'yes' : 'no') . "\n"; $token = generateCSRFToken(); echo 'token=' . (validateCSRFToken($token) ? 'valid' : 'invalid') . "\n"; PHP; $cmd = escapeshellarg(PHP_BINARY) . ' -d log_errors=1 -d error_log=/dev/stdout -r ' . escapeshellarg($snippet) . ' ' . escapeshellarg($securityFile); $fallbackOutput = function_exists('shell_exec') ? shell_exec($cmd) : null; if ($fallbackOutput === null) { test_record(false, 'getCsrfSecret : sous-processus de test du repli éphémère (shell_exec indisponible)'); } else { assertContains( 'SECURITY CRITICAL', $fallbackOutput, 'getCsrfSecret enregistre un avertissement critique avec la valeur par défaut' ); assertContains( 'CSRF_SECRET', $fallbackOutput, 'l\'avertissement critique mentionne CSRF_SECRET' ); assertContains( 'len=64', $fallbackOutput, 'le secret éphémère fait 64 caractères hexadécimaux (32 octets)' ); assertContains( 'same=yes', $fallbackOutput, 'le secret éphémère est stable durant tout le processus' ); assertContains( 'placeholder=no', $fallbackOutput, 'le secret éphémère diffère de la valeur par défaut' ); assertContains( 'token=valid', $fallbackOutput, 'un token signé avec le secret éphémère est validé dans le même processus' ); } // --- isValidRemoteUrl (includes/security.php) --------------------------------- assertTrue(isValidRemoteUrl('https://peertube.example.com'), 'isValidRemoteUrl accepte une URL HTTPS publique'); assertTrue(isValidRemoteUrl('https://kute.o-k-i.net'), 'isValidRemoteUrl accepte une instance Castopod publique'); assertTrue(isValidRemoteUrl('https://mizik.o-k-i.net'), 'isValidRemoteUrl accepte une instance Funkwhale publique'); assertTrue(isValidRemoteUrl('http://castopod.example.com'), 'isValidRemoteUrl accepte HTTP (développement)'); assertFalse(isValidRemoteUrl('ftp://kute.o-k-i.net'), 'isValidRemoteUrl refuse un schéma non HTTP(S)'); assertFalse(isValidRemoteUrl('pas-une-url'), 'isValidRemoteUrl refuse une chaîne mal formée'); assertFalse(isValidRemoteUrl(''), 'isValidRemoteUrl refuse une chaîne vide'); assertFalse(isValidRemoteUrl('https://localhost'), 'isValidRemoteUrl refuse localhost'); assertFalse(isValidRemoteUrl('http://127.0.0.1'), 'isValidRemoteUrl refuse 127.0.0.1'); assertFalse(isValidRemoteUrl('http://192.168.1.1'), 'isValidRemoteUrl refuse une IP privée (192.168.x)'); assertFalse(isValidRemoteUrl('http://10.0.0.5'), 'isValidRemoteUrl refuse une IP privée (10.x)'); assertFalse(isValidRemoteUrl('http://172.16.0.1'), 'isValidRemoteUrl refuse une IP privée (172.16.x)'); assertFalse( isValidRemoteUrl('http://169.254.169.254/latest/meta-data'), 'isValidRemoteUrl refuse l\'IP de métadonnées cloud (link-local)' ); assertFalse( isValidRemoteUrl('https://metadata.google.internal'), 'isValidRemoteUrl refuse metadata.google.internal' ); // --- cspOriginFromUrl --------------------------------------------------------- assertEquals( 'https://peertube.example.com', cspOriginFromUrl('https://peertube.example.com'), 'cspOriginFromUrl extrait l\'origine d\'une URL simple' ); assertEquals( 'https://peertube.example.com', cspOriginFromUrl('https://peertube.example.com/chemin/page?x=1'), 'cspOriginFromUrl ignore le chemin et la query string' ); assertEquals( 'http://127.0.0.1', cspOriginFromUrl('http://127.0.0.1'), 'cspOriginFromUrl conserve le schéma http' ); assertEquals('', cspOriginFromUrl(''), 'cspOriginFromUrl refuse une chaîne vide'); assertEquals('', cspOriginFromUrl('pas-une-url'), 'cspOriginFromUrl refuse une URL mal formée'); // --- buildContentSecurityPolicy ------------------------------------------------ /** * Extrait la valeur d'une directive CSP pour les assertions */ function csp_directive($csp, $name) { if (preg_match('/(?:^| )' . preg_quote($name, '/') . ' ([^;]+)/', $csp, $m)) { return $m[1]; } return null; } $testNonce = 'nonce-de-test'; $csp = buildContentSecurityPolicy($testNonce); assertContains( "'nonce-{$testNonce}", csp_directive($csp, 'script-src'), 'CSP : script-src porte le nonce de la requête' ); assertContains( "'nonce-{$testNonce}", csp_directive($csp, 'style-src'), 'CSP : style-src porte le nonce de la requête' ); // Domaines réellement utilisés présents $imgSrc = csp_directive($csp, 'img-src'); $mediaSrc = csp_directive($csp, 'media-src'); assertContains(cspOriginFromUrl(PEERTUBE_URL), $imgSrc, 'CSP : img-src autorise le domaine PeerTube'); assertContains(cspOriginFromUrl(MASTODON_INSTANCE_URL), $imgSrc, 'CSP : img-src autorise le domaine Mastodon'); assertContains(cspOriginFromUrl(PEERTUBE_URL), $mediaSrc, 'CSP : media-src autorise le domaine PeerTube'); assertContains(cspOriginFromUrl(CASTOPOD_URL), $mediaSrc, 'CSP : media-src autorise le domaine Castopod'); assertContains(cspOriginFromUrl(CASTOPOD_URL), $imgSrc, 'CSP : img-src autorise les pochettes Castopod'); // Pas de joker https:/http: hors développement local (HTTP_HOST absent en CLI) assertTrue( preg_match('/(^|\s)https?:($|\s)/', $imgSrc) === 0, 'CSP : img-src ne contient pas de joker https: en production' ); assertTrue( preg_match('/(^|\s)https?:($|\s)/', $mediaSrc) === 0, 'CSP : media-src ne contient pas de joker https: en production' ); // Directives de verrouillage toujours présentes assertContains("object-src 'none'", $csp, 'CSP : object-src none est présent'); assertContains("frame-ancestors 'self'", $csp, 'CSP : frame-ancestors self est présent'); // En développement local, le joker HTTP(S) est réintroduit pour le contenu fédéré $_SERVER['HTTP_HOST'] = 'localhost:8080'; $cspDev = buildContentSecurityPolicy($testNonce); assertTrue( preg_match('/(^|\s)https?:($|\s)/', csp_directive($cspDev, 'img-src')) === 1, 'CSP : img-src contient le joker https: en développement local' ); assertTrue( preg_match('/(^|\s)https?:($|\s)/', csp_directive($cspDev, 'media-src')) === 1, 'CSP : media-src contient le joker https: en développement local' ); unset($_SERVER['HTTP_HOST']);