Chapitre 515 minIntermédiaire
Faille XSS en PHP : htmlspecialchars et échappement
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- expliquer le mécanisme d'une faille XSS stockée ou réfléchie ;
- échapper toute donnée affichée avec htmlspecialchars ;
- choisir le bon traitement pour un attribut, une URL ou un script ;
- ajouter une politique de sécurité du contenu (CSP).
3 exercices corrigés
Une faille XSS (Cross-Site Scripting) apparaît quand une page affiche une donnée sans l'échapper : si cette donnée contient du HTML ou du JavaScript, le navigateur l'exécute au lieu de l'afficher comme du texte. En PHP, la parade tient en une fonction, htmlspecialchars(), appliquée à chaque donnée au moment de l'afficher. Ce chapitre explique le mécanisme, montre comment échapper selon l'endroit où la donnée est insérée, et présente les protections complémentaires. Le principe de l'attaque et ses variantes sont détaillés dans le cours Sécurité des applications.
C'est quoi une faille XSS ?
Une page web mélange du code (les balises HTML, les scripts) et des données (un libellé, un nom, un commentaire). Quand ton script PHP colle une donnée au milieu du HTML sans précaution, le navigateur ne peut pas savoir où s'arrête la donnée et où recommence le code. Si un utilisateur a saisi un texte qui contient des balises, le navigateur les interprète comme si tu les avais écrites toi-même.
Le danger n'est pas pour le serveur, mais pour les autres utilisateurs qui affichent la page : un script injecté s'exécute dans leur navigateur, avec leur session. Il peut agir à leur place dans l'application (enregistrer un prêt, modifier un compte), lire ce qu'affiche la page, ou remplacer le contenu par un faux formulaire de connexion.
| Variante | D'où vient la donnée | Exemple dans Prêtmat |
|---|---|---|
| XSS stockée | De la base de données, où un utilisateur l'a enregistrée | Le commentaire saisi au retour d'un matériel, affiché ensuite au gestionnaire |
| XSS réfléchie | De la requête elle-même (un paramètre d'URL), renvoyée aussitôt dans la page | La page de recherche qui affiche « Résultats pour : … » |
| XSS basée sur le DOM | Du JavaScript du navigateur, qui insère une donnée dans la page | Un script qui lit l'URL et l'écrit avec innerHTML |
La XSS stockée est la plus grave : un seul enregistrement piégé touche tous ceux qui affichent la page, y compris le gestionnaire, qui a plus de droits. Une XSS réfléchie suppose d'amener la victime à cliquer sur un lien préparé.
Le code vulnérable et sa correction
La page de recherche de Prêtmat affiche le texte cherché :
<?php
// recherche.php : VULNÉRABLE
$q = $_GET['q'] ?? '';
echo "<h1>Résultats pour : $q</h1>";
Le contenu de $q vient de l'URL : n'importe qui peut fabriquer un lien recherche.php?q=… dont la valeur contient des balises. Le navigateur de la personne qui clique reçoit ces balises au milieu de la page et les interprète. La correction :
<?php
// recherche.php : corrigé
$q = $_GET['q'] ?? '';
$q = is_string($q) ? $q : '';
echo '<h1>Résultats pour : ' . htmlspecialchars($q) . '</h1>';
Le même texte est maintenant affiché tel quel, chevrons compris, au lieu d'être interprété. Le code est identique pour une donnée lue en base : le commentaire de retour d'un matériel passe par htmlspecialchars() au moment où la page de suivi l'affiche.
Ce que fait htmlspecialchars
htmlspecialchars() remplace les cinq caractères qui ont un sens en HTML par leur entité, que le navigateur affiche comme le caractère lui-même sans l'interpréter :
| Caractère | Devient | Pourquoi c'est dangereux |
|---|---|---|
< | < | Ouvre une balise |
> | > | Ferme une balise |
& | & | Commence une entité |
" | " | Ferme un attribut entre guillemets |
' | ' | Ferme un attribut entre apostrophes |
<?php
echo htmlspecialchars('Dupont & fils <b>"l\'atelier"</b>');
// Dupont & fils <b>"l'atelier"</b>
// Le navigateur affiche : Dupont & fils <b>"l'atelier"</b>
Depuis PHP 8.1, les réglages par défaut sont les bons : les apostrophes sont échappées (option ENT_QUOTES), une suite d'octets invalide en UTF-8 est remplacée par un caractère de substitution au lieu de rendre une chaîne vide (ENT_SUBSTITUTE), et l'encodage par défaut est UTF-8. Dans du code plus ancien, tu verras souvent htmlspecialchars($x, ENT_QUOTES, 'UTF-8') : c'était nécessaire avant PHP 8.1, c'est sans effet supplémentaire aujourd'hui.
Une fonction courte : e()
Tu vas appeler htmlspecialchars() des dizaines de fois par page. Une fonction au nom court rend les vues lisibles et évite les oublis :
<?php
declare(strict_types=1);
// src/helpers.php
function e(string|int|float|null $valeur): string
{
return htmlspecialchars((string) $valeur);
}
<?php $m = ['reference' => 'PC-0101', 'libelle' => 'Portable Dell Latitude 5450', 'valeur' => 1180.0]; ?>
<tr>
<td><?= e($m['reference']) ?></td>
<td><?= e($m['libelle']) ?></td>
<td><?= e(number_format($m['valeur'], 2, ',', ' ')) ?> €</td>
</tr>
La règle devient simple à vérifier en relisant une vue : chaque <?= contient un e(…), même pour un id ou un total : c'est plus simple que de se demander à chaque fois si la valeur peut contenir un caractère spécial. Les rares exceptions doivent sauter aux yeux : un fragment de HTML produit par ton propre code, déjà sûr (<?= champCsrf() ?>), ou un texte écrit en dur dans la vue (un libellé de formulaire, 'selected').
Échapper selon le contexte
htmlspecialchars() protège une donnée insérée dans le texte d'une page ou dans un attribut entre guillemets. Ailleurs, il faut un autre traitement :
| Où la donnée est insérée | Exemple | Ce qu'il faut faire |
|---|---|---|
| Texte entre deux balises | <td><?= e($libelle) ?></td> | htmlspecialchars() |
| Valeur d'attribut entre guillemets | value="<?= e($libelle) ?>" | htmlspecialchars(), et toujours des guillemets autour |
| Paramètre dans une URL | href="materiel.php?id=<?= $id ?>&q=<?= … ?>" | Construire l'URL avec http_build_query() (ou urlencode() pour une valeur), puis htmlspecialchars() sur l'URL complète |
| URL entière fournie par un utilisateur | href="<?= … ?>" (lien vers une notice) | Vérifier que le schéma est http ou https (avec parse_url()), puis htmlspecialchars() |
| Donnée pour un script JavaScript | const materiel = …; | json_encode() avec les options JSON_HEX_TAG, JSON_HEX_AMP, JSON_HEX_APOS, JSON_HEX_QUOT, ou mieux, un attribut data-… lu par le script |
| Style CSS, nom de balise ou d'attribut | style="color: <?= … ?>" | Ne jamais y mettre une saisie : choisir dans une liste blanche |
Pour l'attribut href, htmlspecialchars() ne suffit pas : une URL qui commence par un autre schéma que http ou https (le schéma javascript: par exemple) peut exécuter du code au clic, sans contenir aucun chevron. On vérifie donc le schéma :
<?php
declare(strict_types=1);
function urlSure(string $url): ?string
{
$schema = strtolower((string) parse_url($url, PHP_URL_SCHEME));
return in_array($schema, ['http', 'https'], true) ? $url : null;
}
$notice = urlSure('https://support.example/notice-latitude-5450.pdf');
if ($notice !== null) {
echo '<a href="' . htmlspecialchars($notice) . '">Notice du fabricant</a>';
}
Pour passer des données PHP à un script, json_encode() produit une valeur JavaScript valide ; les options JSON_HEX_* remplacent <, >, &, les apostrophes et les guillemets par des séquences Unicode (\u003C, \u003E, \u0026, \u0027, \u0022), ce qui empêche une donnée de fermer la balise <script> :
<?php $materiel = ['reference' => 'PC-0101', 'libelle' => 'Portable Dell']; ?>
<script>
const materiel = <?= json_encode($materiel, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?>;
</script>
Échapper à l'affichage, pas à l'enregistrement
On pourrait être tenté d'appliquer htmlspecialchars() une fois pour toutes, avant d'enregistrer la saisie en base. C'est une erreur :
- La donnée est abîmée. La base contient
Dupont & fils. L'export CSV, l'API JSON, le mail de relance et l'application mobile affichent ces entités telles quelles. - Elle est échappée deux fois. Affichée avec
e(), elle devientDupont &amp; fils, et l'utilisateur litDupont & filsà l'écran. - Ce n'est pas le bon traitement partout. Une donnée insérée dans une URL ou un script demande un autre échappement : impossible de le savoir au moment de l'enregistrement.
Afficher du texte sur plusieurs lignes ou du texte riche
Le commentaire de retour d'un matériel peut faire plusieurs lignes. Pour conserver les retours à la ligne, échappe d'abord, puis convertis les sauts de ligne en balises <br> :
<?php
$commentaire = "Écran rayé\nChargeur manquant";
echo nl2br(e($commentaire));
// Écran rayé<br />
// Chargeur manquant
L'ordre compte : e(nl2br($commentaire)) échapperait les <br /> ajoutés, qui s'afficheraient en texte.
Si une fonctionnalité doit vraiment accepter du HTML saisi par un utilisateur (un éditeur de texte riche, par exemple), strip_tags() ne suffit pas : il garde les attributs des balises autorisées, qui peuvent contenir du code. Il faut une bibliothèque de nettoyage par liste blanche, comme HTML Purifier, qui ne garde que les balises et attributs explicitement autorisés. Le plus simple reste souvent de proposer du texte brut, ou du Markdown converti en HTML par une bibliothèque configurée en mode sûr.
Les protections complémentaires : CSP et cookies HttpOnly
L'échappement est la protection principale. Deux mesures limitent les dégâts si une donnée non échappée t'a échappé :
- La politique de sécurité du contenu (Content Security Policy, CSP) : un en-tête HTTP qui dit au navigateur de n'exécuter que les scripts venant de sources autorisées. Avec la politique ci-dessous, un script écrit directement dans la page (dont un script injecté) est bloqué. Elle impose de ranger ton JavaScript dans des fichiers
.js. - L'attribut
HttpOnlydu cookie de session : un script ne peut pas lire le cookie, donc pas le voler (chapitre 6).
<?php
header("Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
header('X-Content-Type-Options: nosniff');
Ces en-têtes se posent dans le contrôleur frontal, une seule fois pour toutes les pages (chapitre 12), ou dans la configuration d'Apache ou de Nginx. Le cours Sécurité des applications détaille la CSP et ses options.
Tester tes pages
Pour vérifier qu'une page échappe correctement, saisis dans chaque champ un texte qui contient les caractères sensibles, par exemple Test <b>gras</b> "guillemets" 'apostrophe' & fin, puis affiche les pages qui montrent cette donnée : liste, fiche, formulaire de modification, export. Partout, tu dois lire le texte exactement tel que tu l'as tapé, chevrons compris, et le mot « gras » ne doit pas apparaître en gras. Fais ce test sur ta application, en local : tester des pages qui ne t'appartiennent pas est une infraction.
Exercices
Exercice 1 sur 3 Trouver les XSS Réussi À revoir
Pour chaque ligne de cette vue, dis si elle est vulnérable, pourquoi, et corrige-la. $m est une ligne de la table materiel, $q le texte de recherche reçu dans l'URL, $retour['commentaire'] un commentaire saisi par un employé.
<h1>Recherche : <?= $q ?></h1>
<input name="q" value=<?= e($q) ?>>
<td><?= e($m['libelle']) ?></td>
<td><?= $m['id'] ?></td>
<p><?= e(nl2br($retour['commentaire'])) ?></p>
<a href="<?= e($m['lien_notice']) ?>">Notice</a>
Voir la réponseMasquer la réponse de l'exercice 1
- Vulnérable :
$qvient de l'URL et n'est pas échappé (XSS réfléchie). Correction :<?= e($q) ?>. - Vulnérable : l'attribut
valuen'a pas de guillemets.htmlspecialchars()n'échappe pas les espaces : une saisie contenant un espace ajoute un nouvel attribut à la balise. Correction :value="<?= e($q) ?>". - Correct.
- Sans danger en pratique :
idest un entier auto-incrémenté produit par la base, pas une saisie. Mais la règle « chaque<?=contient une(…)» est plus simple à vérifier qu'un raisonnement au cas par cas, et elle protège si la colonne change de type un jour : écris<?= e($m['id']) ?>. - Incorrect : l'ordre est inversé.
nl2br()ajoute des<br />qui sont ensuite échappés et s'affichent en texte. Correction :<?= nl2br(e($retour['commentaire'])) ?>. - Vulnérable si
lien_noticeest saisi par un utilisateur :e()ne bloque pas un schéma dangereux dans unhref. Correction : n'afficher le lien que siurlSure($m['lien_notice'])ne renvoie pasnull, puis l'échapper.
Tu avais trouvé ?
Exercice 2 sur 3 Échappé à l'entrée Réussi À revoir
Un développeur a écrit le traitement du formulaire de matériel ainsi : $libelle = htmlspecialchars($_POST['libelle']);, puis enregistre $libelle en base. Le gestionnaire saisit le libellé « Câble HDMI 2 m <-> USB-C ». Qu'affichent : (a) la liste HTML qui fait <?= e($m['libelle']) ?> ; (b) l'API JSON de Prêtmat ; (c) l'export CSV ? Que faut-il changer ?
Voir la réponseMasquer la réponse de l'exercice 2
La base contient Câble HDMI 2 m <-> USB-C.
- (a) La liste échappe une deuxième fois : le HTML produit est
&lt;-&gt;, et l'utilisateur litCâble HDMI 2 m <-> USB-Cà l'écran, avec les entités en clair. - (b) L'API renvoie
"Câble HDMI 2 m <-> USB-C": l'application mobile affiche les entités. - (c) Le CSV contient aussi les entités.
Il faut retirer htmlspecialchars() du traitement : on valide la saisie (longueur, non vide), on l'enregistre telle quelle avec une requête préparée, et on l'échappe uniquement à l'affichage HTML. Les données déjà abîmées en base se corrigent avec html_entity_decode() dans un script de reprise, une seule fois.
Tu avais trouvé ?
Exercice 3 sur 3 Passer une donnée à JavaScript Réussi À revoir
La fiche d'un matériel doit transmettre sa référence et son libellé à un script qui affiche un QR code. Le code actuel est :
<script>
const libelle = '<?= $m['libelle'] ?>';
</script>
- Que se passe-t-il si le libellé contient une apostrophe, comme « Vidéoprojecteur de l'accueil » ?
- Pourquoi
e()n'est-il pas la bonne correction ici ? - Propose deux corrections.
Voir la réponseMasquer la réponse de l'exercice 3
-
L'apostrophe ferme la chaîne JavaScript : le script contient une erreur de syntaxe et ne s'exécute plus. Avec un libellé forgé, la suite de la chaîne deviendrait du code JavaScript exécuté : c'est une XSS.
-
e()produit des entités HTML ('). Or le contenu d'une balise<script>n'est pas décodé comme du HTML : le script recevrait littéralement'dans la chaîne. Le contexte est JavaScript, pas HTML. -
Première correction,
json_encode()avec les options qui neutralisent les caractères sensibles :<script> const libelle = <?= json_encode($m['libelle'], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?>; </script>Seconde correction, souvent plus propre et compatible avec une CSP stricte : un attribut
data-échappé avece(), lu par un script placé dans un fichier.js:<div id="qr" data-reference="<?= e($m['reference']) ?>" data-libelle="<?= e($m['libelle']) ?>"></div> <script src="/js/qrcode.js"></script>Dans
qrcode.js,document.getElementById('qr').dataset.libellerenvoie le libellé d'origine : le navigateur a décodé les entités de l'attribut.
Tu avais trouvé ?