Essayez sans attendre l'hébergement proposé par WordPress
-15% sur le premier mois avec le code 2025PRESS15AFF

Essayer maintenant

Sécuriser l’API REST de WordPress contre l’énumération d’utilisateurs et le brute force

Testez donc /wp-json/wp/v2/users sur votre propre site : il y a de fortes chances que WordPress vous livre gentiment la liste de vos logins sur un plateau. Couplée à une attaque brute force sur wp-login.php, cette fuite d’information devient une porte d’entrée royale pour les indésirables. Bonne nouvelle : quelques hooks bien placés et un système de limitation basé sur les transients suffisent à colmater la brèche. C’est exactement ce qu’on va détailler ici, code à l’appui, pour sécuriser l’API REST WordPress contre l’énumération d’utilisateurs et le brute force.

Comprendre les risques de l’API REST WordPress

Depuis WordPress 4.7 (sorti fin 2016 pour ceux qui suivent), l’API REST est activée par défaut sur tous les sites. Concrètement, ça veut dire que votre site expose une interface JSON accessible publiquement, sans authentification, permettant à n’importe qui (humain ou script) de récupérer des données structurées sur votre installation. Pratique pour les développeurs qui veulent créer des applications headless ou interagir avec WordPress depuis l’extérieur. Mais niveau sécurité, c’est aussi une porte d’entrée qu’il faut absolument surveiller.

Le problème, c’est que beaucoup d’admins ne savent même pas que cette API tourne sur leur site. Et forcément, ce qu’on ne connaît pas, on ne le sécurise pas.

L’endpoint /wp-json/wp/v2/users et l’énumération d’utilisateurs

C’est LE endpoint qui pose problème. Par défaut, /wp-json/wp/v2/users liste tous les utilisateurs de votre site, avec leur login, leur slug et leur ID. Sans authentification, sans rien.

Testez par vous-même avec une simple requête curl :

curl https://monsite.fr/wp-json/wp/v2/users

Et voici ce que vous obtenez (j’ai anonymisé les données, mais la structure est bien réelle) :

[
  {
    "id": 1,
    "name": "admin",
    "slug": "admin",
    "link": "https://monsite.fr/author/admin/"
  }
]

Le champ « slug » correspond quasiment toujours au login utilisé pour se connecter. Autant dire que c’est un cadeau pour un attaquant : il n’a plus qu’à récupérer ces identifiants pour lancer une attaque ciblée.

Autre variante à surveiller : /wp-json/wp/v2/users/1. Cet endpoint permet de deviner les comptes en itérant simplement sur les ID (1, 2, 3…). Souvent, l’utilisateur avec l’ID 1 correspond au tout premier compte créé, donc à l’administrateur principal. Un scan automatisé peut ainsi reconstituer la liste complète des comptes admin en quelques secondes.

Les attaques brute force via l’authentification REST

Une fois les logins récupérés via l’énumération d’utilisateurs, l’attaquant a toutes les cartes en main pour lancer une attaque brute force. Et c’est là que ça devient dangereux : au lieu de deviner à la fois le login ET le mot de passe (ce qui multiplie les combinaisons possibles), le pirate se concentre uniquement sur le mot de passe.

Ces attaques passent généralement par deux canaux bien connus, dont on a déjà parlé dans nos articles précédents sur la sécurisation de wp-login.php et sur les risques liés à xmlrpc.php :

  • wp-login.php : formulaire de connexion classique, ciblé par des scripts qui testent des milliers de combinaisons par minute
  • xmlrpc.php : encore plus problématique, car il permet de tester plusieurs mots de passe en une seule requête (via la méthode system.multicall)

L’API REST vient donc s’ajouter à ces vecteurs d’attaque existants. Elle ne remplace pas ces techniques, elle les complète en fournissant la liste des cibles à tester.

Pourquoi l’API REST est une cible privilégiée

Si les attaquants s’intéressent autant à cette API, c’est pour plusieurs raisons assez logiques quand on y réfléchit.

D’abord, elle est standardisée : tous les sites WordPress utilisent la même structure d’endpoints. Un script de scan développé une fois peut donc être réutilisé sur des millions de sites différents. Pas besoin d’adapter le code selon la configuration.

Ensuite, elle est facile à automatiser. Les outils de scan (souvent basés sur des bots qui tournent 24h/24) parcourent le web à la recherche d’installations WordPress, testent systématiquement /wp-json/wp/v2/users, et alimentent des bases de données de cibles potentielles. J’ai déjà vu des logs de sites clients recevant plusieurs centaines de requêtes vers cet endpoint en une seule journée, sans aucune action de notre part.

Enfin, d’autres endpoints méritent votre vigilance :

  • /wp-json/wp/v2/comments : peut révéler des informations sur les auteurs des commentaires
  • /wp-json/wp/v2/media : expose parfois les métadonnées d’auteur des fichiers uploadés

Bref, l’API REST, c’est un peu comme laisser un plan détaillé de votre maison sur la porte d’entrée. Pratique pour les visiteurs légitimes, mais risqué si on ne filtre pas qui a accès à quoi.

Restreindre et filtrer les endpoints sensibles avec les hooks WordPress

Maintenant qu’on sait pourquoi l’endpoint /wp/v2/users pose problème, passons à l’action. WordPress propose plusieurs hooks natifs qui permettent de reprendre le contrôle sur l’API REST sans tout casser. On va voir quatre approches complémentaires : masquer l’endpoint, forcer l’authentification, filtrer les champs exposés et, pour les plus paranoïaques (ou les sites vraiment sensibles), restreindre par IP.

Toutes ces solutions sont à placer dans le functions.php de votre thème enfant, ou mieux, dans un plugin custom dédié à la sécurité (ainsi, pas de perte au changement de thème).

Désactiver ou masquer l’endpoint users avec rest_endpoints

Le filtre rest_endpoints permet de retirer complètement une route de l’API, avant même qu’elle ne soit interrogée. C’est la méthode la plus radicale pour l’endpoint /wp/v2/users :

/**
 * Supprime l'endpoint users de l'API REST pour les visiteurs non connectés
 */
add_filter( 'rest_endpoints', 'dwp_remove_users_endpoint' );

function dwp_remove_users_endpoint( $endpoints ) {
    // On ne touche à rien si l'utilisateur est connecté (admin, éditeur, etc.)
    if ( is_user_logged_in() ) {
        return $endpoints;
    }

    // Suppression de la route collection /wp/v2/users
    if ( isset( $endpoints['/wp/v2/users'] ) ) {
        unset( $endpoints['/wp/v2/users'] );
    }

    // Suppression de la route individuelle /wp/v2/users/(?P<id>[\d]+)
    if ( isset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] ) ) {
        unset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
    }

    return $endpoints;
}

Attention cependant : cette suppression est totale, donc si un plugin front-end légitime a besoin de récupérer des infos publiques d’auteur (nom affiché sur un article, par exemple), il faudra prévoir une alternative ou adapter la condition.

Bloquer les requêtes non authentifiées avec rest_authentication_errors

Pour aller plus loin, on peut forcer une authentification sur toute l’API, sauf sur une liste blanche d’endpoints qu’on souhaite garder publics (posts, pages, catégories…). Le hook rest_authentication_errors est parfait pour ça :

/**
 * Force l'authentification sur l'API REST, sauf endpoints whitelistés
 */
add_filter( 'rest_authentication_errors', 'dwp_restrict_rest_api' );

function dwp_restrict_rest_api( $result ) {
    // Si une erreur existe déjà, on ne l'écrase pas
    if ( ! empty( $result ) ) {
        return $result;
    }

    // Liste des endpoints publics autorisés sans authentification
    $whitelist = array(
        '/wp/v2/posts',
        '/wp/v2/categories',
        '/wp/v2/tags');

    $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';

    foreach ( $whitelist as $allowed_route ) {
        if ( strpos( $request_uri, $allowed_route ) !== false ) {
            return $result; // Route autorisée, on laisse passer
        }
    }

    // Si l'utilisateur n'est pas connecté et que la route n'est pas whitelistée
    if ( ! is_user_logged_in() ) {
        return new WP_Error(
            'rest_forbidden',
            __( 'Accès refusé à cet endpoint de l\'API REST.', 'dwp' ),
            array( 'status' => 403 )
        );
    }

    return $result;
}

Méfiez-vous tout de même : cette méthode bloque aussi les requêtes internes utilisées par Gutenberg ou par certains plugins (formulaires AJAX, blocs dynamiques). Testez bien en environnement de staging avant de déployer, et ajustez la whitelist selon vos besoins réels.

Filtrer les champs exposés dans les réponses REST

Même quand l’endpoint reste accessible (pour des besoins légitimes), on peut limiter la quantité d’informations renvoyées. Le filtre rest_prepare_user permet de nettoyer la réponse avant qu’elle ne parte au client :

/**
 * Masque certains champs sensibles dans les réponses users de l'API REST
 */
add_filter( 'rest_prepare_user', 'dwp_filter_user_fields', 10, 3 );

function dwp_filter_user_fields( $response, $user, $request ) {
    $data = $response->get_data();

    // On retire le slug (souvent identique au login) et le name complet
    unset( $data['slug'] );
    unset( $data['name'] );

    $response->set_data( $data );

    return $response;
}

Cette approche est intéressante quand vous ne pouvez pas désactiver totalement l’endpoint (par exemple, un thème l’utilise pour afficher les auteurs). Ainsi, on garde la fonctionnalité tout en réduisant la surface d’exposition.

Restreindre l’accès par IP ou par rôle utilisateur

Dernière option, plus musclée : limiter l’accès à l’API REST à une whitelist d’adresses IP, stockée en option WordPress (pratique pour la modifier depuis l’admin sans toucher au code) :

/**
 * Restreint l'accès à l'API REST par IP, sauf pour les IPs whitelistées
 * L'option 'dwp_ip_whitelist' doit contenir un tableau d'IPs autorisées
 */
add_filter( 'rest_authentication_errors', 'dwp_restrict_by_ip' );

function dwp_restrict_by_ip( $result ) {
    if ( ! empty( $result ) ) {
        return $result;
    }

    // Récupération de la whitelist stockée en option (à définir via l'admin)
    $whitelist = get_option( 'dwp_ip_whitelist', array() );

    // Si aucune whitelist n'est configurée, on ne bloque rien
    if ( empty( $whitelist ) ) {
        return $result;
    }

    $remote_ip = isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : '';

    if ( ! in_array( $remote_ip, $whitelist, true ) && ! is_user_logged_in() ) {
        return new WP_Error(
            'rest_ip_forbidden',
            __( 'Votre adresse IP n\'est pas autorisée à accéder à cette API.', 'dwp' ),
            array( 'status' => 403 )
        );
    }

    return $result;
}

Pour peupler la whitelist, un simple update_option( 'dwp_ip_whitelist', array( '203.0.113.5', '198.51.100.10' ) ); suffit (à exécuter une fois, ou via une page d’options custom).

Attention toutefois : cette méthode par IP n’est pas infaillible (IP dynamiques, VPN, proxy…) et peut vite devenir contraignante pour une équipe distribuée. Réservez-la aux cas vraiment sensibles, comme un site intranet ou une API interne.

Dans tous les cas, avant de déployer une de ces solutions en production, testez systématiquement que Gutenberg fonctionne toujours normalement (il dépend fortement de l’API REST pour l’édition des articles), et vérifiez que vos plugins critiques (formulaires, e-commerce, cache) n’utilisent pas les endpoints que vous venez de restreindre.

Implémenter une limitation de tentatives avec les transients WordPress

Bloquer l’énumération et filtrer les champs, c’est une bonne base. Mais ça ne suffit pas si un attaquant peut encore spammer vos points d’entrée d’authentification sans limite. La solution ? Un système de rate limiting maison, basé sur les transients WordPress. Pas besoin de plugin tiers ni de configuration serveur complexe : l’API set_transient/get_transient suffit largement pour ce cas d’usage.

Le principe est simple : on compte les échecs par IP, et on bloque temporairement au-delà d’un seuil. Voyons ça en détail.

Créer un système de comptage des tentatives échouées

On va hooker deux points d’entrée : wp_login_failed (pour wp-login.php) et rest_authentication_errors (pour l’API REST). Chaque échec incrémente un compteur stocké dans un transient, avec une clé basée sur un hash MD5 de l’IP (histoire de ne pas stocker l’IP en clair dans wp_options).

class WP_Login_Rate_Limiter {

    const MAX_ATTEMPTS = 5;
    const LOCKOUT_TIME = 900; // 15 minutes en secondes

    public function __construct() {
        add_action( 'wp_login_failed', array( $this, 'increment_failed_attempts' ) );
        add_filter( 'rest_authentication_errors', array( $this, 'check_rest_rate_limit' ), 5 );
        add_filter( 'authenticate', array( $this, 'check_login_rate_limit' ), 30, 3 );
    }

    private function get_client_ip() {
        // A adapter selon votre config (proxy, CDN, etc.)
        return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( $_SERVER['REMOTE_ADDR'] ) : '0.0.0.0';
    }

    private function get_transient_key( $ip ) {
        return 'login_attempts_' . md5( $ip );
    }

    public function increment_failed_attempts( $username ) {
        $ip  = $this->get_client_ip();
        $key = $this->get_transient_key( $ip );
        $count = (int) get_transient( $key );
        $count++;
        set_transient( $key, $count, self::LOCKOUT_TIME );
    }
}

Rien de sorcier ici : à chaque échec, on lit le compteur existant (ou 0 s’il n’existe pas), on l’incrémente et on le réenregistre avec une durée de vie de 900 secondes. Simple, mais efficace.

Bloquer temporairement une IP après X échecs

Voici la partie qui bloque réellement. On vérifie le compteur avant même de tenter l’authentification, et on renvoie une erreur explicite si le seuil est dépassé. Pour l’API REST, on veut un vrai code HTTP 429 (Too Many Requests), pas juste un message vague.

    public function check_rest_rate_limit( $result ) {
        $ip  = $this->get_client_ip();
        $key = $this->get_transient_key( $ip );
        $count = (int) get_transient( $key );

        if ( $count >= self::MAX_ATTEMPTS ) {
            return new WP_Error(
                'rate_limited',
                __( 'Trop de tentatives de connexion. Réessayez dans quelques minutes.', 'textdomain' ),
                array( 'status' => 429 )
            );
        }
        return $result;
    }

    public function check_login_rate_limit( $user, $username, $password ) {
        if ( empty( $username ) ) {
            return $user;
        }
        $ip  = $this->get_client_ip();
        $key = $this->get_transient_key( $ip );
        $count = (int) get_transient( $key );

        if ( $count >= self::MAX_ATTEMPTS ) {
            return new WP_Error(
                'too_many_attempts',
                __( '<strong>Erreur</strong> : compte temporairement bloqué suite à trop d\'échecs. Réessayez dans 15 minutes.', 'textdomain' )
            );
        }
        return $user;
    }
}

new WP_Login_Rate_Limiter();

Notez la priorité 30 sur le filtre authenticate : ça permet de laisser WordPress faire ses propres vérifications avant, tout en interceptant le résultat final. Par contre, attention : si votre site est derrière un CDN ou un reverse proxy (Cloudflare, par exemple), REMOTE_ADDR renverra l’IP du proxy, pas celle du visiteur. Il faudra alors récupérer l’IP réelle via HTTP_X_FORWARDED_FOR (avec validation, sinon c’est une porte ouverte au spoofing).

Nettoyer et faire expirer les transients automatiquement

Bonne nouvelle : pas besoin de cron job ni de nettoyage manuel ici. C’est tout l’intérêt des transients ! WordPress gère nativement leur expiration.

Concrètement :

  • Sans object cache externe, les transients sont stockés dans wp_options, avec une entrée _transient_timeout_{clé} qui contient le timestamp d’expiration.
  • Quand vous appelez get_transient(), WordPress vérifie ce timestamp. S’il est dépassé, la valeur est automatiquement supprimée et false est retourné.
  • Donc après 15 minutes, le compteur repart naturellement à zéro, sans intervention de votre part.

Avec un object cache externe (Redis ou Memcached), c’est encore plus propre : ces systèmes gèrent l’expiration en interne (TTL natif), et les entrées expirées sont purgées automatiquement sans jamais toucher à wp_options. C’est d’ailleurs fortement recommandé en production, surtout sur un site à fort trafic : ça évite de polluer la table wp_options avec des milliers de lignes temporaires (un vieux problème connu chez WordPress quand on abuse des transients sans object cache).

Petit rappel utile si vous voulez forcer une réinitialisation manuelle (utile en debug) :

delete_transient( 'login_attempts_' . md5( $ip ) );

Et voilà, un système de rate limiting fonctionnel, léger, et sans dépendance externe. Simple à adapter selon votre seuil de tolérance et vos besoins de sécurité.

Vérifier et tester la sécurisation de son API REST

Mettre en place du code de sécurisation, c’est bien. Mais si on ne vérifie pas que ça fonctionne réellement, ça ne sert à rien ! On va donc voir comment tester concrètement chaque correctif, histoire d’être sûr que votre site est vraiment protégé et pas juste « protégé sur le papier ».

Tester l’accès à /wp-json/wp/v2/users avant et après correctifs

Le test le plus simple à faire, c’est avec curl en ligne de commande. Avant d’appliquer les correctifs, lancez cette commande :

curl -I https://monsite.fr/wp-json/wp/v2/users

Vous devriez obtenir un code « 200 OK », accompagné de la liste des utilisateurs en JSON. Pas terrible niveau sécurité, vous en conviendrez.

Une fois le code de masquage de l’endpoint appliqué (celui vu plus haut, avec rest_endpoints), relancez exactement la même commande. Le retour attendu est désormais un code « 403 Forbidden » (ou « 401 » selon la configuration choisie). Si vous obtenez toujours un « 200 », c’est que le filtre ne s’applique pas correctement : vérifiez que le code est bien chargé dans functions.php ou dans votre plugin, et que le cache (navigateur, plugin de cache, CDN) n’affiche pas une ancienne réponse.

Petit conseil : testez aussi en navigation privée pour éviter les faux positifs liés au cache local.

Simuler une attaque brute force pour valider le blocage

Pour vérifier que le système de limitation par transient fonctionne, rien de tel qu’une petite simulation d’attaque (chez vous, hein, pas chez le voisin !). Voici un script bash basique qui envoie plusieurs tentatives de connexion échouées :

for i in {1..7}; do
  curl -s -o /dev/null -w "Tentative $i : %{http_code}\n" \
  -X POST https://monsite.fr/wp-json/wp/v2/users \
  -d "username=admin&password=wrongpass$i"
done

Sur les cinq premières tentatives, vous devriez voir des codes « 401 » ou « 403 » classiques (identifiants invalides). Mais à partir de la sixième tentative (selon le seuil configuré dans votre code), le code retourné doit passer à « 429 Too Many Requests ». C’est le signal que le blocage transient s’est bien déclenché.

Si le code reste bloqué à « 401 » sans jamais passer à « 429 », vérifiez la logique de comptage dans votre fonction : le nom du transient doit être unique par IP (via $_SERVER['REMOTE_ADDR']), sinon le compteur ne s’incrémente pas correctement.

Outils complémentaires pour auditer son API WordPress

Au-delà des tests en ligne de commande, plusieurs outils permettent d’explorer plus en profondeur les routes exposées par votre API :

  • Postman : idéal pour envoyer des requêtes personnalisées (GET, POST, avec headers d’authentification) et visualiser les réponses de façon lisible. Pratique pour tester plusieurs endpoints d’affilée sans retaper des commandes curl à chaque fois.
  • REST API Navigator (extension navigateur) : permet de lister automatiquement toutes les routes disponibles sur un site WordPress, un bon moyen de vérifier qu’aucun endpoint sensible n’a été oublié dans votre configuration.
  • Wordfence ou Sucuri : ces plugins de sécurité offrent un monitoring en temps réel des tentatives d’intrusion, avec alertes par email et tableaux de bord détaillés. Ils viennent en complément du code custom, pas à sa place : votre solution maison reste plus légère et surtout, vous savez exactement ce qu’elle fait (contrairement à certains plugins un peu « boîte noire »).

Dernier point, et pas des moindres : pensez à logger les tentatives bloquées. Un simple error_log() ou l’enregistrement dans une table dédiée (avec IP, timestamp, endpoint visé) vous permettra de suivre l’évolution des attaques dans le temps. Ainsi, vous pourrez ajuster vos seuils de blocage si nécessaire, ou repérer des IP récurrentes à blacklister définitivement.