La règle que tout le monde retient tient en un mot : échapper. Elle est juste, mais incomplète, et cette incomplétude est précisément ce qui produit les failles que l'on retrouve en audit. htmlspecialchars protège le corps d'un document HTML, et fait un travail correct dans une valeur d'attribut correctement délimitée. En revanche, elle ne protège en rien une donnée insérée dans un bloc de script, dans une feuille de style, dans une adresse de lien ou dans un attribut laissé sans délimiteurs. Le raisonnement utile n'est donc pas de savoir s'il faut échapper, mais de savoir dans quel contexte la donnée atterrit, chaque contexte ayant ses propres caractères dangereux et son propre traitement.

Ce que la fonction fait, et ne fait pas

Comprendre exactement le périmètre de cette fonction évite de lui accorder une confiance qu'elle ne mérite que dans certaines situations. Le mécanisme d'attaque sous jacent est décrit dans notre article consacré au XSS et à la protection contre les injections de script.

Une conversion de cinq caractères

La fonction convertit l'esperluette, les deux chevrons et, selon les options actives, les deux formes de guillemets, en entités HTML. C'est tout. Ces cinq caractères suffisent à empêcher qu'une donnée ne soit interprétée comme du balisage dans le corps d'un document, puisqu'ils sont les seuls à pouvoir ouvrir une balise ou fermer une valeur d'attribut. En dehors de ce contexte précis, cette liste n'a aucune raison d'être la bonne, et elle laisse passer sans les toucher des caractères parfaitement exploitables ailleurs, à commencer par les parenthèses, les points virgules et les apostrophes selon la configuration. Il faut également noter qu'elle ne modifie rien à la structure de la donnée : une chaîne contenant du balisage reste une chaîne contenant du balisage, simplement rendue inoffensive à l'affichage, ce qui explique qu'un contenu échappé puis réinjecté ailleurs redevienne dangereux.

Les options qui changent tout

Le comportement par défaut a varié selon les versions de PHP, et une application ancienne peut très bien convertir les guillemets doubles sans toucher aux apostrophes. Comme une bonne partie des gabarits délimitent leurs attributs par des apostrophes, la protection devient nulle sur ces attributs alors que l'appel à la fonction est bien présent dans le code. L'indicateur combinant les deux formes de guillemets et le traitement du HTML5 doit donc être passé explicitement, à chaque appel, sans se reposer sur la valeur par défaut de la version installée.

L'encodage déclaré

Le troisième paramètre indique l'encodage du texte traité. Une valeur incorrecte peut conduire la fonction à retourner une chaîne vide sur une donnée mal formée, ce qui produit des champs vides inexpliqués, ou à mal interpréter des séquences d'octets. L'encodage doit correspondre à celui de la base, à celui des fichiers et à celui déclaré dans la page, ces quatre valeurs devant être identiques dans toute la chaîne. La plupart des comportements erratiques d'échappement viennent d'une divergence sur ce point précis, et non de la fonction elle même. Le contrôle se fait en trois vérifications rapides : le jeu de caractères des tables, celui de la connexion à la base et celui déclaré dans l'en tête de réponse, qui doivent tous trois désigner la même chose.

Le double échappement

Appliquer la fonction deux fois transforme une esperluette déjà convertie en une nouvelle entité, ce qui fait apparaître des séquences illisibles dans les pages. Le problème n'est pas de sécurité mais de qualité, et il devient difficile à corriger quand les données ont été enregistrées échappées en base. Le paramètre permettant d'éviter la conversion des entités existantes atténue le symptôme sans traiter la cause, qui est presque toujours un échappement effectué au mauvais moment, à l'enregistrement plutôt qu'à l'affichage.

Ce que la fonction ne protège pas

Elle ne protège ni contre une injection dans une requête de base de données, où seules les requêtes préparées valent quelque chose, ni contre une inclusion de fichier construite à partir d'une donnée reçue, ni contre une commande système. Ce sont des contextes entièrement différents, avec des caractères dangereux différents. L'idée d'une fonction unique nettoyant les données une fois pour toutes est séduisante et fausse, et c'est elle qui produit les applications où tout est échappé partout sans que rien ne soit réellement protégé. Ces applications sont d'ailleurs les plus pénibles à reprendre, puisque la présence visible d'un traitement à chaque ligne donne une impression de rigueur qui décourage l'examen réel de chaque point d'insertion.

Contextes d’insertion de données dans un document HTML et traitements associés

Un échappement par contexte

Voici la correspondance entre les endroits où une donnée peut atterrir et le traitement qui convient. Un moteur de gabarits bien conçu applique ces règles pour vous, comme nous le montrons dans notre article sur la façon d'écrire un système de gabarits en PHP pur.

Le corps du document

C'est le cas le plus simple et le seul où la fonction standard suffit sans réserve. Une donnée placée entre deux balises ne peut nuire que si elle parvient à ouvrir une balise, ce que la conversion des chevrons empêche entièrement. Le seul point de vigilance concerne les zones du document qui ressemblent au corps sans en être : l'intérieur d'une balise de script, d'une balise de style ou d'un commentaire, où les règles d'interprétation sont totalement différentes. Une donnée placée dans un commentaire HTML peut ainsi refermer ce commentaire et poursuivre en balisage ordinaire, ce qui surprend toujours parce qu'un commentaire passe pour une zone inerte.

Une valeur d'attribut

Dans un attribut correctement délimité, la fonction convient à condition que les deux formes de guillemets soient converties. Le risque réside dans la fermeture prématurée de la valeur, qui permet ensuite d'ajouter un attribut d'événement. La conversion des deux formes de délimiteurs ferme cette porte, mais elle suppose que le délimiteur soit bien présent, ce qui n'est pas toujours le cas dans les gabarits écrits rapidement, et cette hypothèse mérite d'être vérifiée plutôt que supposée.

Une adresse dans un attribut de lien

Une adresse est un contexte à part entière. Une donnée insérée dans un attribut de lien peut désigner un protocole exécutable, ce qui déclenche du code au clic sans qu'aucun chevron ni guillemet ne soit nécessaire. La protection consiste à valider le protocole en le comparant à une liste courte d'entrées autorisées, avant tout échappement. Pour les paramètres d'une adresse, c'est la fonction d'encodage d'adresse qui s'applique, et non celle destinée au HTML. Les deux traitements se combinent d'ailleurs : le paramètre est d'abord encodé pour l'adresse, puis l'adresse complète est échappée pour le document, dans cet ordre et jamais dans l'autre.

Un contexte JavaScript

Insérer une donnée dans un bloc de script est le cas le plus dangereux, et la fonction standard y est inopérante : à l'intérieur d'une chaîne JavaScript, une entité HTML n'est pas décodée et le texte reste tel quel, tandis qu'une apostrophe non traitée ferme la chaîne. La seule méthode sûre consiste à ne jamais écrire de donnée directement dans le script, mais à la transmettre encodée en JSON, avec les options de protection des chevrons et de l'esperluette activées. Une variante plus robuste encore consiste à déposer la valeur dans un attribut de données sur un élément du document, puis à la lire depuis le script, ce qui ramène le problème au contexte d'attribut, bien mieux maîtrisé.

Un contexte CSS

Le cas est rare mais réel dès qu'une valeur d'apparence provient d'une saisie, par exemple une couleur choisie dans une interface. Une donnée insérée dans une déclaration de style peut fermer la déclaration et en ouvrir une autre, ou faire appel à une ressource externe. Ici encore, la validation par liste de valeurs autorisées vaut mieux que tout échappement : une couleur doit correspondre à un format connu, et tout ce qui n'y correspond pas est rejeté sans discussion.

Un contexte JSON

Une donnée sérialisée en JSON puis insérée dans une page mêle deux contextes superposés. L'encodage JSON protège la structure du document mais laisse passer les chevrons, qui peuvent fermer la balise de script englobante. Les options dédiées à l'échappement des chevrons, de l'esperluette et des apostrophes existent précisément pour cela et doivent être activées systématiquement dès que la sortie est insérée dans un document HTML plutôt que renvoyée telle quelle à un client.

Contexte Traitement Risque si omis
Corps du document Conversion des entités HTML Injection de balise
Valeur d'attribut Conversion incluant les deux guillemets Ajout d'un attribut d'événement
Attribut de lien Validation du protocole puis encodage Exécution au clic
Paramètre d'adresse Encodage d'adresse Détournement de paramètre
Bloc de script Encodage JSON avec protection des chevrons Exécution immédiate
Déclaration de style Validation par liste autorisée Appel de ressource externe
Requête de base Requête préparée Injection SQL

Les pièges les plus fréquents

Ces cinq erreurs représentent l'essentiel de ce que nous relevons en revue de code, et aucune ne vient d'une méconnaissance de la fonction elle même. Elles portent toutes sur le moment ou l'endroit où elle est appliquée.

Échapper à l'entrée plutôt qu'à la sortie

Transformer les données au moment de leur enregistrement paraît économique, puisqu'on ne le fait qu'une fois. C'est une fausse bonne idée : la base contient alors du HTML au lieu du texte saisi, les recherches ne fonctionnent plus correctement, les exports contiennent des entités, et le jour où la même donnée doit alimenter un flux ou un courriel, il faut la déséchapper. L'échappement dépendant du contexte de sortie, il ne peut être appliqué qu'au moment de la sortie, et jamais avant.

Faire confiance à une source interne

Les données considérées comme sûres parce qu'elles viennent de la base, d'un fichier de configuration ou d'une interface d'administration ne le sont pas. Elles ont bien été saisies par quelqu'un à un moment, parfois par un compte compromis, parfois par une importation automatisée. La règle est de traiter la donnée selon sa destination et non selon son origine, ce qui présente en outre l'avantage d'être une règle simple, applicable sans réfléchir à chaque affichage.

Les attributs sans délimiteurs

Un attribut écrit sans guillemets autour de sa valeur peut être quitté par un simple espace, ce qui permet d'ajouter un attribut d'événement sans employer aucun des caractères convertis par la fonction. C'est la faille la plus fréquemment rencontrée dans les gabarits anciens, et elle survit à un audit superficiel puisque l'appel à la fonction d'échappement est bien présent. La règle absolue consiste à délimiter systématiquement toutes les valeurs d'attributs, sans exception.

Le HTML légitime dans le contenu

Un champ destiné à recevoir du texte enrichi ne peut pas être échappé, sous peine d'afficher du balisage à l'écran. Il doit être nettoyé, ce qui est une opération entièrement différente : un analyseur reconstruit le document en ne conservant que les balises et attributs figurant sur une liste autorisée. Écrire ce nettoyage soi même à coups d'expressions régulières est une erreur classique, l'exercice étant bien plus difficile qu'il n'y paraît et régulièrement contourné. Une bibliothèque éprouvée fait ce travail correctement, elle est maintenue face aux nouvelles techniques de contournement, et son coût d'intégration se compte en minutes.

Les gabarits assemblés par concaténation

Un fragment de HTML construit par concaténation de chaînes dans le code applicatif rend impossible toute vérification systématique, puisque le contexte de chaque insertion n'est plus lisible. La séparation entre le code qui prépare les données et le gabarit qui les affiche n'est pas seulement une question de propreté : c'est ce qui permet de garantir qu'aucune donnée n'atteint le document sans avoir traversé la bonne fonction. Sans cette séparation, la vérification devient une lecture manuelle ligne à ligne, exercice que personne ne mène jusqu'au bout sur une application de quelques dizaines de milliers de lignes.

Répartition des défauts d'échappement relevés en revue de code applicatif
Attribut sans délimiteurs
29 %
Donnée insérée dans un script
24 %
Échappement fait à l'enregistrement
19 %
Source interne jugée sûre
16 %
Adresse non validée
12 %

Défauts relevés sur des applications PHP en revue de code. Aucun ne vient d'une méconnaissance de la fonction, tous du moment ou du contexte de son emploi.

Rendre l'échappement systématique

Une règle qui repose sur la vigilance de chacun finit par être oubliée. L'objectif est de rendre l'oubli impossible, ou au moins immédiatement visible.

Une fonction courte par contexte

Plutôt que d'appeler la fonction standard avec ses trois paramètres à chaque insertion, il vaut mieux disposer de quelques fonctions très courtes, une par contexte, nommées de façon explicite. Le code du gabarit devient lisible, les options ne peuvent plus être oubliées, et un changement de politique se fait en un seul endroit. C'est un investissement de vingt lignes qui supprime définitivement une catégorie entière d'erreurs de configuration.

Un moteur de gabarits qui échappe par défaut

La meilleure protection consiste à inverser la valeur par défaut : tout est échappé sauf mention contraire explicite. Les moteurs de gabarits courants fonctionnent ainsi, et un système écrit sur mesure peut adopter le même principe sans difficulté. L'affichage brut devient alors un geste délibéré, visible en relecture, et les rares endroits où il est légitime se repèrent immédiatement dans le code plutôt que de se confondre avec le reste.

Le contrôle par relecture automatisée

Un outil d'analyse statique repère les affichages de variables non traitées dans les gabarits et les signale à chaque intégration de code. Le paramétrage demande une heure et il faut accepter un premier passage bruyant, mais le dispositif empêche ensuite toute régression silencieuse. C'est particulièrement utile sur les projets où plusieurs personnes interviennent, la discipline individuelle ne survivant jamais très longtemps à un délai serré.

Une politique de sécurité du contenu en filet

Une politique de sécurité du contenu correctement configurée empêche l'exécution des scripts intégrés directement dans la page, ce qui neutralise une large part des injections même lorsqu'une donnée est passée au travers. Elle ne remplace pas l'échappement et ne doit jamais servir d'excuse pour le négliger, mais elle transforme une faille exploitable en tentative bloquée. Sa mise en place est détaillée dans notre article consacré à la Content Security Policy.

Tester avec des chaînes témoins

Un jeu de quelques chaînes contenant les caractères sensibles, insérées dans tous les champs de l'application, révèle en une heure les points d'insertion mal traités. Le test doit vérifier deux choses : que rien ne s'exécute, et que la chaîne s'affiche exactement telle qu'elle a été saisie. Ce second point détecte les doubles échappements, qui sont invisibles autrement et qui finissent toujours par être signalés par un utilisateur plusieurs mois plus tard.

Trois règles à retenir

Pour une équipe, tout tient en trois phrases : on échappe à la sortie et jamais à l'entrée, on choisit le traitement selon la destination et non selon la provenance, on délimite toujours les valeurs d'attributs. Ces trois règles couvrent la quasi totalité des cas réels, elles s'expliquent en cinq minutes et se vérifient en relecture. Le reste relève de situations particulières qui méritent d'être traitées au cas par cas, avec l'aide de quelqu'un qui connaît le contexte concerné. Une quatrième phrase peut être ajoutée pour les équipes qui manipulent du texte enrichi : ce qui doit contenir du HTML se nettoie et ne s'échappe pas, et le nettoyage se confie à une bibliothèque plutôt qu'à une expression régulière écrite pour l'occasion. Cette bibliothèque se choisit une fois pour toutes et se configure au niveau du projet, non au cas par cas dans chaque gabarit.