Application Security

Mettre en place la limitation de débit de zéro : Algorithmes de base et gestion d'état pour les applications web

La limitation de débit n'est pas seulement une fonctionnalité ; c'est la première ligne de défense dans la sécurité des applications web. Elle protège votre infrastructure contre les attaques par déni de service (DoS), empêche l'abus d'API et assure une répartition équitable des ressources entre les utilisateurs. Alors que de nombreux développeurs s'appuient sur des services tiers comme Cloudflare ou AWS WAF, comprendre comment mettre en place la limitation de débit à partir de zéro est crucial pour créer des solutions personnalisées ou optimiser des microservices légers.

Dans cet article, nous explorerons les algorithmes de base derrière la limitation de débit, discuterons des stratégies de gestion d'état et fournirons des exemples de code pratiques en JavaScript et en Go.

L'algorithme de la fenêtre fixe : Simplicité vs l'effet de falaise

L'algorithme de la fenêtre fixe est l'approche la plus simple. Il divise le temps en intervalles fixes (par exemple, des fenêtres de 1 minute) et compte les requêtes dans cette fenêtre. Une fois que le compteur dépasse la limite, les requêtes suivantes sont rejetées jusqu'à la réinitialisation de la fenêtre.

Bien que efficace, cette méthode souffre de l'« effet de falaise ». Si un utilisateur effectue 100 requêtes à la toute fin d'une fenêtre et 100 requêtes au début de la suivante, il contourne effectivement une limite de 200 requêtes par minute en effectuant 200 requêtes sur une période de 2 minutes. Pour atténuer ce problème, certains systèmes mettent en œuvre des fenêtres superposées, mais cela ajoute de la complexité.

Implémentation en JavaScript (Node.js)

Voici un exemple simple de middleware utilisant l'approche de la fenêtre fixe avec un magasin en mémoire. Notez que pour la production, vous remplaceriez le magasin en mémoire par Redis pour gérer l'état distribué.

const RATE_LIMIT = 100;
const WINDOW_MS = 60000; // 1 minute

const requests = new Map();

function fixedWindowLimit(req, res, next) {
  const ip = req.ip;
  const now = Date.now();
  const windowStart = Math.floor(now / WINDOW_MS) * WINDOW_MS;
  
  if (!requests.has(ip) || requests.get(ip).windowStart !== windowStart) {
    // Nouvelle fenêtre, réinitialiser le compteur
    requests.set(ip, { count: 1, windowStart });
  } else {
    const current = requests.get(ip);
    if (current.count >= RATE_LIMIT) {
      return res.status(429).json({ error: "Too Many Requests" });
    }
    current.count++;
  }
  
  next();
}

Le journal de fenêtre glissante : Précision à un coût

Pour éliminer l'effet de falaise, l'algorithme du journal de fenêtre glissante enregistre l'horodatage de chaque requête. Lorsqu'une nouvelle requête arrive, le système calcule le nombre de requêtes dans les N dernières secondes. Si le compteur dépasse la limite, la requête est refusée.

Cette approche offre une grande précision mais entraîne un coût de stockage important. À mesure que le nombre de requêtes augmente, le journal croît indéfiniment à moins que vous ne mettiez en œuvre un nettoyage périodique ou n'utilisiez une structure de données triée pour éliminer efficacement les anciennes entrées.

Le seau de jetons : Un flux de trafic fluide

L'algorithme du seau de jetons est souvent considéré comme la référence pour équilibrer l'équité et les performances. Imaginez un seau d'une capacité fixe. Des jetons sont ajoutés au seau à un taux constant. Chaque requête consomme un jeton. Si le seau est vide, la requête est refusée.

Cela permet des rafales de trafic (si des jetons sont accumulés) tout en limitant strictement le taux moyen à long terme. Il est mathématiquement élégant et performe bien dans les systèmes distribués.

Implémentation en Go

Le modèle de concurrence de Go en fait un excellent choix pour implémenter des seaux de jetons. Voici une implémentation robuste utilisant une structure protégée par un mutex.

package main

import (
	"sync"
	"time"
)

type TokenBucket struct {
	tokens     float64
	maxTokens  float64
	refillRate float64
	lastRefill time.Time
	mu         sync.Mutex
}

func NewTokenBucket(maxTokens, refillRate float64) *TokenBucket {
	return &TokenBucket{
		tokens:     maxTokens,
		maxTokens:  maxTokens,
		refillRate: refillRate,
		lastRefill: time.Now(),
	}
}

func (tb *TokenBucket) Allow() bool {
	tb.mu.Lock()
	defer tb.mu.Unlock()

	now := time.Now()
	elapsed := now.Sub(tb.lastRefill).Seconds()
	tb.tokens += elapsed * tb.refillRate

	if tb.tokens > tb.maxTokens {
		tb.tokens = tb.maxTokens
	}
	tb.lastRefill = now

	if tb.tokens >= 1.0 {
		tb.tokens--
		return true
	}
	return false
}

Gestion de l'état : Choisir votre backend

L'aspect le plus critique de la limitation de débit de niveau production est la gestion de l'état. Les cartes en mémoire, comme montré dans l'exemple JavaScript, ne fonctionnent que pour les déploiements à instance unique. Pour les environnements multi-nœuds, vous avez besoin d'un état partagé.

  • Redis : La norme de l'industrie. Utilisez des ensembles triés pour les fenêtres glissantes ou des hachages simples pour les fenêtres fixes. Sa vitesse et ses opérations atomiques en font l'idéal pour les API à haut débit.
  • Memcached : Plus rapide que Redis pour les recherches simples de clés-valeurs, mais il manque les structures de données nécessaires pour les fenêtres glissantes.
  • Verrous distribués : Si vous devez utiliser une base de données, assurez-vous d'utiliser un verrouillage optimiste ou des contraintes au niveau de la base de données pour éviter les conditions de course lors d'une forte concurrence.

Conclusion

Mettre en place la limitation de débit à partir de zéro vous donne un contrôle granulaire sur la posture de sécurité de votre application. Bien que l'algorithme de la fenêtre fixe soit facile à comprendre, le seau de jetons offre une meilleure flexibilité pour les modèles de trafic réels. Quel que soit l'algorithme que vous choisissez, privilégiez toujours une gestion efficace de l'état à l'aide d'outils comme Redis pour assurer la cohérence à travers les systèmes distribués. En maîtrisant ces algorithmes de base, vous construisez une fondation résiliente qui protège votre application contre les abus tout en maintenant une expérience fluide pour les utilisateurs légitimes.

Share: