; ?>)
$video['uuid'],
- 'title' => $video['name'],
+ 'title' => $video['name'] ?? '',
'thumbnail' => $thumbnail,
- 'duration' => $video['duration'],
- 'channel' => $video['channel']['displayName'],
+ 'duration' => $video['duration'] ?? 0,
+ 'channel' => $video['channel']['displayName'] ?? '',
'channelAvatar' => $channelAvatar,
- 'views' => $video['views'],
- 'date' => $video['publishedAt'],
- 'aspectRatio' => $video['aspectRatio'],
+ 'views' => $video['views'] ?? 0,
+ 'date' => $video['publishedAt'] ?? '',
+ 'aspectRatio' => $video['aspectRatio'] ?? null,
'description' => $video['description'] ?? '',
'tags' => $video['tags'] ?? [],
- 'isLive' => isset($video['isLive']) ? $video['isLive'] : false
+ 'isLive' => $video['isLive'] ?? false
];
}
@@ -461,7 +446,18 @@ function formatViewCount($views) {
}
function formatDate($dateString) {
- $date = new DateTime($dateString);
+ // Chaîne vide : rien à formater (new DateTime('') renverrait « maintenant »)
+ if (!is_string($dateString) || trim($dateString) === '') {
+ return (string) $dateString;
+ }
+
+ try {
+ $date = new DateTime($dateString);
+ } catch (Exception $e) {
+ // Date malformée (données API inattendues) : afficher la chaîne brute
+ return $dateString;
+ }
+
$now = new DateTime();
$interval = $now->diff($date);
@@ -667,6 +663,12 @@ function getCastopodEpisodes($castopodUrl = null, $podcastSlugs = null, $count =
$podcastSlugs = $podcastSlugs ?? (defined('CASTOPOD_PODCAST_SLUGS') ? CASTOPOD_PODCAST_SLUGS : ['annu_kute_cedric']);
$count = $count ?? CASTOPOD_EPISODES_COUNT;
+ // Validation de l'URL Castopod pour prévenir SSRF
+ if (!isValidRemoteUrl($castopodUrl)) {
+ error_log('SECURITY: Invalid Castopod URL detected: ' . $castopodUrl);
+ return [];
+ }
+
// Convertir en tableau si c'est une chaîne unique (rétrocompatibilité)
if (is_string($podcastSlugs)) {
$podcastSlugs = [$podcastSlugs];
@@ -702,6 +704,7 @@ function getCastopodEpisodes($castopodUrl = null, $podcastSlugs = null, $count =
curl_setopt($ch, CURLOPT_URL, $feedUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+ curl_setopt($ch, CURLOPT_MAXREDIRS, 3);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$xmlContent = curl_exec($ch);
@@ -890,6 +893,12 @@ function getFunkwhaleTracks($funkwhaleUrl = null, $count = null) {
$funkwhaleUrl = $funkwhaleUrl ?? FUNKWHALE_URL;
$count = $count ?? FUNKWHALE_TRACKS_COUNT;
+ // Validation de l'URL Funkwhale pour prévenir SSRF
+ if (!isValidRemoteUrl($funkwhaleUrl)) {
+ error_log('SECURITY: Invalid Funkwhale URL detected: ' . $funkwhaleUrl);
+ return [];
+ }
+
// Clé de cache - on récupère un grand nombre de morceaux pour le cache
$cacheKey = 'funkwhale_' . md5($funkwhaleUrl);
$cacheFetchSize = 50; // Nombre de morceaux à mettre en cache
@@ -912,6 +921,7 @@ function getFunkwhaleTracks($funkwhaleUrl = null, $count = null) {
curl_setopt($ch, CURLOPT_URL, $apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+ curl_setopt($ch, CURLOPT_MAXREDIRS, 3);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$jsonContent = curl_exec($ch);
diff --git a/tests/e2e/test_video_page.py b/tests/e2e/test_video_page.py
index 6324ffb..ef5b74d 100644
--- a/tests/e2e/test_video_page.py
+++ b/tests/e2e/test_video_page.py
@@ -88,3 +88,28 @@ def test_video_page_invalid_id_redirects(page, base_url):
page.goto(base_url + "/video.php?id=pas-un-uuid-valide",
wait_until="domcontentloaded")
assert page.url.endswith("/index.php"), f"URL après redirection : {page.url}"
+
+
+def test_video_page_not_found_no_js_errors(page, base_url):
+ """Un UUID bien formé mais inexistant affiche « Vidéo non trouvée » sans erreur JS."""
+ erreurs_js = []
+ page.on("pageerror", lambda exc: erreurs_js.append(str(exc)))
+ page.goto(base_url + "/video.php?id=00000000-0000-0000-0000-000000000000",
+ wait_until="load")
+
+ # La page d'erreur est affichée (pas de redirection : l'UUID est bien formé ;
+ # si l'API est indisponible, la réponse vide mène au même état)
+ assert page.locator(".error-message").count() == 1, "Le message d'erreur est absent"
+ assert page.locator("#error-heading").inner_text() == "Vidéo non trouvée"
+
+ # Les modales téléchargement/partage et leur script ne doivent pas être rendus
+ assert page.locator("#download-modal").count() == 0, (
+ "La modale de téléchargement est présente sur la page d'erreur"
+ )
+ assert page.locator("#share-modal").count() == 0, (
+ "La modale de partage est présente sur la page d'erreur"
+ )
+
+ assert erreurs_js == [], (
+ f"Erreurs JS sur la page « vidéo introuvable » : {erreurs_js}"
+ )
diff --git a/tests/php/security-test.php b/tests/php/security-test.php
index 1798e85..9835422 100644
--- a/tests/php/security-test.php
+++ b/tests/php/security-test.php
@@ -141,6 +141,18 @@ 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');
@@ -155,3 +167,91 @@ $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'
+);
diff --git a/video.php b/video.php
index 3da4624..b246550 100644
--- a/video.php
+++ b/video.php
@@ -140,9 +140,9 @@ if (empty($videoData) || isset($videoData['error'])) {
-
+
-
">
+
@@ -152,7 +152,7 @@ if (empty($videoData) || isset($videoData['error'])) {
-
+
@@ -224,7 +224,7 @@ if (empty($videoData) || isset($videoData['error'])) {
vues
-
+