Sécurisation WordPress : prévenir les failles XSS

Une faille XSS (Cross-Site Scripting) ne ressemble pas à un grand braquage. C’est souvent plus discret. Un champ de formulaire qui affiche “en clair” une partie de ce que l’utilisateur a tapé, un paramètre d’URL mal contrôlé, un champ de profil qui accepte du contenu “un peu” HTML. Au final, quelqu’un peut injecter du code côté navigateur, et ce code s’exécute au nom de la victime. Selon le contexte, cela peut aller de la simple nuisance (défigurer une page) à des actions plus sérieuses, comme voler une session, forcer une action sur un compte, ou rediriger vers une page de phishing.

Sur WordPress, la tentation est parfois de penser que “tout vient du back-office”. En réalité, WordPress est une plateforme d’extension permanente: thèmes, plugins, widgets, shortcodes, builders de pages, compatibilités multilingues, et même des systèmes d’import. Chaque couche est une surface d’attaque potentielle si on ne traite pas correctement l’entrée et la sortie des données. La sécurisation WordPress contre les XSS, ce n’est pas une case “désactiver XSS” dans un panneau. C’est une discipline de traitement des données, et surtout une exigence constante sur le moment où on encode ou on autorise.

Comprendre ce que “XSS” veut dire concrètement

XSS, c’est l’exécution de code dans le navigateur de quelqu’un d’autre. Le cœur du problème, c’est le mélange entre données et instructions. Si une application renvoie à la page HTML une donnée non maîtrisée, et que cette donnée peut contenir des balises ou des attributs interprétables, le navigateur devient un exécuteur.

Trois familles reviennent souvent dans les rapports d’incidents:

    XSS stockée: la charge reste dans la base de données (un commentaire, une option, un champ de profil, un contenu ajouté via une interface). XSS réfléchie: la charge vit dans l’URL ou dans une requête, et le serveur la renvoie telle quelle. XSS basée DOM: le serveur renvoie du contenu “banal”, mais le JavaScript côté navigateur réassemble des fragments de façon dangereuse.

Dans WordPress, les scénarios typiques que j’ai vus passer en audit sont rarement “spectaculaires”. Le plus fréquent, c’est un attribut HTML qui se retrouve interpolé avec une valeur non échappée. Par exemple, un template qui fait quelque chose comme value="<?php echo $_GET['q']; ?>" sans encodage adapté. Un autre grand classique, ce sont les shortcodes qui renvoient du HTML depuis des attributs non filtrés. Et puis il y a tout le sujet des modules qui affichent “des messages d’erreur” en réutilisant des entrées utilisateur.

Les causes les plus courantes côté WordPress

WordPress fournit des fonctions d’échappement et de validation, mais elles ne servent à rien si on appelle la mauvaise fonction pour le bon contexte. Le navigateur n’interprète pas la même chose selon qu’une donnée est placée dans du HTML, dans un attribut, dans du JavaScript, ou dans une URL.

La cause numéro un dans les incidents que j’ai rencontrés, c’est l’absence d’encodage au moment de l’affichage. La donnée peut être “jolie” et inoffensive au moment de la saisie, puis devenir dangereuse au moment où elle est injectée dans une sortie HTML.

La seconde cause, c’est le “laisser passer” par confort. On autorise un peu de HTML, on autorise certains tags, et on oublie les cas limites: attributs d’événement (onerror, onclick), protocoles dans des URLs (javascript:), balises inattendues, ou encore encodages qui changent la lecture. Autoriser “un peu” de HTML est faisable, mais il faut le faire avec une liste blanche stricte et une logique de nettoyage rigoureuse.

La troisième cause, c’est l’angle mort des plugins “annexes”. Un thème peut être propre, mais un constructeur de formulaires, un plugin de recherche, un plugin d’analytics, ou un plugin d’import peut réintroduire des entrées non maîtrisées. On croit sécuriser tout le site en patchant un endroit, puis la faille se cache ailleurs. C’est pour ça que la sécurisation WordPress contre les XSS doit être pensée comme un système, pas comme un point.

Le principe qui marche toujours: traiter à la saisie, mais surtout encadrer à la sortie

On entend souvent “valider à l’entrée”. C’est utile, mais ça ne suffit pas. Deux raisons simples:

Les données peuvent être créées ailleurs que via le formulaire qui vous intéresse (API, import, migration, plugin). Même des données “valides” peuvent devenir dangereuses si vous les affichez dans le mauvais contexte.

En pratique, je raisonne comme suit: valider et nettoyer pour réduire la surface, puis encoder selon le contexte au moment de l’affichage. Cette double approche aide à éviter les erreurs humaines, et elle s’adapte à WordPress, où les données passent par plusieurs étapes.

Identifier les points sensibles dans un thème ou un plugin

La majorité des failles XSS viennent de quelques zones récurrentes dans le code. Cela peut être un template qui concatène des fragments, un shortcode qui renvoie un HTML, ou un endroit qui imprime une valeur dans une balise.

Quelques exemples concrets de points à inspecter dans votre base de code WordPress:

    templates PHP qui injectent une valeur dans une balise HTML, un attribut, ou du contenu texte sans échappement contextuel; shortcodes ou blocs qui construisent une sortie en HTML à partir d’attributs; pages qui renvoient un message d’erreur ou de succès basé sur $_GET, $_POST, ou des métadonnées; scripts JavaScript intégrés dans des templates où des variables PHP sont interpolées sans encodage adapté; systèmes de recherche et filtres qui réaffichent des termes dans la page.

Un détail important: l’XSS ne nécessite pas forcément d’avoir des balises

Échapper correctement selon le contexte

Sur WordPress, l’enjeu n’est pas de trouver “une fonction magique”, c’est d’utiliser la bonne stratégie selon le contexte.

En simplifiant, on pense en quatre cases:

Texte placé entre des balises HTML: on escape pour le HTML. Valeur d’attribut HTML: on escape en contexte attribut. Insertion dans du JavaScript: on doit encoder pour être sûr que la chaîne ne casse pas la syntaxe. Valeur d’URL: on doit valider le protocole et encoder pour le contexte URL.

Je vois beaucoup d’erreurs typiques: quelqu’un échappe pour le HTML, puis utilise la valeur dans un attribut, ou inversement. Le navigateur interprète alors différemment, et la protection disparaît.

Quand on code soi-même (thème, plugin, custom block), il faut aussi se méfier du “fallback” des templates. Par exemple, si une donnée est vide, certains développeurs mettent une valeur par défaut directement dans une chaîne HTML. Si cette valeur par défaut provient d’une option ou d’un paramètre, elle reste un vecteur possible.

Cas particulier: le HTML autorisé (et ce qu’il ne faut pas oublier)

WordPress permet d’autoriser certains contenus HTML via des filtres et des fonctions de sanitation. Mais la sécurité ne se résume pas à “autoriser p et a”. Les liens, en particulier, sont un endroit où l’on se fait piéger.

Un lien peut être dangereux si:

    le protocole est non attendu (par exemple javascript:); l’attribut contient une charge non échappée; la combinaison balise et attribut ouvre une nouvelle interprétation côté navigateur.

Même quand vous autorisez une liste https://gardewp.fr/securite-wordpress/ de tags, vous devez contrôler les attributs autorisés. Une approche “je nettoie, puis je renvoie” doit avoir une liste blanche stricte, et idéalement une logique d’override.

Dans des projets réels, j’ai souvent vu des sites “semi-autorisants” qui laissent passer des attributs inattendus. Le correctif a rarement été un gros refactoring, c’était presque toujours une correction ciblée dans la fonction de nettoyage: modifier la whitelist des attributs, ajouter un filtre de protocole sur les URLs, et vérifier la sortie finale dans le template.

Sécurisation WordPress: durcir l’affichage dans les thèmes et plugins

La “bonne” sécurisation WordPress contre les XSS, c’est un mélange de bonnes pratiques et de vérifications systématiques. L’erreur la plus coûteuse, c’est de traiter uniquement les pages publiques où vous avez déjà testé manuellement. Une XSS peut se cacher dans une page d’administration, dans une page d’aperçu, ou dans un endpoint utilisé par un shortcode.

Voici une approche de travail pragmatique, celle que j’utilise quand on veut réduire les risques sans casser le site.

1) Créez une liste de zones “à risque” dans votre code: champs qui affichent des données utilisateur, paramètres d’URL, et attributs de shortcodes. 2) Vérifiez pour chaque zone quel contexte HTML est utilisé: texte, attribut, URL, ou JavaScript. 3) Remplacez les affichages directs par des fonctions d’échappement adaptées au contexte. 4) Pour toute autorisation de HTML, faites une whitelist stricte et testez les cas limite. 5) Ajoutez des tests manuels sur quelques charges utiles réalistes (sans faire de “trop”, juste ce qui valide réellement que la sortie est inoffensive).

Pour rendre ça concret, je conseille un petit atelier de validation lors d’un sprint. Vous prenez deux ou trois pages ou composants qui manipulent des entrées, vous identifiez où la donnée arrive dans le HTML, puis vous vérifiez visuellement et via inspecteur navigateur. Ça prend du temps au début, mais ça évite les corrections tardives qui, elles, coûtent plus cher.

Comment diagnostiquer une XSS sur un site WordPress existant

Sur une base existante, le diagnostic est souvent plus difficile que la correction. La donnée peut circuler via des hooks, des filtres, des shortcodes, des options, ou des métadonnées.

Une démarche utile consiste à se concentrer sur des symptômes:

    une chaîne “anormale” dans le DOM (une valeur de formulaire qui se retrouve sans échappement); un élément HTML inattendu généré à partir d’un champ; des événements déclenchés (par exemple un navigateur qui montre une action inattendue suite à un affichage).

Ensuite, on remonte la trace côté serveur. Si la page contient une donnée provenant de $_GET ou $_POST, l’URL est un bon point de départ. Si la page affiche un contenu qui vient de la base, pensez au workflow d’édition: commentaires, champs de profil, champs ACF, options plugin, contenu importé.

Une fois que vous avez l’endroit d’affichage, la correction n’est pas “patcher la charge utile”. La correction, c’est d’encoder et, si nécessaire, de nettoyer la donnée pour qu’elle ne puisse plus être interprétée comme du code.

Prévenir aussi côté navigateur: CSP et limites réalistes

Beaucoup de gens considèrent Content Security Policy (CSP) comme un bouclier qui résout tout. Ce n’est pas faux, mais ce n’est pas suffisant non plus. Une CSP bien pensée limite les effets d’une XSS en bloquant l’exécution de scripts non autorisés. Elle ne remplace pas l’encodage, parce que vous pouvez encore avoir des impacts via l’affichage, le vol de données par des canaux autorisés, ou des comportements si la CSP autorise trop.

Dans WordPress, la mise en place de CSP peut être délicate, car les plugins ajoutent parfois des scripts, des trackers, ou des iframes. J’ai déjà vu des sites “cassés” après une CSP trop stricte, non pas parce que la politique était mauvaise, mais parce qu’on n’avait pas inventorié toutes les dépendances. Le bon sens est de partir d’une politique progressive, en observant d’abord via les modes de reporting si votre configuration le permet.

Une règle d’atelier: tant que vous n’êtes pas sûr de votre inventaire de sources scripts, ne cherchez pas l’ultra strict du premier jour. Cherchez d’abord à empêcher l’exécution de scripts injectés, puis durcissez.

Le piège du JavaScript: XSS via DOM (sans faille côté HTML)

Les XSS DOM sont particulièrement sournoises, parce que vous pouvez avoir une page HTML bien échappée, et pourtant une charge utile s’exécute via une manipulation de chaîne côté navigateur.

Le scénario typique: un script lit location.search ou une variable issue d’un attribut data, puis fait un innerHTML avec cette valeur. Même si côté serveur vous avez échappé, si vous utilisez ensuite une API dangereuse, le navigateur interprète de nouveau.

Pour détecter ces cas, je recommande d’inspecter les endroits où le code fait:

    insertion via innerHTML ou outerHTML; création de nœuds via chaînes concaténées; utilisation de eval ou de fonctions similaires (souvent rares, mais quand ça existe, c’est grave).

Sur un site WordPress riche en JavaScript (thèmes modernes, blocs, builders), ces risques sont plus fréquents que les XSS “classiques” via des

Régles concrètes pour les développeurs WordPress

Pour éviter les erreurs récurrentes dans une équipe, je m’appuie sur quelques règles de travail. Elles ne sont pas glamour, mais elles réduisent le risque.

image

    Si une valeur vient de l’extérieur, elle ne s’affiche jamais en “clair” dans un template sans échappement contextuel. Si vous autorisez du HTML, vous contrôlez tags et attributs, et vous testez les liens. Si vous passez des données au JavaScript, vous encodez de façon à ne jamais casser la syntaxe et à ne pas créer de nouveaux contextes interprétables. Si une donnée est une URL, vous validez le schéma attendu, et vous encodez pour le contexte.

On peut débattre des détails d’implémentation selon le framework interne ou les conventions du projet, mais l’idée centrale reste stable: la sortie doit être sûre, pas seulement la saisie.

Petit guide de test manuel, utile même sans outils spécialisés

Il ne s’agit pas de faire du “pentest au hasard”. Le but est de vérifier que votre traitement de sortie empêche l’interprétation. Je propose une mini série de tests, simples, répétables, et centrés sur votre code.

Testez un champ qui s’affiche ensuite dans la page (commentaire, profil, recherche, message). Testez un paramètre d’URL qui est réaffiché dans un titre, un texte, ou un attribut. Testez un attribut généré par un shortcode ou un bloc, par exemple une valeur utilisée dans href ou data-*. Testez un champ qui accepte du HTML, surtout les liens. Ouvrez la page, inspectez le DOM, puis vérifiez qu’aucun comportement inattendu ne se produit.

Le test vaut autant pour la correction que pour la régression. Un plugin mis à jour peut réintroduire une interpolation non échappée.

Trade-offs: ce que vous risquez de casser en corrigeant trop vite

Sécuriser, c’est parfois réduire des libertés fonctionnelles. Par exemple, si votre site autorise un peu de HTML dans des champs, une correction de whitelist peut enlever des styles ou des attributs utiles. Si votre thème utilisait une donnée “brute” pour générer des composants UI, l’encodage peut modifier le rendu.

Il faut donc corriger en ciblant le minimum nécessaire. Souvent, la correction est très localisée: remplacer une interpolation directe par une fonction d’échappement adaptée au contexte, sans changer le reste. D’autres fois, il faut refactoriser un composant pour éviter que des valeurs soient réinsérées dans innerHTML.

Un autre trade-off concerne les performances et la maintenance. Les développeurs évitent parfois des traitements de sanitation “parce que ça ralentit”. Dans la vraie vie, le coût de quelques fonctions d’échappement est généralement marginal comparé au coût humain de corriger une faille. Les micro-optimisations ont rarement raison, surtout sur des chemins qui ne sont pas massivement répétés.

Cas pratiques: trois scénarios typiques et comment les traiter

1) Champ de recherche qui réaffiche la requête

Un site affiche “Résultats pour: mot-clé”. Si cette valeur est intégrée dans un attribut ou une balise non échappée, une charge peut devenir interprétable. La correction consiste à échapper selon le contexte exact, et à éviter de mettre la chaîne dans du HTML “interprétable”. En général, un encodage HTML suffit si la valeur ne doit être que du texte visible.

2) Shortcode qui fabrique un lien depuis des attributs

Un shortcode du type [bouton url="..."] renvoie un . Si le développeur n’échappe pas correctement href, ou s’il autorise trop de choses dans l’URL, une charge peut contourner la sortie attendue. La correction consiste à valider le protocole (schémas autorisés), puis à encoder la valeur pour l’attribut.

3) Admin screen qui affiche une valeur enregistrée

Même un écran d’administration peut être touché, et c’est parfois plus grave car la victime peut avoir plus de droits. Si un plugin stocke une valeur et l’affiche ensuite dans l’interface, l’XSS stockée est possible. La correction reste la même: échappement contextuel et nettoyage strict à la source lorsque l’entrée est censée être du texte ou un sous-ensemble HTML.

Dans tous ces cas, la correction ne consiste pas à “filtrer la charge utile”. Elle consiste à rendre la sortie inoffensive par construction.

Maintenir la sécurité dans le temps

Une fois corrigé, le risque revient rarement “tout seul”. Il revient par usure logicielle: mises à jour de plugins, nouveaux contenus, nouveaux hooks, nouveaux composants. C’est là que la méthode compte.

Je recommande de garder des habitudes simples:

    garder un inventaire des plugins qui manipulent du contenu utilisateur ou qui injectent du HTML; relire rapidement tout nouveau code introduisant une sortie HTML basée sur des entrées externes; tester les écrans clés après mise à jour majeure, surtout ceux qui affichent des entrées comme commentaires, formulaires, profils, et pages “résultats”.

Si votre organisation a déjà une routine de validation, vous pouvez l’intégrer avec un focus XSS. Le but n’est pas de refaire un audit complet à chaque fois, c’est de détecter les régressions tôt.

Conclusion implicite: l’XSS se gagne par la rigueur du “bon contexte”

La prévention des failles XSS sur WordPress n’est pas une quête d’absolu, c’est un travail d’alignement entre données et contexte d’affichage. Tant que vous traitez chaque entrée comme potentiellement hostile, que vous encodez selon la destination, et que vous contrôlez strictement le HTML autorisé, vous réduisez fortement les risques. Et quand un plugin ou un thème ajoute une nouvelle sortie, vous avez une méthode pour l’évaluer rapidement.

Si vous cherchez à avancer concrètement, commencez par le site réel: les endroits où des utilisateurs saisissent quelque chose puis voient leur texte s’afficher. C’est là que la sécurisation WordPress contre les XSS produit ses meilleurs gains, parce que vous traitez les chemins les plus vivants, ceux qui servent réellement au quotidien.