Files
annu-kute-ced/tests/php/security-test.php
T

340 lines
15 KiB
PHP
Raw Normal View History

<?php
/**
* Tests unitaires pour includes/security.php
* (et les validateurs d'URL de includes/config.php)
*/
// --- validateVideoId --------------------------------------------------------
assertEquals(
'9c5de94d-8e1a-4b3c-9d2e-1234567890ab',
validateVideoId('9c5de94d-8e1a-4b3c-9d2e-1234567890ab'),
'validateVideoId accepte un UUID valide'
);
assertEquals(
'9C5DE94D-8E1A-4B3C-9D2E-1234567890AB',
validateVideoId('9C5DE94D-8E1A-4B3C-9D2E-1234567890AB'),
'validateVideoId accepte un UUID en majuscules'
);
assertEquals(
'9c5de94d-8e1a-4b3c-9d2e-1234567890ab',
validateVideoId(' 9c5de94d-8e1a-4b3c-9d2e-1234567890ab '),
'validateVideoId nettoie les espaces autour de l\'UUID'
);
assertFalse(validateVideoId(''), 'validateVideoId refuse une chaîne vide');
assertFalse(validateVideoId('pas-un-uuid'), 'validateVideoId refuse une chaîne quelconque');
assertFalse(
validateVideoId('9c5de94d-8e1a-4b3c-1234567890ab'),
'validateVideoId refuse un UUID tronqué'
);
// --- validateSearchQuery ----------------------------------------------------
assertEquals('indépendance', validateSearchQuery('indépendance'), 'validateSearchQuery conserve les accents');
assertEquals(
'script',
validateSearchQuery('<script>'),
'validateSearchQuery supprime les chevrons'
);
assertEquals(
'recherche btest/b',
validateSearchQuery('recherche <b>test</b>'),
'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']);