fix(security): safe JSON-LD encoding and URL escaping

This commit is contained in:
2026-07-27 01:54:49 +04:00
parent a28ba8bfec
commit 4778f0e9e3
3 changed files with 204 additions and 12 deletions
+6 -4
View File
@@ -3,6 +3,8 @@
require_once 'includes/config.php'; require_once 'includes/config.php';
// Inclure les fonctions de sécurité // Inclure les fonctions de sécurité
require_once 'includes/security.php'; require_once 'includes/security.php';
// Inclure les fonctions de données structurées (getBaseUrl, getCurrentUrl)
require_once 'includes/structured-data.php';
// Appliquer les en-têtes de sécurité // Appliquer les en-têtes de sécurité
setSecurityHeaders(); setSecurityHeaders();
?> ?>
@@ -14,7 +16,7 @@ setSecurityHeaders();
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>"> <meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
<title><?php echo SITE_NAME; ?> - Ouverture prochaine</title> <title><?php echo SITE_NAME; ?> - Ouverture prochaine</title>
<meta name="description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?> Ouverture prochaine."> <meta name="description" content="<?php echo htmlspecialchars(SITE_DESCRIPTION); ?> Ouverture prochaine.">
<link rel="canonical" href="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/countdown.php'; ?>"> <link rel="canonical" href="<?php echo getBaseUrl() . '/countdown.php'; ?>">
<!-- Styles --> <!-- Styles -->
<link rel="stylesheet" href="css/countdown.css?v=<?php echo filemtime('css/countdown.css'); ?>"> <link rel="stylesheet" href="css/countdown.css?v=<?php echo filemtime('css/countdown.css'); ?>">
@@ -42,8 +44,8 @@ setSecurityHeaders();
setlocale(LC_TIME, 'fr_FR.UTF-8'); setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%e %B %Y', $targetDate->getTimestamp()); echo strftime('%e %B %Y', $targetDate->getTimestamp());
?>. Restez connectés !"> ?>. Restez connectés !">
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>"> <meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>"> <meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>"> <meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
<meta property="og:locale" content="fr_FR"> <meta property="og:locale" content="fr_FR">
@@ -56,7 +58,7 @@ setSecurityHeaders();
setlocale(LC_TIME, 'fr_FR.UTF-8'); setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%e %B %Y', $targetDate->getTimestamp()); echo strftime('%e %B %Y', $targetDate->getTimestamp());
?>. Restez connectés !"> ?>. Restez connectés !">
<meta name="twitter:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>"> <meta name="twitter:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
<!-- Configuration JavaScript --> <!-- Configuration JavaScript -->
<script nonce="<?php echo getCspNonce(); ?>"> <script nonce="<?php echo getCspNonce(); ?>">
+76 -8
View File
@@ -5,6 +5,18 @@
* pour améliorer le SEO et l'affichage dans les moteurs de recherche * pour améliorer le SEO et l'affichage dans les moteurs de recherche
*/ */
/**
* Drapeaux json_encode pour la sortie JSON-LD.
*
* Les JSON_HEX_* encodent <, >, &, ' et " en séquences \u00XX : une valeur
* contenant "</script>" (titre de vidéo, nom de chaîne, titre d'épisode…)
* ne peut plus fermer le bloc <script> ni injecter de HTML dans la page.
* Le JSON reste valide et se décode à l'identique.
*/
if (!defined('JSONLD_ENCODE_FLAGS')) {
define('JSONLD_ENCODE_FLAGS', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
}
/** /**
* Génère le JSON-LD pour un objet WebSite * Génère le JSON-LD pour un objet WebSite
* *
@@ -38,7 +50,7 @@ function generateWebSiteJsonLd() {
] ]
]; ];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return json_encode($data, JSONLD_ENCODE_FLAGS);
} }
/** /**
@@ -158,7 +170,7 @@ function generateVideoObjectJsonLd($videoData, $video) {
$data["videoFrameSize"] = "Portrait"; $data["videoFrameSize"] = "Portrait";
} }
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return json_encode($data, JSONLD_ENCODE_FLAGS);
} }
/** /**
@@ -254,7 +266,7 @@ function generatePodcastJsonLd($episodes) {
"@graph" => array_merge([$series], $episodeItems) "@graph" => array_merge([$series], $episodeItems)
]; ];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return json_encode($data, JSONLD_ENCODE_FLAGS);
} }
/** /**
@@ -327,7 +339,7 @@ function generateBreadcrumbJsonLd($breadcrumbs) {
"itemListElement" => $listItems "itemListElement" => $listItems
]; ];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return json_encode($data, JSONLD_ENCODE_FLAGS);
} }
/** /**
@@ -372,7 +384,7 @@ function generateVideoCollectionJsonLd($name, $description, $videos, $url) {
] ]
]; ];
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); return json_encode($data, JSONLD_ENCODE_FLAGS);
} }
/** /**
@@ -440,14 +452,70 @@ function truncateText($text, $length = 200) {
} }
/** /**
* Obtient l'URL de base du site * Valide un nom d'hôte applicatif (hostname, IPv4 ou localhost, port optionnel)
*
* @param string $host Hôte à valider
* @return bool True si l'hôte est exploitable en toute sécurité dans une URL
*/
function isValidAppHostName($host) {
if (!is_string($host) || $host === '' || strlen($host) > 253) {
return false;
}
// Labels alphanumériques/tirets séparés par des points, port optionnel
return (bool) preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/i', $host);
}
/**
* Retourne le nom d'hôte de l'application, validé une fois à l'initialisation.
*
* Utilise la constante APP_HOST_NAME (configuration) et jamais l'en-tête
* HTTP_HOST fourni par le client, afin d'empêcher l'injection via Host.
*
* @return string Nom d'hôte validé ('localhost' en repli)
*/
function getAppHostName() {
static $validatedHost = null;
if ($validatedHost === null) {
$configuredHost = defined('APP_HOST_NAME') ? (string) APP_HOST_NAME : '';
if (isValidAppHostName($configuredHost)) {
$validatedHost = $configuredHost;
} else {
error_log('SECURITY: APP_HOST_NAME absent ou invalide, repli sur localhost');
$validatedHost = 'localhost';
}
}
return $validatedHost;
}
/**
* Obtient l'URL de base du site (schéma + hôte validé)
* *
* @return string URL de base * @return string URL de base
*/ */
function getBaseUrl() { function getBaseUrl() {
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http'; $scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; return $scheme . '://' . getAppHostName();
return $scheme . '://' . $host; }
/**
* Construit l'URL absolue de la page courante.
*
* L'hôte provient de getBaseUrl() (APP_HOST_NAME validé, pas l'en-tête Host)
* et REQUEST_URI est débarrassé des guillemets, chevrons, espaces et
* caractères de contrôle qui casseraient un attribut HTML ou une URL.
*
* @return string URL absolue de la requête courante
*/
function getCurrentUrl() {
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
$requestUri = preg_replace('/[\x00-\x20"<>\'`]/', '', $requestUri);
if ($requestUri === '' || $requestUri[0] !== '/') {
$requestUri = '/' . $requestUri;
}
return getBaseUrl() . $requestUri;
} }
/** /**
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* Tests unitaires pour includes/structured-data.php
* (encodage JSON-LD anti-injection et construction d'URL de confiance)
*/
// --- JSON-LD : neutralisation de "</script>" ---------------------------------
$maliciousTitle = 'Vidéo </script><script>alert("xss")</script> test';
$videoData = [
'thumbnailPath' => '/static/thumbnails/test.jpg',
];
$video = [
'id' => '9c5de94d-8e1a-4b3c-9d2e-1234567890ab',
'title' => $maliciousTitle,
'description' => 'Une description',
'duration' => 120,
'channel' => 'Chaîne </script>',
'views' => 42,
'date' => '2024-01-15 10:00:00',
'tags' => ['test'],
];
$jsonLd = generateVideoObjectJsonLd($videoData, $video);
assertNotContains('</script>', $jsonLd, 'generateVideoObjectJsonLd ne reproduit pas "</script>" en clair');
// L'équivalent encodé de "</script>" (séquences \u00XX via JSON_HEX_TAG)
// doit apparaître à la place de la balise en clair
$encodedClosingTag = trim(json_encode('</script>', JSON_UNESCAPED_SLASHES | JSON_HEX_TAG), '"');
assertContains(
$encodedClosingTag,
$jsonLd,
'generateVideoObjectJsonLd encode "<" et ">" en séquences unicode (JSON_HEX_TAG)'
);
$decoded = json_decode($jsonLd, true);
assertTrue(is_array($decoded), 'Le JSON-LD VideoObject reste un JSON valide');
assertEquals(
$maliciousTitle,
$decoded['name'],
'Le titre malveillant est préservé à l\'identique après décodage JSON'
);
// Les autres générateurs JSON-LD bénéficient des mêmes drapeaux d'encodage
$websiteLd = generateWebSiteJsonLd();
assertNotContains('</script>', $websiteLd, 'generateWebSiteJsonLd ne contient pas "</script>"');
assertTrue(json_decode($websiteLd, true) !== null, 'generateWebSiteJsonLd produit un JSON valide');
$breadcrumbLd = generateBreadcrumbJsonLd([
['name' => 'Accueil', 'url' => 'https://test.local'],
['name' => 'Page </script>', 'url' => 'https://test.local/page'],
]);
assertNotContains('</script>', $breadcrumbLd, 'generateBreadcrumbJsonLd neutralise "</script>" dans un nom de fil d\'Ariane');
$collectionLd = generateVideoCollectionJsonLd('Collection </script>', 'Description', [$video], 'https://test.local/col');
assertNotContains('</script>', $collectionLd, 'generateVideoCollectionJsonLd neutralise "</script>" dans le nom de collection');
$podcastLd = generatePodcastJsonLd([
[
'title' => 'Épisode </script>',
'link' => 'https://podcast.example/ep1',
'pubDate' => '2024-01-01',
],
]);
assertNotContains('</script>', $podcastLd, 'generatePodcastJsonLd neutralise "</script>" dans un titre d\'épisode');
assertTrue(json_decode($podcastLd, true) !== null, 'generatePodcastJsonLd produit un JSON valide');
// --- isValidAppHostName -------------------------------------------------------
assertTrue(isValidAppHostName('example.com'), 'isValidAppHostName accepte un nom de domaine');
assertTrue(isValidAppHostName('test.local'), 'isValidAppHostName accepte un domaine local');
assertTrue(isValidAppHostName('localhost'), 'isValidAppHostName accepte localhost');
assertTrue(isValidAppHostName('127.0.0.1:8080'), 'isValidAppHostName accepte une IPv4 avec port');
assertTrue(isValidAppHostName('sub.example-site.com'), 'isValidAppHostName accepte sous-domaines et tirets');
assertFalse(isValidAppHostName(''), 'isValidAppHostName refuse une chaîne vide');
assertFalse(isValidAppHostName('evil.com"><script>'), 'isValidAppHostName refuse guillemets et chevrons');
assertFalse(isValidAppHostName("evil.com\r\nX-Injected: 1"), 'isValidAppHostName refuse les CRLF (injection d\'en-tête)');
assertFalse(isValidAppHostName('http://evil.com'), 'isValidAppHostName refuse une URL avec schéma');
assertFalse(isValidAppHostName('evil com'), 'isValidAppHostName refuse les espaces');
assertFalse(isValidAppHostName('evil.com/path'), 'isValidAppHostName refuse un chemin');
// --- getBaseUrl / getAppHostName : hôte de confiance --------------------------
// Un en-tête Host malveillant ne doit pas influencer l'URL de base
$_SERVER['HTTP_HOST'] = 'evil.example"><script>alert(1)</script>';
$_SERVER['HTTPS'] = 'on';
assertEquals(
'https://' . APP_HOST_NAME,
getBaseUrl(),
'getBaseUrl utilise APP_HOST_NAME et ignore un HTTP_HOST malveillant'
);
assertNotContains('evil.example', getBaseUrl(), 'getBaseUrl ne reflète pas l\'en-tête Host');
assertEquals(APP_HOST_NAME, getAppHostName(), 'getAppHostName retourne APP_HOST_NAME validé');
unset($_SERVER['HTTPS']);
assertEquals('http://' . APP_HOST_NAME, getBaseUrl(), 'getBaseUrl retombe en http sans HTTPS');
// --- getCurrentUrl : REQUEST_URI assaini --------------------------------------
$_SERVER['REQUEST_URI'] = '/video.php?id=abc"><meta http-equiv="refresh" content="0;url=https://phishing.example">';
$currentUrl = getCurrentUrl();
assertNotContains('"', $currentUrl, 'getCurrentUrl supprime les guillemets de REQUEST_URI');
assertNotContains('<', $currentUrl, 'getCurrentUrl supprime les chevrons de REQUEST_URI');
assertNotContains(' ', $currentUrl, 'getCurrentUrl supprime les espaces de REQUEST_URI');
assertEquals(
0,
strpos($currentUrl, 'http://' . APP_HOST_NAME . '/video.php?id=abc'),
'getCurrentUrl préfixe par l\'hôte validé et conserve le chemin'
);
$_SERVER['REQUEST_URI'] = 'sans-slash-initial';
assertEquals(
'http://' . APP_HOST_NAME . '/sans-slash-initial',
getCurrentUrl(),
'getCurrentUrl ajoute le slash initial manquant'
);
// Nettoyage des superglobales modifiées pour ne pas impacter d'autres tests
unset($_SERVER['HTTPS']);
$_SERVER['HTTP_HOST'] = APP_HOST_NAME;
$_SERVER['REQUEST_URI'] = '/';