# 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.

- Auteur : Clément Hadrot
- Publié le : 2024-06-27
- Mis à jour le : 2024-06-27
- Catégorie : Sécurité
- URL : https://wpmoderne.dev.wordpress-developpement.fr/securite/role-editeur-unfiltered-html-injection-js/

## L’essentiel

- 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

**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é.
