security: SRI Font Awesome, CSP restrictions, remove X-XSS-Protection, rate limiting

This commit is contained in:
2026-07-27 09:15:20 +04:00
parent ddd82fdff6
commit 7cbb7334f0
11 changed files with 186 additions and 135 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ if (function_exists('setSecurityHeaders')) {
<!-- Styles -->
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime(__DIR__ . '/css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
+1 -1
View File
@@ -40,7 +40,7 @@ if (function_exists('setSecurityHeaders')) {
<!-- Styles -->
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime(__DIR__ . '/css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
+3 -3
View File
@@ -19,8 +19,8 @@ setSecurityHeaders();
$categoryId = isset($_GET['id']) ? $_GET['id'] : null;
$categoryId = $categoryId ? validateCategoryId($categoryId) : null;
// Récupérer les catégories disponibles
$allCategories = PEERTUBE_CATEGORIES;
// Récupérer les catégories disponibles (chargement paresseux, ARC-2)
$allCategories = getPeertubeCategories();
// Récupérer les vidéos de la catégorie si un ID est fourni
if ($categoryId && isset($allCategories[$categoryId])) {
@@ -46,7 +46,7 @@ if ($categoryId && isset($allCategories[$categoryId])) {
<meta name="description" content="Découvrez toutes les vidéos de la catégorie <?php echo htmlspecialchars($categoryName); ?> sur <?php echo SITE_NAME; ?>. Contenu multimédia de qualité et exclusif.">
<link rel="canonical" href="<?php echo getBaseUrl() . '/categories.php?id=' . $categoryId; ?>">
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
+4 -106
View File
@@ -28,7 +28,7 @@ $liveStream = getLiveStream();
<meta name="description" content="Suivez nos diffusions en direct sur <?php echo SITE_NAME; ?>. Contenu en temps réel, discussions et événements exclusifs.">
<link rel="canonical" href="<?php echo getBaseUrl(); ?>/direct.php">
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
@@ -160,111 +160,9 @@ $liveStream = getLiveStream();
$showNextLiveAnnouncement = defined('NEXT_LIVE_ENABLED') && NEXT_LIVE_ENABLED === true;
if ($showNextLiveAnnouncement) {
// Afficher l'annonce du prochain live
// Définir l'image de fond si disponible
$bgImageStyle = '';
if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)) {
$bgImageStyle = 'background-image: url(\'' . htmlspecialchars(NEXT_LIVE_IMAGE) . '\');';
}
?>
<?php if (!empty($bgImageStyle)): ?>
<style nonce="<?php echo getCspNonce(); ?>">
.next-live-announcement { <?php echo $bgImageStyle; ?> }
</style>
<?php endif; ?>
<div class="next-live-announcement">
<?php if (!empty(NEXT_LIVE_IMAGE) && file_exists(NEXT_LIVE_IMAGE)): ?>
<div class="next-live-image-container">
<img src="<?php echo htmlspecialchars(NEXT_LIVE_IMAGE); ?>"
alt="<?php echo htmlspecialchars(NEXT_LIVE_TITLE); ?>"
class="next-live-image">
</div>
<?php endif; ?>
<div class="next-live-content">
<i class="fas fa-calendar-alt"></i>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveDate = new DateTime(NEXT_LIVE_DATE, new DateTimeZone(DEFAULT_TIMEZONE));
$dayFormatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::NONE,
DEFAULT_TIMEZONE,
IntlDateFormatter::GREGORIAN,
'EEEE d MMMM'
);
$formattedDay = $dayFormatter->format($liveDate);
$formattedDay = ucfirst($formattedDay);
$dynamicTitle = NEXT_LIVE_TITLE . ' - ' . $formattedDay;
} else {
$dynamicTitle = NEXT_LIVE_TITLE;
}
?>
<h1><?php echo htmlspecialchars($dynamicTitle); ?></h1>
<?php
if (!empty(NEXT_LIVE_DATE)) {
$liveHour = $liveDate->format('H\hi');
$dynamicDescription = 'Rejoignez-nous à ' . $liveHour . '. ' . NEXT_LIVE_DESCRIPTION;
} else {
$dynamicDescription = NEXT_LIVE_DESCRIPTION;
}
?>
<p><?php echo nl2br(htmlspecialchars($dynamicDescription)); ?></p>
<?php if (!empty(NEXT_LIVE_DATE)): ?>
<div class="next-live-datetime">
<p class="next-live-date">
<i class="fas fa-clock"></i>
<?php
$formatter = new IntlDateFormatter(
'fr_FR',
IntlDateFormatter::FULL,
IntlDateFormatter::SHORT,
DEFAULT_TIMEZONE
);
echo $formatter->format($liveDate);
$offset = $liveDate->format('P');
echo ' <span class="utc-offset">(UTC' . $offset . ')</span>';
?>
</p>
<!-- Autres fuseaux horaires -->
<div class="next-live-timezones">
<?php
// Ordre croissant : du plus en retard au plus en avance
$timezones = [
'Ma\'ohi Nui' => 'Pacific/Tahiti',
'Martinique / Guadeloupe' => 'America/Martinique',
'Guyane' => 'America/Cayenne',
'France' => 'Europe/Paris',
'Kanaky' => 'Pacific/Noumea'
];
foreach($timezones as $name => $timezone):
$liveDateLocal = clone $liveDate;
$liveDateLocal->setTimezone(new DateTimeZone($timezone));
// Vérifier si c'est un jour différent
$dayDiff = $liveDateLocal->format('j') - $liveDate->format('j');
$dayIndicator = '';
if ($dayDiff > 0) {
$dayIndicator = ' <span class="day-shift">+1j</span>';
} elseif ($dayDiff < 0) {
$dayIndicator = ' <span class="day-shift">-1j</span>';
}
?>
<span class="timezone-item">
<strong><?php echo $name; ?> :</strong> <?php echo $liveDateLocal->format('H\hi'); ?><?php echo $dayIndicator; ?>
</span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<a href="index.php" class="btn-primary">Retour à l'accueil</a>
</div>
</div>
<?php
// Afficher l'annonce du prochain live (partial partagé avec hero-section.php)
$nextLiveVariant = 'page';
include 'includes/partials/next-live.php';
} else {
?>
<div class="no-live-message">
+1 -1
View File
@@ -83,7 +83,7 @@ function linkUrlsInText(string $text): string {
<!-- Styles -->
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="css/donations.css?v=<?php echo filemtime('css/donations.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
+1 -1
View File
@@ -30,7 +30,7 @@ if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') &
<meta name="description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?>">
<link rel="canonical" href="<?php echo getBaseUrl() . '/'; ?>">
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<link rel="stylesheet" href="css/mastodon-timeline.min.css?v=<?php echo filemtime('css/mastodon-timeline.min.css'); ?>">
<?php if (defined('WORDPRESS_ENABLED') && WORDPRESS_ENABLED): ?>
<link rel="stylesheet" href="css/wordpress-posts.css?v=<?php echo filemtime('css/wordpress-posts.css'); ?>">
+1 -1
View File
@@ -13,7 +13,7 @@ setSecurityHeaders();
<meta name="description" content="Consultez les mentions légales de <?php echo SITE_NAME; ?>. Informations légales, conditions d'utilisation et politique de confidentialité.">
<link rel="canonical" href="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/mentions-legales.php'; ?>">
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
+27 -20
View File
@@ -24,26 +24,33 @@ $currentSearchPage = validatePageNumber($_GET['page'] ?? 1);
$isTagSearch = !empty($query) && substr($query, 0, 1) === '#';
$searchTag = $isTagSearch ? substr($query, 1) : '';
// Rechercher les vidéos via l'API PeerTube si une requête est soumise
$searchResults = !empty($query) ? searchVideos($query, COUNT_VIDEO_SEARCH) : [];
// Définir le nombre total de résultats
$resultsCount = count($searchResults);
// Calculer le nombre total de pages
$totalPages = ceil($resultsCount / VIDEOS_PER_PAGE);
// S'assurer que la page actuelle est valide
$currentSearchPage = min($currentSearchPage, max(1, $totalPages));
// Calculer les indices de début et de fin pour la page actuelle
$startIndex = ($currentSearchPage - 1) * VIDEOS_PER_PAGE;
$endIndex = min($startIndex + VIDEOS_PER_PAGE, $resultsCount);
// Extraire les vidéos pour la page actuelle
// Rechercher les vidéos via l'API PeerTube si une requête est soumise.
// La pagination est déléguée à l'API (paramètre start) : chaque page ne
// récupère que VIDEOS_PER_PAGE vidéos et $resultsCount reçoit le total réel.
$resultsCount = 0;
$currentPageVideos = [];
if ($resultsCount > 0) {
$currentPageVideos = array_slice($searchResults, $startIndex, VIDEOS_PER_PAGE);
$totalPages = 1;
if (!empty($query)) {
$currentPageVideos = searchVideos(
$query,
VIDEOS_PER_PAGE,
($currentSearchPage - 1) * VIDEOS_PER_PAGE,
$resultsCount
);
$totalPages = max(1, (int) ceil($resultsCount / VIDEOS_PER_PAGE));
// Page demandée au-delà de la dernière : se replacer sur la dernière page valide
if ($currentSearchPage > $totalPages) {
$currentSearchPage = $totalPages;
$currentPageVideos = searchVideos(
$query,
VIDEOS_PER_PAGE,
($currentSearchPage - 1) * VIDEOS_PER_PAGE,
$resultsCount
);
// Le total a pu changer (ex. API en échec sur un start trop élevé)
$totalPages = max(1, (int) ceil($resultsCount / VIDEOS_PER_PAGE));
}
}
?>
@@ -61,7 +68,7 @@ if ($resultsCount > 0) {
<link rel="canonical" href="<?php echo getBaseUrl() . '/recherche.php'; ?>">
<?php endif; ?>
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* Tests unitaires pour checkRateLimit() (includes/security.php)
*/
$rlDir = sys_get_temp_dir() . '/annu-kute-rl-test-' . getmypid();
// Nettoyage d'une éventuelle exécution précédente
foreach (glob($rlDir . '/rl_*.json') ?: [] as $oldFile) {
unlink($oldFile);
}
// --- Limite de base -----------------------------------------------------------
$ip = '203.0.113.10';
assertTrue(checkRateLimit($ip, 3, 60, $rlDir), 'rate limit : la 1re requête est autorisée');
assertTrue(checkRateLimit($ip, 3, 60, $rlDir), 'rate limit : la 2e requête est autorisée');
assertTrue(checkRateLimit($ip, 3, 60, $rlDir), 'rate limit : la 3e requête est autorisée');
assertFalse(checkRateLimit($ip, 3, 60, $rlDir), 'rate limit : la 4e requête est refusée (limite 3)');
assertFalse(checkRateLimit($ip, 3, 60, $rlDir), 'rate limit : une 5e requête reste refusée');
// --- Indépendance des identifiants --------------------------------------------
assertTrue(
checkRateLimit('203.0.113.99', 3, 60, $rlDir),
'rate limit : un autre identifiant a son propre compteur'
);
assertTrue(
checkRateLimit($ip, 3, 60, $rlDir . '/autre'),
'rate limit : un autre répertoire de stockage a son propre compteur'
);
// --- Réinitialisation de la fenêtre -------------------------------------------
// Forcer l'expiration de la fenêtre en réécrivant le fichier d'état
$stateFile = $rlDir . '/rl_' . hash('sha256', $ip) . '.json';
assertTrue(file_exists($stateFile), 'rate limit : le fichier d\'état existe');
file_put_contents($stateFile, json_encode(['count' => 3, 'reset' => time() - 1]));
assertTrue(
checkRateLimit($ip, 3, 60, $rlDir),
'rate limit : le compteur repart à zéro après expiration de la fenêtre'
);
// --- Fail-open si le stockage est indisponible ---------------------------------
// Un chemin qui est un fichier (pas un répertoire) : mkdir doit échouer
$notADir = $rlDir . '/fichier-bloquant';
file_put_contents($notADir, 'x');
assertTrue(
@checkRateLimit('198.51.100.5', 1, 60, $notADir),
'rate limit : fail-open si le répertoire de stockage est indisponible'
);
// --- Nettoyage -----------------------------------------------------------------
foreach ([$rlDir, $rlDir . '/autre'] as $dir) {
foreach (glob($dir . '/rl_*.json') ?: [] as $file) {
unlink($file);
}
if (is_dir($dir)) {
rmdir($dir);
}
}
unlink($notADir);
+82
View File
@@ -255,3 +255,85 @@ 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']);
+1 -1
View File
@@ -126,7 +126,7 @@ if (empty($videoData) || isset($videoData['error'])) {
<?php endif; ?>
<link rel="stylesheet" href="css/styles.css?v=<?php echo filemtime('css/styles.css'); ?>">
<link rel="stylesheet" href="css/video-page.css?v=<?php echo filemtime('css/video-page.css'); ?>">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" integrity="sha512-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" crossorigin="anonymous">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">