Si vous avez déjà vu passer un commentaire qui “casse” votre mise en page ou un lien qui redirige vers un domaine louche, vous avez probablement rencontré le même problème racine : une sortie HTML non échappée.

Sur WordPress 6.9.4 (avril 2026) et PHP 8.1+, esc_html(), esc_attr() et esc_url() restent vos trois réflexes les plus rentables pour éviter les XSS et les injections dans le HTML. Le piège, c’est qu’on les utilise souvent au mauvais endroit… ou sur la mauvaise donnée.

La menace

L’attaque la plus courante derrière une “sortie non échappée”, c’est le XSS (Cross-Site Scripting). Dans la pratique, l’attaquant arrive à faire afficher (et parfois exécuter) du JavaScript dans le navigateur de vos visiteurs, via un champ que vous affichez tel quel : un titre, une meta, un champ ACF, un paramètre d’URL, un extrait, un nom d’auteur, un libellé de taxonomie.

Ce qu’un attaquant peut faire quand un XSS passe :

  • Voler des sessions (cookies) ou forcer des actions au nom d’un utilisateur connecté (surtout si vous avez des admins qui naviguent souvent sur le front).
  • Modifier l’interface (faux formulaire de connexion, bouton “Mettre à jour” piégé, etc.).
  • Injecter du spam SEO invisible (liens cachés, cloaking HTML) et dégrader votre réputation.
  • Propager une compromission : un XSS “admin” peut parfois mener à l’installation d’un plugin malveillant via des actions CSRF et des endpoints mal protégés.

Fréquence réelle : les XSS restent une catégorie récurrente dans les vulnérabilités de thèmes/plugins, et dans les audits de snippets “faits maison”. Je l’ai souvent vu sur des sites Elementor/Divi/Avada où un développeur a ajouté un widget/module “simple” qui affiche une option de thème ou un champ de formulaire sans échappement.

Explication simple : vous ne contrôlez pas toujours ce que contient une chaîne. Même si “ça vient de la base”, ça a pu être enregistré par un contributeur, importé via CSV, injecté par un plugin, ou modifié après compromission. L’échappement, c’est la dernière barrière avant le navigateur.

Résumé rapide

  • Échappez à la sortie : utilisez esc_html() pour du texte, esc_attr() pour un attribut HTML, esc_url() pour une URL.
  • Ne “sanitisez” pas à la place d’échapper : sanitize_text_field() et amis servent surtout à l’entrée (enregistrement), pas à l’affichage.
  • Évitez echo $variable dans les templates et widgets : c’est le pattern qui finit en XSS.
  • Pour du HTML autorisé, utilisez wp_kses() / wp_kses_post(), pas esc_html().
  • Attention aux contextes : HTML, attribut, JS inline, CSS inline, URL… chaque contexte a sa fonction.
  • Testez vos sorties : cherchez echo $_GET, echo get_option, print_r oubliés, et les champs de page builder rendus “raw”.

Code vulnérable — ce qu’il ne faut PAS faire

Exemple réaliste : un petit plugin de “bannière promo” qui lit un texte et un lien depuis les options, et qui permet aussi de surcharger via un paramètre ?promo_text=... pour des tests rapides. J’ai déjà croisé ce genre de snippet copié-collé dans un thème enfant, puis oublié.

<?php
/**
 * Exemple VULNÉRABLE : ne copiez pas ce code.
 * WordPress 6.9.4 / PHP 8.1+
 */

add_action('wp_footer', function () {
	// Option enregistrée en base (peut venir d'un import, d'un éditeur, d'un plugin, etc.)
	$promo_text = get_option('my_promo_text', 'Promo du jour');
	$promo_url  = get_option('my_promo_url', 'https://example.com');

	// Surcharge via URL (très mauvaise idée, mais fréquent pour "tester vite")
	if (isset($_GET['promo_text'])) {
		$promo_text = $_GET['promo_text']; // Aucune validation ni échappement
	}

	echo '<div class="promo-banner">';
	echo '<a href="' . $promo_url . '" class="promo-link">' . $promo_text . '</a>';
	echo '</div>';
});

Ce qui se passe en coulisses :

  • Injection dans le contenu : si $promo_text contient du HTML/JS, il sera injecté tel quel dans la page. Un payload peut être aussi simple qu’une balise avec un événement (ex. onerror) ou une fermeture de balise suivie d’un script.
  • Injection dans l’attribut : href="..."> sans esc_url() peut accepter des schémas dangereux (selon les cas), ou casser l’attribut via des guillemets si la chaîne n’est pas normalisée.
  • Le fait que ça vienne d’une option ne protège pas : un contributeur peut parfois modifier des options via un plugin, un import, un endpoint REST mal protégé, ou une compromission antérieure.

Ce que je vois souvent comme “fausse bonne idée” : remplacer ça par sanitize_text_field() au moment de l’affichage. Ça ne couvre pas le contexte attribut/URL, et ça casse parfois des caractères (accents, apostrophes typographiques) sans résoudre le vrai problème : le navigateur interprète votre sortie dans un contexte précis.

Code sécurisé — la bonne implémentation

On reprend la même fonctionnalité, mais on corrige vraiment le problème : validation/sanitization à l’entrée quand c’est possible, et échappement systématique à la sortie selon le contexte.

Version plugin : options + rendu sécurisé

<?php
/**
 * Exemple SÉCURISÉ : plugin minimal.
 * Compatible WordPress 6.9.4+ / PHP 8.1+
 */

/**
 * 1) Enregistrement des options (exemple) :
 * - Sanitization à l'entrée (quand on sauvegarde)
 * - Puis échappement à la sortie (dans le rendu)
 */
add_action('admin_init', function () {
	register_setting(
		'reading',
		'my_promo_text',
		[
			'type'              => 'string',
			'sanitize_callback' => 'sanitize_text_field', // Texte simple, pas de HTML
			'default'           => 'Promo du jour',
		]
	);

	register_setting(
		'reading',
		'my_promo_url',
		[
			'type'              => 'string',
			'sanitize_callback' => 'esc_url_raw', // URL à l'entrée
			'default'           => 'https://example.com',
		]
	);
});

/**
 * 2) Rendu front : échappement par contexte.
 */
add_action('wp_footer', function () {
	$promo_text = get_option('my_promo_text', 'Promo du jour');
	$promo_url  = get_option('my_promo_url', 'https://example.com');

	// Surcharge via URL : si vous gardez ce mécanisme, limitez-le fortement.
	// Ici : uniquement pour les utilisateurs capables de gérer des options, et uniquement en texte.
	if (is_user_logged_in() && current_user_can('manage_options') && isset($_GET['promo_text'])) {
		// Sanitization minimale (entrée), puis échappement au rendu (sortie)
		$promo_text = sanitize_text_field(wp_unslash($_GET['promo_text']));
	}

	echo '<div class="promo-banner">';
	echo '<a href="' . esc_url($promo_url) . '" class="promo-link">' . esc_html($promo_text) . '</a>';
	echo '</div>';
}, 20);

Explication “langage simple” :

  • esc_html() transforme les caractères spéciaux en entités HTML. Résultat : le navigateur affiche du texte, pas des balises.
  • esc_attr() protège un attribut HTML (ex. title, data-*, aria-label) contre la rupture de guillemets et l’injection.
  • esc_url() normalise l’URL et retire ce qui n’est pas acceptable dans ce contexte (et encode ce qui doit l’être).

Explication plus technique :

  • On fait de la sanitization à l’entrée (avec sanitize_text_field(), esc_url_raw()) pour stocker des valeurs propres en base, limiter les surprises, et réduire la surface d’attaque si un autre endroit réutilise ces options.
  • On fait de l’échappement à la sortie parce que le contexte final (HTML vs attribut vs URL) n’existe qu’au moment du rendu.
  • On traite correctement $_GET : wp_unslash() (WordPress ajoute des slashes), puis sanitization. Sans ça, vous avez des bugs bêtes (antislashes visibles) et des contournements.

Choisir la bonne fonction selon le contexte (avec exemples)

1) Texte visible : esc_html()

<?php
echo '<p>' . esc_html($author_display_name) . '</p>';

2) Attribut HTML : esc_attr()

<?php
echo '<button type="button" aria-label="' . esc_attr($label) . '">OK</button>';

3) URL : esc_url()

<?php
echo '<a href="' . esc_url($profile_url) . '">Voir le profil</a>';

4) Vous devez autoriser un peu de HTML : wp_kses_post()

Cas typique : un champ “description” où vous acceptez <a>, <strong>, <em>. Si vous utilisez esc_html(), vous allez afficher les balises au lieu de les rendre.

<?php
// HTML limité comme dans le contenu d'un post
echo wp_kses_post($custom_html);

J’ai souvent vu l’erreur inverse : utiliser wp_kses_post() “partout” parce que “ça marche”. Ça élargit inutilement ce que vous autorisez. Pour une option censée être du texte, restez sur esc_html().

Piège fréquent : double échappement et chaînes déjà échappées

Si vous stockez en base une valeur déjà échappée (par exemple vous avez enregistré &lt; au lieu de <), puis vous faites esc_html() au rendu, vous obtenez du texte illisible (&amp;lt;). Le bon pattern : stockez brut (mais sanitizé), échappez uniquement au rendu.

Compatibilité Divi 5, Elementor, Avada : où ça casse vraiment

Les page builders introduisent souvent des champs “HTML”, “lien”, “attribut”, “classe CSS”. Le risque n’est pas le builder lui-même, c’est votre code autour.

  • Elementor : si vous créez un widget custom et que vous rendez un champ texte avec echo $settings['title'];, vous avez un XSS. Rendez-le avec echo esc_html( $settings['title'] );. Pour une URL : esc_url().
  • Divi 5 : même logique dans un module custom. Beaucoup de modules “maison” affichent des data-* sans esc_attr(), puis un script lit ces valeurs. C’est un combo XSS classique.
  • Avada / Fusion Builder : attention aux shortcodes custom qui retournent des chaînes HTML. Le shortcode doit échapper chaque fragment selon son contexte avant de concaténer.

Configuration serveur

L’échappement côté PHP est la base. Ensuite, vous pouvez réduire l’impact d’un XSS qui passerait malgré tout (plugin vulnérable, snippet oublié, etc.) avec des en-têtes HTTP et quelques réglages.

Headers HTTP recommandés (CSP, X-Frame-Options, etc.)

Le meilleur levier anti-XSS côté navigateur, c’est une Content-Security-Policy (CSP). Le problème : beaucoup de sites WordPress (et surtout avec page builders) utilisent encore des scripts inline, ce qui rend une CSP stricte difficile. Je préfère une approche progressive : démarrer en Report-Only, corriger, puis appliquer.

Apache (.htaccess) : exemple prudent

# À placer dans le .htaccess à la racine (si AllowOverride autorisé)
<IfModule mod_headers.c>
  # Empêche l'interprétation MIME hasardeuse
  Header set X-Content-Type-Options "nosniff"

  # Réduit les fuites de referrer
  Header set Referrer-Policy "strict-origin-when-cross-origin"

  # Limite l'embarquement en iframe (anti clickjacking)
  Header set X-Frame-Options "SAMEORIGIN"

  # Active une CSP en mode Report-Only pour commencer (à adapter)
  # Remplacez https://example.com/csp-report par votre endpoint si vous en avez un.
  Header set Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' https: data:; style-src 'self' 'unsafe-inline' https:; script-src 'self' 'unsafe-inline' https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; report-uri https://example.com/csp-report"
</IfModule>

Note : 'unsafe-inline' n’est pas idéal, mais je l’assume ici pour compatibilité initiale. Sur des sites Elementor/Divi/Avada, une CSP trop stricte casse vite l’édition front/back. Le but : instrumenter, puis durcir.

Nginx : équivalent (extrait)

add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' https: data:; style-src 'self' 'unsafe-inline' https:; script-src 'self' 'unsafe-inline' https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;

wp-config.php : durcissement utile (sans casser le site)

<?php
// Désactive l'éditeur de fichiers dans l'admin (réduit l'impact d'un compte compromis)
define('DISALLOW_FILE_EDIT', true);

// En prod, évitez d'afficher les erreurs PHP (fuites d'infos)
define('WP_DEBUG', false);
define('WP_DEBUG_DISPLAY', false);

// Logguez plutôt dans un fichier (à sécuriser au niveau serveur)
define('WP_DEBUG_LOG', true);

J’ai vu des sites “sécurisés” avec WP_DEBUG actif en production. Quand un XSS provoque une erreur, le message peut exposer des chemins, versions et bouts de code. Ce n’est pas la faille principale, mais ça aide l’attaquant.

Bloquer l’exécution PHP dans les dossiers upload (si possible)

Ce point vise surtout les uploads malveillants, mais il complète un scénario XSS → escalade. Sur Apache :

# Dans /wp-content/uploads/.htaccess
<FilesMatch ".php$">
  Deny from all
</FilesMatch>

Selon votre hébergeur, la directive peut varier. Testez toujours sur un environnement de staging.

Vérifier si votre site est vulnérable

Vous ne trouverez pas tous les XSS avec une recherche texte, mais vous allez éliminer 80% des snippets dangereux en 15 minutes.

Audit rapide via WP-CLI (recherche de patterns)

Sur un serveur avec WP-CLI :

# Cherche des sorties directes d'entrées utilisateur
wp eval "echo 'OK';"

# Recherches simples (à affiner) dans plugins/thèmes
grep -RIn --exclude-dir=node_modules --exclude-dir=vendor "echo $_GET" wp-content
grep -RIn --exclude-dir=node_modules --exclude-dir=vendor "echo $_POST" wp-content
grep -RIn --exclude-dir=node_modules --exclude-dir=vendor "print($_GET" wp-content
grep -RIn --exclude-dir=node_modules --exclude-dir=vendor "href=".s*$" wp-content

Ce que vous cherchez :

  • Des echo / printf / print qui sortent des variables issues de $_GET, $_POST, $_REQUEST, get_option(), get_post_meta(), $settings (Elementor), sans esc_*.
  • Des concaténations dans des attributs : href=", src=", data-, style=".

Audit de la base : repérer des scripts injectés dans des champs courants

Sans donner de méthode d’exploitation, vous pouvez chercher des marqueurs évidents (balises <script, événements onerror=) dans le contenu et certaines metas. Faites une sauvegarde avant toute manipulation.

# Requête SQL via WP-CLI (lecture seule)
wp db query "SELECT ID, post_type, post_title FROM wp_posts WHERE post_content LIKE '%<script%' LIMIT 20;"
wp db query "SELECT meta_id, post_id, meta_key FROM wp_postmeta WHERE meta_value LIKE '%onerror=%' LIMIT 20;"

Interprétation :

  • Si vous trouvez des occurrences dans post_content, ce n’est pas forcément une compromission : certains sites stockent du script volontairement. Mais c’est un drapeau rouge.
  • Dans postmeta, c’est souvent plus suspect (widgets, options de builder, champs custom).

Logs : indices réalistes

  • Access logs : paramètres d’URL anormalement longs, répétitifs, avec beaucoup de caractères spéciaux. Ça ne prouve rien, mais ça signale une tentative.
  • Erreur PHP : warnings liés à des concaténations HTML, “Undefined index”, ou erreurs dans des templates custom. Un attaquant “fuzze” parfois des paramètres jusqu’à déclencher des comportements inattendus.
  • WAF/CDN (Cloudflare, etc.) : pics de requêtes bloquées sur des règles XSS.

Tableau de diagnostic

Symptôme Cause probable Vérification Solution
Pop-up / redirection aléatoire sur le front XSS stocké dans une option, un widget, un champ builder Rechercher <script / onerror= en base + inspecter le HTML rendu Nettoyer la donnée + corriger le rendu avec esc_html/esc_attr/esc_url ou wp_kses
Liens “cassés” (href tronqué) ou HTML invalide Attribut non échappé contenant guillemets Voir le HTML généré (DevTools) et la valeur source Utiliser esc_attr() pour attributs, esc_url() pour URLs
Le texte affiche &lt;strong&gt; au lieu de gras Double échappement ou mauvais choix (esc_html au lieu de wp_kses_post) Comparer la valeur stockée en base vs la sortie Stocker brut (sanitizé), échappement au rendu; autoriser HTML via wp_kses_post si nécessaire
Le builder (Elementor/Divi/Avada) “perd” des caractères Sanitization trop agressive à l’enregistrement Tester la sauvegarde d’un champ et relire la valeur Utiliser une sanitization adaptée (URL vs texte) et garder l’échappement pour la sortie

Erreurs de sécurité fréquentes

Erreur Risque Solution
Afficher une option/meta avec echo get_option(...) ou echo get_post_meta(...) XSS stocké si la donnée a été polluée Échapper à la sortie : esc_html, esc_attr, esc_url, ou wp_kses_post selon le contexte
Utiliser sanitize_text_field() au moment du rendu “pour sécuriser” Protection incomplète (contexte attribut/URL), bugs d’encodage Sanitization à l’entrée + échappement au rendu
Utiliser wp_kses_post() partout Autorise trop de HTML, augmente la surface d’attaque Limiter au strict nécessaire; texte simple → esc_html()
Oublier wp_unslash() sur $_GET/$_POST Valeurs altérées, contournements, bugs difficiles à reproduire wp_unslash() puis sanitization, puis échappement à la sortie
Mettre un snippet dans le mauvais endroit (template, plugin de snippets, thème parent) Mises à jour qui écrasent, exécution partielle, erreurs fatales Utiliser un plugin mu-plugin ou un plugin dédié; éviter le thème parent
Oublier un point-virgule / parenthèse dans un snippet de sécurité White screen / fatal error, site indisponible Tester en staging, activer logs, déployer progressivement
Hook inadapté (rendu trop tôt / trop tard) Variables non disponibles, HTML injecté au mauvais endroit Front : wp_footer/wp_head avec priorité maîtrisée; admin : hooks dédiés
Conflit avec cache (page cache / minification) Vous pensez avoir corrigé, mais l’ancienne sortie reste servie Purger cache plugin/CDN + cache navigateur
Tester directement en production sans sauvegarde Indisponibilité, perte de données Staging + sauvegarde + plan de rollback
Copier un tutoriel ancien (pré-PHP 8) avec fonctions obsolètes ou patterns risqués Incompatibilités, failles, warnings Cibler WP 6.9.4+ et PHP 8.1+, vérifier la doc officielle

Checklist de durcissement

  • Passer en revue vos templates et widgets : chaque variable affichée a-t-elle un esc_* adapté ?
  • Dans vos shortcodes : échapper chaque attribut séparément avec esc_attr() et chaque URL avec esc_url().
  • Pour les champs “HTML autorisé” : remplacer les echo $html par echo wp_kses_post($html) (ou wp_kses() avec une whitelist stricte).
  • Limiter les surcharges via $_GET / $_POST. Si vous en gardez : current_user_can() + nonces + wp_unslash() + sanitization.
  • Ajouter des headers de base : nosniff, SAMEORIGIN, Referrer-Policy. Démarrer une CSP en Report-Only.
  • Désactiver l’éditeur de fichiers : DISALLOW_FILE_EDIT.
  • Mettre à jour WordPress, thèmes, plugins. Un échappement parfait dans votre code ne compense pas un plugin vulnérable.
  • Purger caches après déploiement (plugin + CDN), sinon vous validez la correction sur une page qui n’est pas servie.
  • Vérifier la compatibilité PHP : exécuter au minimum PHP 8.1 (et idéalement plus récent si votre stack le permet).

Que faire si le site est déjà compromis ?

  1. Isoler : activez une page de maintenance si possible, ou bloquez temporairement l’accès admin par IP. Évitez de “bricoler” pendant que le site est attaqué.
  2. Sauvegarder : export complet des fichiers + dump DB. Même compromis, ça sert pour l’analyse et parfois pour récupérer du contenu.
  3. Identifier le vecteur : plugin vulnérable, compte compromis, snippet, formulaire. Regardez les logs, l’historique des déploiements, et les utilisateurs récemment créés.
  4. Réinstaller proprement le core : remplacez wp-admin et wp-includes par les fichiers officiels de la même version (6.9.4 ou plus récent).
  5. Comparer et nettoyer :
    • Dans wp-content/plugins et wp-content/themes : supprimer tout ce qui est inconnu, réinstaller depuis les sources officielles.
    • Dans uploads : chercher des fichiers PHP (ils n’ont généralement rien à y faire).
  6. Nettoyer la base :
    • Chercher scripts injectés dans wp_options, wp_posts, wp_postmeta.
    • Vérifier les utilisateurs admins, clés API, tokens, options de builder.
  7. Changer tous les secrets : mots de passe WP, FTP/SSH, base de données, clés d’authentification WordPress (salts), clés d’API tierces.
  8. Mettre à jour tout (plugins/thèmes) et supprimer ce qui n’est pas maintenu.
  9. Durcir : headers, permissions fichiers, désactivation éditeur, WAF si possible.
  10. Surveiller : logs + alertes (création d’utilisateur, modifications de fichiers, pics de requêtes).

Si vous suspectez un XSS “stocké”, la priorité est double : retirer la donnée injectée (sinon elle continue de s’exécuter) et corriger la sortie (sinon elle reviendra au prochain enregistrement).

Conseils de maintenance et compatibilité

Avec WordPress 6.9.4, l’écosystème bouge vite, mais les règles d’or ne changent pas : sanitization à l’entrée, échappement à la sortie, et validation des capacités.

Performance : l’échappement coûte moins cher qu’un incident

esc_html(), esc_attr(), esc_url() ont un coût négligeable face au rendu complet d’une page (requêtes, cache, images, JS). Le vrai coût, c’est un incident XSS : nettoyage, blacklisting SEO, perte de confiance.

SEO : le spam injecté est un signal faible… jusqu’au jour où ça ne l’est plus

Les injections XSS s’accompagnent souvent de liens cachés. Même si Google ignore une partie, vous finissez avec des pages polluées, du cloaking, et des rapports Search Console qui vous font perdre des semaines.

Compatibilité page builders : testez l’éditeur, pas seulement le front

Quand vous ajoutez une CSP ou quand vous durcissez les sorties, testez :

  • l’édition Elementor (éditeur + preview),
  • Divi 5 (builder + front),
  • Avada (Fusion Builder + options).

J’ai déjà vu une CSP “trop ambitieuse” casser l’éditeur, puis un admin la désactiver complètement. Résultat : vous perdez le bénéfice sécurité. Allez-y par étapes.

Un pattern que je recommande : fonctions de rendu dédiées

Au lieu d’échapper “au feeling” partout, centralisez :

<?php
/**
 * Rend une bannière promo de façon cohérente.
 * Cela réduit les oublis d'échappement dans 10 templates différents.
 */
function mytheme_render_promo_banner(string $text, string $url): string {
	$text = esc_html($text);
	$url  = esc_url($url);

	return '<div class="promo-banner"><a class="promo-link" href="' . $url . '">' . $text . '</a></div>';
}

Le piège classique : appeler cette fonction avant qu’elle soit chargée (mauvais fichier, mauvais hook). Si vous la mettez dans functions.php d’un thème enfant, assurez-vous qu’elle est définie avant usage, ou chargez-la via require_once dans un plugin.

Ressources

FAQ

Est-ce que je dois échapper même une donnée “de confiance” (option admin, champ ACF, etc.) ?

Oui. “De confiance” décrit la source supposée, pas l’état réel. Une option peut être modifiée par un import, un plugin, un compte compromis, ou un bug. Échapper à la sortie empêche le navigateur d’interpréter la chaîne comme du code.

Quelle est la différence entre sanitization et escaping ?

La sanitization nettoie/normalise une donnée pour la stocker ou la traiter. L’escaping prépare une donnée pour un contexte de sortie (HTML, attribut, URL). En pratique : sanitization à l’entrée, escaping à la sortie.

Pourquoi esc_url_raw() à l’entrée et esc_url() à la sortie ?

esc_url_raw() est fait pour nettoyer une URL avant stockage (pas d’encodage pour affichage). esc_url() est fait pour afficher dans le HTML. Les deux se complètent.

Je mets esc_html() partout et c’est bon ?

Non. Dans un attribut, vous devez utiliser esc_attr(). Dans une URL, esc_url(). Et si vous devez autoriser du HTML, wp_kses_post() (ou wp_kses() avec whitelist). Le contexte dicte la fonction.

Que faire si je dois afficher du HTML provenant d’un éditeur (ex. champ WYSIWYG) ?

Utilisez wp_kses_post() si vous acceptez le même type de HTML qu’un contenu d’article. Si vous avez besoin d’une whitelist plus stricte (ex. autoriser seulement <a> et <strong>), utilisez wp_kses() avec une liste de balises/attributs autorisés.

esc_attr() protège-t-il aussi les data-* ?

Oui, c’est même un des meilleurs usages. Les attributs data-* sont souvent consommés par du JS. Sans échappement, vous pouvez injecter des guillemets et casser l’attribut, voire influencer le script.

J’ai corrigé le code, mais je vois encore l’ancienne sortie. Pourquoi ?

Cache. Purgez le cache du plugin, du serveur, du CDN, et testez en navigation privée. J’ai souvent vu des développeurs valider une correction sur une page encore servie depuis un cache full-page.

Les page builders gèrent-ils déjà l’échappement ?

Ils échappent une partie, mais vous ne devez pas compter dessus pour votre code custom (widgets, modules, shortcodes, hooks). Le point faible est presque toujours un rendu “raw” ajouté à côté du builder.

Si j’utilise Gutenberg (éditeur de blocs), suis-je protégé automatiquement ?

Vous êtes mieux loti sur le contenu standard, mais pas “automatiquement” sur vos champs custom, vos options, vos templates, vos shortcodes, vos blocs custom, ou vos endpoints. Le risque revient dès que vous faites echo $variable dans un rendu PHP.

Quelle est la première chose à corriger sur un site existant ?

Les sorties qui affichent des données contrôlables : paramètres d’URL, champs de formulaire, metas affichées sur le front, widgets custom, shortcodes. C’est là que les XSS se cachent le plus souvent.