fix(security): safe JSON-LD encoding and URL escaping
This commit is contained in:
+6
-4
@@ -3,6 +3,8 @@
|
||||
require_once 'includes/config.php';
|
||||
// Inclure les fonctions de sécurité
|
||||
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é
|
||||
setSecurityHeaders();
|
||||
?>
|
||||
@@ -14,7 +16,7 @@ setSecurityHeaders();
|
||||
<meta name="csrf-token" content="<?php echo generateCSRFToken(); ?>">
|
||||
<title><?php echo SITE_NAME; ?> - Ouverture prochaine</title>
|
||||
<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 -->
|
||||
<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');
|
||||
echo strftime('%e %B %Y', $targetDate->getTimestamp());
|
||||
?>. Restez connectés !">
|
||||
<meta property="og:image" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . '/img/logo.png'; ?>">
|
||||
<meta property="og:url" content="<?php echo 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; ?>">
|
||||
<meta property="og:image" content="<?php echo getBaseUrl() . '/img/logo.png'; ?>">
|
||||
<meta property="og:url" content="<?php echo htmlspecialchars(getCurrentUrl()); ?>">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="<?php echo SITE_NAME; ?>">
|
||||
<meta property="og:locale" content="fr_FR">
|
||||
@@ -56,7 +58,7 @@ setSecurityHeaders();
|
||||
setlocale(LC_TIME, 'fr_FR.UTF-8');
|
||||
echo strftime('%e %B %Y', $targetDate->getTimestamp());
|
||||
?>. 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 -->
|
||||
<script nonce="<?php echo getCspNonce(); ?>">
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
* 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
|
||||
*
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
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)
|
||||
];
|
||||
|
||||
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
|
||||
];
|
||||
|
||||
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
|
||||
*/
|
||||
function getBaseUrl() {
|
||||
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
return $scheme . '://' . $host;
|
||||
return $scheme . '://' . getAppHostName();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'] = '/';
|
||||
Reference in New Issue
Block a user