vendredi 25 septembre 2026

À propos

Contact

Sécurité

Sécuriser une route REST publique contre l’épuisement de ressources (DoS)

Une route REST personnalisée sans limite de résultats permettait de forcer WordPress à charger des dizaines de milliers d'articles en une requête. Correctif par pagination forcée et limite stricte.

Par Clément Hadrot • 2 mai 2023 • 5 min de lecture • Aucun commentaire
Sécuriser une route REST publique contre l'épuisement de ressources (DoS)

Symptôme. Un client exploitant un site associatif avec un annuaire de plusieurs dizaines de milliers de membres, exposé via une route REST personnalisée pour une application mobile, constate des ralentissements généralisés du site à intervalles irréguliers, parfois accompagnés d’erreurs 503 côté hébergeur. L’hébergeur signale des pics de charge processeur inhabituels, sans lien apparent avec le trafic global mesuré côté analytics, qui reste stable.

Diagnostic : une seule requête qui charge tout l’annuaire

L’analyse des journaux serveur, croisée avec les pics de charge, fait apparaître un motif récurrent : des requêtes répétées vers /wp-json/annuaire/v1/membres, sans aucun paramètre de pagination, provenant d’adresses IP variées mais avec un intervalle de quelques secondes seulement entre chaque appel — pas nécessairement une attaque malveillante organisée, potentiellement aussi un script d’agrégation tiers mal écrit qui explore l’API sans respecter de limite raisonnable. Quelle que soit l’intention, le résultat est identique : chaque appel déclenche une requête interne qui charge l’intégralité des dizaines de milliers de membres en une seule fois.

Le code de la route, écrit rapidement lors du développement initial pour simplifier l’intégration côté mobile, révèle immédiatement la cause :

// Code fautif : aucune limite, aucune pagination forcée
register_rest_route( 'annuaire/v1', '/membres', array(
    'methods'             => 'GET',
    'callback'            => 'moncpt_lister_membres',
    'permission_callback' => '__return_true',
) );

function moncpt_lister_membres( $request ) {
    $requete = new WP_Query( array(
        'post_type'      => 'membre',
        'posts_per_page' => -1, // toutes les entrées, sans limite
    ) );

    $resultats = array();
    foreach ( $requete->posts as $post ) {
        $resultats[] = array(
            'id'    => $post->ID,
            'nom'   => get_post_meta( $post->ID, 'nom_complet', true ),
            'ville' => get_post_meta( $post->ID, 'ville', true ),
        );
    }

    return new WP_REST_Response( $resultats, 200 );
}

posts_per_page => -1 demande explicitement à WP_Query de ne poser aucune limite, une valeur parfaitement légitime dans un contexte interne maîtrisé (un export administratif, par exemple), mais dangereuse dès qu’elle est atteignable directement par n’importe quel appel HTTP public, sans authentification ni contrôle de fréquence.

Pourquoi ce type de route est un vecteur de déni de service applicatif

L'essentiel à retenir : Un paramètre non borné devient un levier de saturation ; posts_per_page à -1 dans une route publique est un risque direct ; La pagination forcée protège sans limiter les usages légitimes

Ce n’est pas une attaque réseau volumétrique classique (un déni de service distribué visant à saturer la bande passante ou les connexions réseau elles-mêmes), qui se traite à un tout autre niveau, généralement via un pare-feu applicatif ou une protection au niveau du fournisseur d’infrastructure. Il s’agit ici d’un déni de service applicatif : une requête, en apparence légitime et peu coûteuse à formuler côté client (une simple requête GET sans paramètre), déclenche côté serveur un traitement disproportionné — chargement de dizaines de milliers de lignes en base, itération PHP sur chacune, appels répétés à get_post_meta() pour chaque entrée (chacun une requête SQL supplémentaire en l’absence de préchargement). Répétée plusieurs fois par minute, cette seule route suffit à épuiser les ressources processeur et mémoire du serveur, sans qu’aucun volume de trafic réseau anormal ne soit nécessaire.

Le correctif : pagination forcée et limite stricte

register_rest_route( 'annuaire/v1', '/membres', array(
    'methods'             => 'GET',
    'callback'            => 'moncpt_lister_membres_corrige',
    'permission_callback' => '__return_true',
    'args'                => array(
        'page' => array(
            'default'           => 1,
            'sanitize_callback' => 'absint',
        ),
    ),
) );

function moncpt_lister_membres_corrige( $request ) {
    $page = max( 1, (int) $request->get_param( 'page' ) );

    // Limite stricte non contournable par le client, quel que soit
    // ce qu'il tenterait de transmettre en paramètre.
    $par_page = 50;

    $requete = new WP_Query( array(
        'post_type'      => 'membre',
        'posts_per_page' => $par_page,
        'paged'          => $page,
        'no_found_rows'  => false, // nécessaire pour exposer le nombre total de pages
    ) );

    $resultats = array();
    foreach ( $requete->posts as $post ) {
        $resultats[] = array(
            'id'    => $post->ID,
            'nom'   => get_post_meta( $post->ID, 'nom_complet', true ),
            'ville' => get_post_meta( $post->ID, 'ville', true ),
        );
    }

    $reponse = new WP_REST_Response( $resultats, 200 );
    $reponse->header( 'X-WP-TotalPages', (string) $requete->max_num_pages );

    return $reponse;
}

Le paramètre de pagination reste ouvert au client (il choisit quelle page il veut consulter), mais la taille de chaque page est fixée côté serveur, sans jamais dépendre d’une valeur transmise par la requête — c’est cette dissociation qui referme la faille : même une tentative explicite de demander une valeur énorme via un paramètre détourné n’a plus aucun effet, puisque $par_page est une constante du code, pas une entrée utilisateur.

Compléter par une limite de fréquence

La pagination forcée limite le coût de chaque requête individuelle, mais n’empêche pas un grand nombre de requêtes légitimement bornées d’être envoyées en rafale. Une limite de fréquence par IP, appliquée spécifiquement à cette route via un compteur en cache d’objet (le même principe que pour la limitation des tentatives de connexion, appliqué ici à un contexte différent), complète la protection sans pénaliser un usage normal de l’application mobile.

Sur ce projet, après correctif, la charge processeur générée par cette route est passée d’un pic occasionnel proche de la saturation à une consommation stable et négligeable, sans qu’aucune fonctionnalité de l’application mobile n’ait eu besoin d’être modifiée côté client au-delà de la gestion de la pagination déjà prévue dans les spécifications initiales.

Ce que ce cas ne couvre pas

Ce diagnostic ne traite pas des attaques par déni de service distribué au niveau réseau, qui relèvent de protections d’infrastructure (pare-feu applicatif, CDN avec atténuation DDoS) indépendantes du code applicatif WordPress. Le problème traité ici se situe entièrement au niveau du code de la route elle-même, et se corrige donc entièrement à ce niveau.

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