WordPress est solide, mais il n’est pas magique. Une partie de la sécurité côté XSS tient moins à “mettre un pare-feu” qu’à discipliner la façon dont votre application manipule le texte, puis l’affiche. Dans un site WordPress réel, on voit vite le même scénario se répéter: un champ “simple” passe de la base de données à l’interface via un thème ou un plugin, et quelque part entre les deux, l’output n’est pas échappé au bon endroit. Résultat, du JavaScript peut se glisser dans une page pourtant “validée”.
Le durcissement contre les injections de type XSS (Cross-Site Scripting) repose sur deux piliers qui se complètent:
- traiter proprement les entrées, mais sans se reposer uniquement dessus surtout, échapper correctement l’output au moment exact où vous construisez HTML, attributs, URLs et scripts
Dans la pratique, si vous voulez faire gagner des points à votre WordPress, c’est là que vous devez investir.
XSS dans WordPress, ce que l’on attaque vraiment
Le XSS a plusieurs formes, et elles n’appellent pas les mêmes réflexes.
Le XSS stocké apparaît quand du contenu malveillant est enregistré côté serveur, puis réutilisé plus tard par d’autres pages. Un champ de profil, une méta donnée d’article, un commentaire, un champ option stocké, ou une partie de contenu injectée via un plugin, et vous avez un “stock”.
Le XSS réfléchi arrive quand la charge utile est renvoyée telle quelle dans la réponse, par exemple via un paramètre GET ou POST qui influence une vue. Une recherche avec un paramètre dans le HTML sans échappement, une redirection mal gérée, un message d’erreur construit à partir d’un champ non échappé, tout ça peut suffire.
Enfin, il y a des XSS “DOM-based”, où le danger se joue côté navigateur, souvent via du JavaScript qui lit l’emplacement (location, hash, dataset) et réinjecte dans le DOM. WordPress n’échappe pas à ce risque, surtout dès que vous avez des scripts front qui consomment des données.
Pour durcir, vous devez raisonner par contexte d’affichage. Une même chaîne ne doit pas être échappée “une fois pour toutes”, elle doit être échappée selon ce qu’elle va devenir: texte HTML, attribut HTML, URL, fragment de script, contenu autorisé, etc.
Input ne remplace pas l’output escaping
Un piège fréquent consiste à croire que “sanitizer à l’entrée” suffit. Dans WordPress, on utilise souvent des fonctions de sanitization comme sanitize_text_field() ou sanitize_email(), et c’est très bien. Mais ces fonctions n’ont pas le même but que l’échappement.
- La sanitization vise à retirer ou normaliser certaines valeurs problématiques, par exemple des retours à la ligne, ou des caractères inattendus, ou limiter le format. L’échappement vise à rendre une valeur sûre dans un contexte de sortie. En clair, même une donnée “propre” peut devenir dangereuse si elle est injectée dans un HTML sans échapper les caractères qui ont un sens spécial.
Imaginons un champ texte “titre” qui doit afficher “Bonjour”. Si un attaquant y met
, une sanitization naïve peut réduire le risque, mais si quelque part vous faites ensuite un echo $value; dans votre template sans échappement contextuel, le navigateur interprète. Dans WordPress, la règle que je recommande est simple: vous pouvez sanitiser pour limiter les surprises dans la base, mais vous devez toujours échapper à la sortie, même pour des rôles “qui semblent fiables” (auteurs, admins, éditeurs). Les bons réflexes côté thème et plugin: échapper au bon contexte
Dans un projet WordPress, les injections XSS viennent rarement “de WordPress” lui-même. Elles viennent presque toujours d’un endroit où un développeur a imprimé une valeur sans la convertir pour le contexte HTML exact.
Voici les fonctions d’échappement qui reviennent le plus souvent dans le code WordPress, parce qu’elles couvrent des contextes concrets:
- Pour le contenu texte dans une page HTML: esc_html() Pour un attribut: esc_attr() Pour une URL (href, src): esc_url() Pour un bloc HTML autorisé (quand vous voulez accepter un sous-ensemble de balises): wp_kses_post() ou wp_kses() Pour une entrée destinée à être incluse dans un script ou un attribut de type “inline”: on utilise d’autres stratégies, souvent wp_json_encode() pour passer des données JSON, puis on échappe au niveau du script ou de la structure
Le point clé, c’est l’endroit où vous appelez la fonction. L’échappement doit se produire juste avant l’affichage, au moment où vous construisez la chaîne finale. Si vous échappez trop tôt, vous risquez de perdre des intentions, comme vouloir reconstruire un fragment HTML ensuite.
Un exemple classique côté thème: vous avez un champ d’option qui contient un slogan et vous voulez l’afficher dans un h1. La version robuste fait en général quelque chose du genre echo esc_html( $slogan );. Si, à la place, vous utilisez echo $slogan;, vous laissez une porte ouverte.
Autre exemple, un attribut data-... rempli à partir de la base. Il doit passer par esc_attr(), pas par esc_html(). Les deux peuvent “sembler” corrects au premier abord, mais l’attribut a ses propres règles, notamment pour les guillemets et certains caractères.
Cas pratique: méta données, formulaires et rendus fragmentés
La plupart des WordPress custom rencontrent le couple “formulaire” et “rendu” via des hooks. Un formulaire enregistre une valeur, puis plus tard, un template affiche cette valeur.
Le durcissement commence dès l’enregistrement, mais il se joue à l’affichage:
- côté formulaire (traitement POST), vous validez la requête et vous contrôlez les capacités (current_user_can), puis vous sanitisez côté affichage, vous échappez selon le contexte
Sur un site où j’ai vu des XSS passer, ce n’était pas la partie “enregistrer” qui posait problème, c’était le rendu d’une valeur dans un champ d’interface. Le développeur avait correctement normalisé le contenu, mais l’avait ensuite injecté dans un attribut title sans échappement. Le navigateur a exécuté le code parce que le contexte d’attribut n’était pas correctement sécurisé.
Cette erreur est réaliste, parce qu’un attribut semble “juste décoratif”. Mais un attribut peut transporter des guillemets, des séquences de fermeture, et ouvrir la voie à du HTML et du script. esc_attr() existe précisément pour ce genre de cas.
Désactiver l’angle mort: rôles, permissions et “unfiltered_html”
Quand on durcit WordPress, on rencontre vite la question du contenu “non filtré”. Par défaut, WordPress limite la possibilité d’entrer du HTML brut selon le rôle utilisateur. La capacité unfiltered_html change la donne: elle autorise des utilisateurs à publier du contenu HTML non filtré via l’éditeur ou d’autres routes.
Le problème n’est pas uniquement “un utilisateur malveillant”. Même sans intention, une configuration trop large peut laisser passer des contenus qui finissent ensuite dans des pages avec un rendu inattendu.
Concrètement, votre politique de sécurité devrait être:
- garder unfiltered_html le plus restrictif possible vérifier qui a réellement cette capacité dans vos environnements s’assurer que vos plugins ne “rebaignent” pas du HTML brut sans contrôle, puis ne l’affichent pas sans wp_kses
Cela ne remplace pas l’échappement, mais ça réduit la surface d’attaque, surtout pour les XSS stockés.
Filtrer l’output quand vous autorisez un sous-ensemble HTML
Il y a des cas où vous ne voulez pas afficher du texte brut, vous voulez autoriser une portion de HTML: liens, gras, listes, mise en forme légère. WordPress vous donne des briques, mais c’est à vous de calibrer.
wp_kses_post() est souvent le point de départ pour du contenu qui ressemble à un “post body”. Si vous autorisez plus finement, vous pouvez utiliser wp_kses() avec une liste d’autorisations ciblées, ou filtrer la liste des balises autorisées via les filtres WordPress adaptés à votre contexte.
Le compromis est réel. Plus vous autorisez de balises, plus vous devez vous assurer que:
- les attributs autorisés sont suffisamment restrictifs les protocoles d’URL sont filtrés (par exemple éviter javascript:) vous ne laissez pas passer des événements inline ou des attributs qui déclenchent du comportement
À l’inverse, trop restreindre rend l’interface frustrante, parce que le contenu “cassera” au rendu. C’est souvent un sujet côté UX et support: “pourquoi mon lien ne s’affiche pas comme avant ?”. Pour éviter de tomber dans un durcissement brut, j’ai tendance à commencer avec wp_kses_post() et à affiner ensuite selon les besoins réels du contenu, en observant les types de HTML qu’utilisent les rédacteurs.
La couche REST et les pages admin: XSS en coulisses
Même si votre site est principalement front, l’administration WordPress a aussi son lot de risques. Les écrans d’admin affichent des valeurs provenant de la base, de paramètres, ou de réponses API, et ce sont des endroits où l’erreur est coûteuse.
Une bonne pratique est de traiter toute donnée envoyée au front comme non fiable jusqu’à preuve du contraire, puis de:

- échapper côté PHP avant d’injecter dans les vues échapper côté JavaScript avant d’insérer dans le DOM
Pour les interfaces qui consomment REST, un schéma robuste consiste à passer les données en JSON avec wp_json_encode(), puis à construire le DOM en utilisant des méthodes qui ne réinterprètent pas du HTML arbitraire. Si vous faites innerHTML = ..., vous redonnez au navigateur la possibilité d’interpréter une charge utile. À la place, privilégiez textContent pour du texte, ou des créations DOM contrôlées.
Je ne dis pas que tout doit être réécrit, mais quand vous durcissez contre XSS, c’est souvent là que le gain est le plus rapide: remplacer deux ou trois usages d’injection HTML par une insertion textuelle ou par une construction DOM propre.
Où ça se casse le plus souvent: “echo” dans les templates
Il n’y a pas de formule magique, mais il y a des motifs de code qui reviennent. Si vous auditez un thème ou un plugin, cherchez les endroits où une variable est imprimée directement.
Typiquement:
- echo $value; dans un template printf( '...%s...', $value ); sans échappement dans le format des concaténations de chaînes HTML où la valeur n’est pas passée par esc_*
Le danger augmente quand:
- vous injectez dans un attribut vous construisez une balise script vous utilisez des fonctions qui acceptent du HTML, mais que vous leur donnez des valeurs non validées
Une méthode d’audit efficace, à taille humaine, consiste à se concentrer d’abord sur les zones qui affichent des données “user-controlled”: champs de profil, champs de contenu personnalisés, champs de paramètres, résultats de recherche affichés dans une vue, messages d’erreur incluant des valeurs.
Si vous avez déjà un gestionnaire de contenu, n’oubliez pas les “petits champs” comme:
- champs de configuration de widget texte de pied de page libellés de menu descriptions de taxonomie attributs personnalisés
Ils finissent tous à un moment en sortie HTML.
Ajoutez une politique de défense en profondeur: CSP
L’échappement et le filtrage sont votre première https://gardewp.fr/securite-wordpress/ ligne, CSP est une seconde ligne de défense. Même si quelque chose glisse, CSP peut réduire la capacité du navigateur à exécuter du JavaScript injecté.
Cependant, CSP est un chantier. Selon votre stack, vous pouvez vous heurter à des scripts inline existants, des plugins qui injectent des tags, des analytics, des bundles buildés différemment. La meilleure approche que j’ai vue fonctionner sans casser le site est graduelle: commencer en rapportant, puis passer en mode bloquant. Selon vos outils, vous pouvez configurer une politique en mode “report-only”, pour mesurer ce qui viole la règle avant d’en durcir l’application.
Gardez en tête aussi que CSP ne remplace pas l’échappement. Elle limite, elle ne garantit pas.
Une checklist réaliste pour durcir sans casser votre contenu
Si vous deviez faire un premier durcissement pragmatique, voici un ordre de travail qui marche bien sur des projets existants. Plutôt que tout réécrire, vous ciblez les zones où l’impact est le plus fort.
- Vérifiez et réduisez l’accès à unfiltered_html pour limiter le HTML non filtré. Échappez chaque sortie en fonction du contexte, esc_html, esc_attr, esc_url, et wp_kses_post quand un sous-ensemble HTML est attendu. Remplacez les insertions dangereuses côté navigateur, en évitant les innerHTML alimentés par des données contrôlées par l’utilisateur. Construisez les URLs et attributs en utilisant esc_url et esc_attr, pas des concaténations brutes. Ajoutez une politique CSP progressive, idéalement en mode report-only au départ, pour repérer les violations avant de bloquer.
Ce n’est pas glamour, mais c’est là que les XSS sont stoppés.
Trade-offs: quand “trop sécuriser” casse l’interface
Durcir l’output peut créer des effets de bord, et c’est normal.
Par exemple, si vous passez systématiquement un champ “riche” par esc_html(), tout le HTML autorisé devient affiché comme du texte. Le rédacteur perd l’usage de gras, liens, ou listes.
Inversement, si vous “assouplissez” trop avec wp_kses_post() sans contrôler les attributs, vous augmentez le risque que des constructions inattendues passent. C’est un équilibre. Je préfère réfléchir en termes de contenu attendu plutôt que de tout autoriser “par facilité”, parce que chaque balise autorisée a un coût.
Autre cas: certains champs servent à alimenter un attribut ou un data-set pour des scripts. Si vous échappez incorrectement, vous pouvez casser des sélecteurs, des formats JSON, ou des valeurs lues par un script. C’est pour ça que le contexte doit guider la fonction d’échappement.
Scénarios concrets à tester
Un durcissement sans tests ressemble à un pansement. Les XSS ont des charges utiles variées, et certaines se manifestent seulement dans un contexte précis.
Sur WordPress, vous pouvez tester avec un jeu réduit mais ciblé, par exemple:
- une charge qui casse un élément HTML si elle est imprimée sans esc_html une charge qui tente de fermer un attribut si elle est injectée sans esc_attr une charge dans une URL si l’application utilise href ou src sans esc_url
Le but n’est pas de “définir un payload parfait”. C’est de vérifier que chaque contexte d’affichage résiste à ce type d’attaque.
Testez aussi les chemins moins évidents:
- paginations messages de recherche (souvent oubliés) pages de détail écrans admin qui affichent des valeurs d’options preview d’éditeur ou modales
Souvent, l’erreur n’est pas dans la page front principale, mais dans un modal, un tooltip, ou une zone qui ne reçoit pas la même attention.
Ce que je regarde quand j’audite un WordPress existant
Quand je dois hardener un WordPress déjà en production, je ne pars pas d’une liste théorique. Je commence par observer où les données utilisateurs vivent et où elles s’affichent.
Je cherche en priorité:
- les champs meta liés à des thèmes ou plugins custom les shortcodes et leurs handlers les widgets, surtout ceux qui acceptent des champs texte ou HTML les zones admin, parce que l’impact est plus sévère toute intégration où du HTML est rechargé côté client
Puis j’évalue, pour chaque sortie:
- quel contexte HTML est utilisé quelle fonction d’échappement est appliquée si l’échappement est fait au bon moment si un sous-ensemble HTML est attendu, est-ce que wp_kses_* est correctement configuré
Le plus souvent, vous corrigez quelques impressions directes, puis vous ajoutez une couche côté JS là où l’output est réinjecté dans le DOM.
Une règle d’équipe qui évite les retours en arrière
Le durcissement échoue quand il disparaît dans la mémoire d’un projet. Une fois corrigé, le “echo” sans échappement peut revenir ailleurs, surtout si plusieurs personnes touchent le thème ou des plugins maison.
Une règle simple que j’ai vue fonctionner: dans le code maison, on documente explicitement la différence entre sanitization et échappement, et on impose un réflexe à chaque ajout de champ.
Ça peut être aussi concret qu’une phrase dans la doc interne:
- “On sanitise à l’entrée, mais on échappe à la sortie selon le contexte”
Sans cette discipline, vous finissez avec des correctifs ponctuels, puis avec une dette de sécurité.
Derniers pièges à connaître
Même avec une bonne hygiène d’échappement, il reste quelques pièges:
Premièrement, les chaînes “préparées” ne doivent pas être re-traitées comme si elles étaient sûres. Si une valeur est réchappée puis ensuite concaténée dans un autre contexte, vous pouvez réintroduire un risque. L’échappement est contextuel, pas universel.
Deuxièmement, attention aux flux où PHP génère des morceaux de HTML qui sont ensuite utilisés par du JavaScript. Si un script lit une donnée et reconstruit des éléments avec innerHTML, vous annulez une partie des garanties.
Troisièmement, certains plugins ou thèmes injectent déjà du contenu. Le problème n’est pas “le plugin”, c’est la manière dont il affiche les données. Une dépendance peut être saine, mais une intégration peut être dangereuse si elle passe des valeurs à l’endroit où l’échappement manque.
Ce que “good enough” signifie pour une défense XSS
Le bon objectif en hardening n’est pas de rendre l’application invulnérable à toute forme de charge utile. L’objectif est de réduire drastiquement les surfaces d’injection et de s’assurer que les chemins d’affichage de données non fiables sont systématiquement filtrés.
Quand c’est bien fait, on voit trois effets:
- les XSS stockés deviennent rares car la chaîne “base vers interface” est maîtrisée les XSS réfléchis s’effondrent car chaque paramètre affiché est échappé côté navigateur, les injections dans le DOM sont traitées comme des données, pas comme du HTML
Et surtout, votre équipe peut livrer des fonctionnalités sans réinventer la sécurité à chaque fois.
Si vous voulez démarrer aujourd’hui, choisissez un thème ou un plugin custom, auditez les sorties qui affichent des champs, corrigez les échappements contextuels, puis testez les écrans les plus utilisés. Ensuite seulement, élargissez. C’est souvent le meilleur chemin pour durcir WordPress contre les injections XSS sans casser votre site, ni surcharger vos rédacteurs.