Skip to content

How-to : Limiteur de débit à fenêtre glissante

Vue d'ensemble

Ce guide couvre la construction d'un limiteur de débit à fenêtre glissante par utilisateur et par endpoint avec NENE2. Les requêtes sont comptées dans une fenêtre de temps mobile ; une fois la limite atteinte, les requêtes suivantes sont rejetées avec 429 Too Many Requests.

Implémentation de référence : ../NENE2-FT/ratelog/


Conception du schéma

sql
CREATE TABLE IF NOT EXISTS rate_events (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    endpoint   TEXT    NOT NULL,
    created_at TEXT    NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_rate_events_user_endpoint
    ON rate_events (user_id, endpoint, created_at);

L'index sur (user_id, endpoint, created_at) rend la requête COUNT rapide à l'échelle.


Table des routes

MéthodeCheminAuthDescription
POST/rate/checkUtilisateurEnregistrer une requête ; retourne 429 si au-dessus de la limite
GET/rate/statusUtilisateurUtilisation actuelle pour un utilisateur/endpoint
DELETE/rate/reset/{userId}AdminRéinitialiser les compteurs d'un utilisateur

Algorithme principal

php
private const int LIMIT = 10;
private const int WINDOW_SECONDS = 60;

public function check(int $userId, string $endpoint): string
{
    $since = $this->windowStart();   // now() - 60s
    $count = $this->countInWindow($userId, $endpoint, $since);

    if ($count >= self::LIMIT) {
        return 'rate_limited';
    }

    $this->recordEvent($userId, $endpoint);
    return 'ok';
}

Fenêtre glissante : chaque check() regarde exactement WINDOW_SECONDS en arrière depuis le moment actuel, donc les anciens événements tombent naturellement hors de portée.


Réinitialisation admin avec pattern fail-closed

php
private function isAdmin(ServerRequestInterface $req): bool
{
    if ($this->adminKey === '') {
        return false;     // fail-closed : clé non configurée bloque tout accès admin
    }
    return hash_equals($this->adminKey, $req->getHeaderLine('X-Admin-Key'));
}

Réinitialiser tous les compteurs d'un utilisateur (tous les endpoints) :

sql
DELETE FROM rate_events WHERE user_id = :uid

Réinitialiser pour un endpoint spécifique :

sql
DELETE FROM rate_events WHERE user_id = :uid AND endpoint = :ep

Extraction de paramètre de chemin (sans Router::param())

Quand Router::param() n'est pas disponible dans la version installée, utiliser l'attribut directement :

php
/** @var array<string, string> $params */
$params = $req->getAttribute(Router::PARAMETERS_ATTRIBUTE, []);
$raw    = $params['userId'] ?? '';

Validation

  • endpoint : chaîne non vide, max 128 caractères
  • X-User-Id : ctype_digit() + entier positif
  • Chemin userId : ctype_digit() + entier positif (échec → 404)
  • Clé admin : comparaison hash_equals() (échec → 403)

Codes de statut HTTP

SituationStatut
Requête autorisée200
Statut récupéré200
Compteur réinitialisé200
Pas de X-User-Id400
Pas de corps400
Endpoint vide / manquant422
Endpoint trop long422
Pas de clé admin403
Mauvaise clé admin403
userId invalide dans le chemin404
Limite de débit dépassée429

Patterns d'attaque ATK couverts

ATKPatternDéfense
ATK-01X-User-Id manquant400 avec message
ATK-02Chaîne endpoint videValidation 422
ATK-03Endpoint de 129 caractères (DoS)Limite de longueur 422
ATK-04Injection SQL dans l'endpointRequêtes paramétrées
ATK-05Tentative de réinitialisation non-admin403 fail-closed
ATK-06Mauvaise clé admin403 hash_equals()
ATK-07userId négatif dans le chemin404
ATK-08userId zéro404
ATK-09userId non-numérique (abc)404 ctype_digit
ATK-10Statut sans paramètre endpoint422
ATK-11Check sans corps400
ATK-12Corps sans clé endpoint422

Notes

  • Concurrence : La fenêtre glissante a une petite fenêtre TOCTOU. Pour une utilisation en production à forte concurrence, envisager des compteurs atomiques (Redis INCR + EXPIRE) ou un verrouillage au niveau de la base de données.
  • Dérive d'horloge : Tous les timestamps doivent utiliser UTC pour éviter les surprises liées à l'heure d'été ou aux fuseaux horaires.
  • Croissance du stockage : Les anciens événements s'accumulent. Ajouter un job de nettoyage périodique : DELETE FROM rate_events WHERE created_at < :cutoff.

Publié sous licence MIT.