vendredi 25 septembre 2026

À propos

Contact

Sécurité

Un rôle d’éditeur WordPress qui pouvait injecter du JS via unfiltered_html

Un rôle éditeur personnalisé héritait par erreur de la capacité unfiltered_html, permettant d'injecter du JavaScript stocké dans un article. Diagnostic et correctif du rôle en cause.

Par Clément Hadrot • 27 juin 2024 • 6 min de lecture • Aucun commentaire
Un rôle d'éditeur WordPress qui pouvait injecter du JS via unfiltered_html

Symptôme. Un client, agence de communication gérant un blog collaboratif à une dizaine de rédacteurs, a signalé un comportement étrange : depuis quelques jours, une fenêtre publicitaire s’ouvrait aléatoirement sur certains articles du site, sans lien avec aucune extension de publicité installée. Le comportement n’était pas systématique : il touchait uniquement certains articles précis, rédigés par certains auteurs, jamais tous. Un scan Wordfence rapide n’a rien détecté, ce qui a orienté l’investigation vers le contenu même des articles plutôt que vers une extension compromise.

Diagnostic : remonter jusqu’au contenu injecté

L’inspection du code source d’un des articles concernés a révélé, au milieu du contenu, un bloc <script> parfaitement intact dans la base de données, quelque chose comme <script>window.open('https://exemple-pub.net')</script>, inséré directement dans le corps de l’article via l’éditeur de blocs, dans un bloc HTML personnalisé. Sur une installation WordPress standard, un tel contenu est censé être neutralisé automatiquement au moment de l’enregistrement : la fonction wp_kses_post(), appliquée par défaut au contenu des articles pour tout rôle ne disposant pas de la capacité unfiltered_html, retire systématiquement les balises <script> et les attributs dangereux comme onerror ou onclick.

Le fait que ce script ait survécu au filtrage indiquait une seule possibilité : le compte ayant publié l’article disposait de la capacité unfiltered_html, réservée par défaut, sur une installation WordPress classique (mono-site), aux seuls administrateurs. Une vérification rapide via WP-CLI a confirmé l’hypothèse :

wp user list --role=redacteur_junior --field=user_login | \
  xargs -I{} wp user meta get {} wp_capabilities

Le rôle personnalisé redacteur_junior, créé un an plus tôt pour distinguer les rédacteurs externes des rédacteurs internes, affichait bien la capacité unfiltered_html parmi ses droits, alors que rien dans sa description fonctionnelle ne le justifiait.

Comprendre l’origine de l’erreur

L'essentiel à retenir : unfiltered_html contourne le filtrage automatique du contenu ; Un rôle cloné hérite parfois de capacités non désirées ; Le correctif se joue en une ligne, une fois la cause identifiée

La cause remontait à la création du rôle lui-même. Le développeur qui l’avait mis en place était parti du rôle editor natif, en le clonant via un plugin de gestion de rôles pour créer un rôle légèrement plus restreint, censé retirer la capacité de publier directement (publish_posts) au profit d’un statut de brouillon systématique. Le rôle editor de WordPress dispose nativement de unfiltered_html sur une installation mono-site (cette capacité est retirée par défaut sur un réseau multisite, où seul le super-administrateur la conserve). En clonant le rôle sans passer en revue l’intégralité de ses capacités une par une, la capacité unfiltered_html avait été copiée sans que personne ne s’en rende compte, alors que l’intention était justement de créer un rôle plus restrictif que editor.

Ce type d’erreur est fréquent avec les plugins de gestion de rôles à interface graphique, qui présentent souvent des dizaines de cases à cocher sans les regrouper par niveau de risque. Une capacité comme unfiltered_html, qui touche directement à la sécurité du contenu affiché aux visiteurs, se noie visuellement parmi des dizaines d’autres capacités bien plus anodines comme edit_others_posts ou delete_published_posts.

Correctif appliqué

Le correctif tient en une ligne, une fois la cause identifiée, en retirant explicitement la capacité au rôle concerné :

$role = get_role( 'redacteur_junior' );
if ( $role ) {
    $role->remove_cap( 'unfiltered_html' );
}

Cette ligne, à exécuter une seule fois (par exemple depuis une page d’administration temporaire ou WP-CLI, jamais à chaque chargement pour des raisons de performance), corrige le rôle existant en base. Il reste ensuite à nettoyer le contenu déjà injecté :

wp post list --post_type=post --format=ids | xargs -I{} \
  wp post get {} --field=post_content | grep -l '<script'

Chaque article identifié a été relu manuellement pour retirer le contenu injecté, plutôt que de faire confiance à un remplacement automatisé par expression régulière, qui aurait pu casser du contenu légitime contenant accidentellement la chaîne script dans un autre contexte.

Vérifier qu’aucun autre rôle n’est concerné

Un incident de ce type justifie un audit complet, pas seulement un correctif ponctuel sur le rôle fautif. La vérification suivante liste tous les rôles disposant de la capacité, sur l’ensemble de l’installation :

<?php
global $wp_roles;
foreach ( $wp_roles->roles as $nom => $role ) {
    if ( ! empty( $role['capabilities']['unfiltered_html'] ) ) {
        echo $nom . PHP_EOL;
    }
}

Sur ce site, seuls administrator et, par erreur, redacteur_junior disposaient de cette capacité. Après correctif, seul administrator la conserve, ce qui correspond au comportement natif attendu de WordPress hors contexte multisite.

Prévention

Pour éviter la répétition de ce type d’incident, plusieurs pratiques ont été adoptées sur les projets suivants de l’agence :

  • Ne jamais cloner un rôle existant sans dresser d’abord la liste complète de ses capacités et statuer explicitement sur chacune, notamment celles à fort impact comme unfiltered_html, edit_files ou manage_options.
  • Ajouter un test automatisé, exécuté après chaque déploiement, qui vérifie qu’aucun rôle en dehors d’administrator ne dispose de unfiltered_html.
  • Privilégier la construction d’un rôle à partir d’un tableau de capacités explicite plutôt que par clonage d’un rôle existant via une interface graphique, pour garder une trace lisible de chaque décision.

Cloner un rôle, c’est cloner ses angles morts en même temps que ses fonctionnalités utiles. Ce qu’on ne relit pas capacité par capacité finit toujours par ressurgir.

Ce qu’il faut retenir

Cette panne n’avait rien d’une faille zero-day ni d’un contournement technique sophistiqué : c’était une capacité de trop, héritée par inadvertance lors de la création d’un rôle, restée invisible pendant un an avant qu’un rédacteur externe, sans doute sans même comprendre la portée de ce qu’il faisait, n’en profite pour injecter du contenu publicitaire. Le système de capacités de WordPress est puissant précisément parce qu’il est granulaire, mais cette granularité impose une revue attentive à chaque création ou modification de rôle, sans quoi elle devient une source d’erreurs silencieuses plutôt qu’un outil de sécurité.

Partager :

À propos de l'auteur

Clément Hadrot

Développeur WordPress, passionné par Elementor, le FSE et l’automatisation par IA.

Voir tous ses articles

Dans la même veine

À lire aussi