Aller au contenu principal

Concept #026

Sécurité

Le Rate Limiting

#0266 min de lecture
  • sécurité
  • api
  • performance
Rate Limiting : l'assurance-vie de vos ressources

Le principe en une phrase

Le Rate Limiting consiste à limiter le nombre de requêtes qu'un client peut envoyer à un service dans un intervalle de temps donné. Au-delà de la limite, les requêtes sont rejetées (généralement avec un code HTTP 429).

C'est l'assurance-vie de vos ressources. Sans rate limiting, un seul utilisateur (ou bot) peut monopoliser votre serveur, ralentir le service pour tout le monde, ou pire -- exploiter vos endpoints sensibles par force brute.


Pourquoi le Rate Limiting est indispensable

Protection contre les attaques par force brute

Un formulaire de connexion sans rate limiting est une invitation ouverte. Un attaquant peut tester des milliers de combinaisons login/mot de passe par seconde.

POST /api/login  { "email": "admin@site.com", "password": "aaa" }   → 401
POST /api/login  { "email": "admin@site.com", "password": "aab" }   → 401
POST /api/login  { "email": "admin@site.com", "password": "aac" }   → 401
... 10 000 tentatives plus tard ...
POST /api/login  { "email": "admin@site.com", "password": "P@ss1" } → 200 !!!

Avec un rate limit de 5 tentatives par minute sur /api/login, l'attaque devient impraticable. Il faudrait des mois au lieu de quelques minutes.

Protection contre le scraping

Sans limite, un concurrent peut aspirer l'intégralité de votre catalogue produit, vos prix, vos contenus -- en quelques heures avec un script automatisé.

Protection de la disponibilité

Un pic de trafic (légitime ou non) peut saturer vos serveurs. Le rate limiting protège les ressources pour que le service reste disponible pour l'ensemble des utilisateurs.


Les algorithmes de Rate Limiting

1. Fenêtre fixe (Fixed Window)

L'approche la plus simple : on découpe le temps en fenêtres fixes (par exemple, chaque minute) et on compte les requêtes dans chaque fenêtre.

class FixedWindowLimiter {
  private counts = new Map<string, { count: number; windowStart: number }>();
 
  constructor(
    private readonly maxRequests: number,
    private readonly windowMs: number,
  ) {}
 
  isAllowed(clientId: string): boolean {
    const now = Date.now();
    const entry = this.counts.get(clientId);
 
    // Nouvelle fenêtre ou premier appel
    if (!entry || now - entry.windowStart >= this.windowMs) {
      this.counts.set(clientId, { count: 1, windowStart: now });
      return true;
    }
 
    // Dans la fenêtre courante
    if (entry.count < this.maxRequests) {
      entry.count++;
      return true;
    }
 
    return false; // Limite atteinte
  }
}
 
const limiter = new FixedWindowLimiter(100, 60_000); // 100 req/min

Inconvénient : le problème de la "frontière de fenêtre". Un client peut envoyer 100 requêtes à 12:00:59, puis 100 autres à 12:01:00 -- soit 200 requêtes en 2 secondes. La fenêtre a changé, mais le pic est bien réel.

2. Fenêtre glissante (Sliding Window Log)

On enregistre le timestamp de chaque requête et on compte celles dans les N dernières secondes.

class SlidingWindowLimiter {
  private logs = new Map<string, number[]>();
 
  constructor(
    private readonly maxRequests: number,
    private readonly windowMs: number,
  ) {}
 
  isAllowed(clientId: string): boolean {
    const now = Date.now();
    const timestamps = this.logs.get(clientId) ?? [];
 
    // Supprimer les entrées hors de la fenêtre
    const validTimestamps = timestamps.filter(
      (t) => now - t < this.windowMs,
    );
 
    if (validTimestamps.length < this.maxRequests) {
      validTimestamps.push(now);
      this.logs.set(clientId, validTimestamps);
      return true;
    }
 
    this.logs.set(clientId, validTimestamps);
    return false;
  }
}

Plus précis que la fenêtre fixe, mais consomme plus de mémoire car il faut stocker chaque timestamp.

3. Token Bucket

Le client dispose d'un "seau" de jetons. Chaque requête consomme un jeton. Les jetons se régénèrent à un rythme constant. Si le seau est vide, la requête est refusée.

class TokenBucket {
  private tokens: number;
  private lastRefill: number;
 
  constructor(
    private readonly capacity: number,
    private readonly refillRate: number, // tokens par seconde
  ) {
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }
 
  consume(): boolean {
    this.refill();
 
    if (this.tokens >= 1) {
      this.tokens -= 1;
      return true;
    }
 
    return false;
  }
 
  private refill(): void {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(
      this.capacity,
      this.tokens + elapsed * this.refillRate,
    );
    this.lastRefill = now;
  }
}
 
// 10 tokens max, recharge de 2 tokens/seconde
const bucket = new TokenBucket(10, 2);

L'avantage du Token Bucket : il autorise des rafales (bursts) tant que le seau n'est pas vide, tout en limitant le débit moyen. C'est l'algorithme utilisé par la plupart des grandes API (AWS, Stripe, GitHub).


Implémentation avec Express.js

En pratique, on utilise un middleware dédié :

import rateLimit from "express-rate-limit";
import RedisStore from "rate-limit-redis";
import { createClient } from "redis";
 
const redisClient = createClient({ url: "redis://localhost:6379" });
 
// Rate limit global : 100 requêtes par minute
const globalLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  standardHeaders: true, // en-têtes RateLimit-*
  legacyHeaders: false,
  store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
  message: { error: "Trop de requêtes. Réessayez dans une minute." },
});
 
// Rate limit strict sur le login : 5 tentatives par minute
const loginLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true, // ne compte que les échecs
  message: { error: "Trop de tentatives. Compte temporairement verrouillé." },
});
 
app.use(globalLimiter);
app.post("/api/login", loginLimiter, loginHandler);

L'utilisation de Redis comme store est essentielle en production : si vous avez plusieurs instances de votre serveur (derrière un load balancer), le compteur doit être partagé.


Les en-têtes HTTP standard

Les API bien conçues communiquent l'état du rate limiting via des en-têtes HTTP :

HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 42
RateLimit-Reset: 1706889600

Et quand la limite est atteinte :

HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1706889600

{"error": "Rate limit exceeded. Try again in 30 seconds."}

Le header Retry-After indique au client quand il peut réessayer. Les clients bien implémentés respectent cette valeur et attendent automatiquement.


Stratégies de limitation

Le rate limiting ne se limite pas à un compteur global. Selon le contexte, on adapte la stratégie :

  • Par adresse IP : la plus courante, mais attention aux utilisateurs derrière un NAT ou un VPN d'entreprise qui partagent la même IP.
  • Par utilisateur authentifié : plus précis, indépendant de l'IP. Un utilisateur premium peut avoir des limites plus élevées.
  • Par endpoint : /api/login a des limites strictes, /api/products est plus permissif.
  • Par plan tarifaire : 1000 requêtes/jour pour le plan gratuit, 100 000 pour le plan entreprise. C'est le modèle de la plupart des API SaaS.

Rate Limiting vs Throttling

Les deux concepts sont proches mais distincts :

Rate LimitingThrottling
ActionRejette les requêtes excédentairesRalentit les requêtes excédentaires
Réponse429 Too Many RequestsMise en file d'attente ou délai
Expérience clientErreur expliciteLatence accrue

En pratique, les deux sont souvent combinés : on throttle d'abord (file d'attente), puis on rate-limite si la file déborde.


Résumé

Le Rate Limiting n'est pas un luxe, c'est une nécessité. Il protège contre les attaques par force brute, empêche le scraping intensif, et garantit la disponibilité du service pour l'ensemble des utilisateurs. Combiné aux bons en-têtes HTTP et à un stockage partagé (Redis), il devient une brique fondamentale de toute API sérieuse.