Rate limit

Limitation de débit en mémoire par identifiant et par action, avec extraction fiable de l'adresse IP.

Limiteur de débit en mémoire, appliqué par le routeur API et par les server actions d'authentification. Les compteurs vivent dans une Map : pas de base, pas de dépendance externe.

Import

import {
  RATE_LIMITS, AUTHENTICATED_MULTIPLIER, checkRateLimit,
  getIpFromHeaders, getIpFromRequest, formatRetryAfter,
} from '@lrvo/core/shared/rate-limit';

Module serveur.

Préréglages

ActionTentativesConnectéFenêtreBlocage
login10-15 min15 min
register10-60 min30 min
forgot_password6-60 min30 min
reset_password10-15 min15 min
setup_account10-15 min15 min
verify_email20-60 min15 min
resend_verification5-60 min30 min
api3009001 min2 min
upload120036001 min1 min

api est le budget par défaut de toute route. Une route peut en nommer un autre avec son champ rateLimit, et comme la clé du compteur est préréglage:identité, elle obtient alors son propre budget au lieu de puiser dans le budget partagé.

Session vérifiée

Un appelant authentifié dispose de AUTHENTICATED_MULTIPLIER fois le budget de base, soit le triple : un compte est révocable et traçable, là où une adresse ne l'est pas et ne coûte rien à changer.

Le multiplicateur ne vaut que parce que le compteur d'un appelant authentifié est tenu sur son compte, pas sur son adresse. C'est ce qui distingue les deux colonnes du tableau ci-dessus : ce ne sont pas deux plafonds sur un même compteur, mais deux compteurs distincts. Un utilisateur connecté derrière une adresse partagée, NAT de bureau ou d'opérateur mobile, ne subit donc plus le trafic anonyme de cette adresse.

Les colonnes d'authentification restent vides pour les actions d'authentification : ce sont des parcours qui précèdent la session, et une protection contre la force brute sur login ne doit jamais s'élargir parce que l'appelant détient un cookie.

Le multiplicateur se décide sur une session résolue côté serveur, jamais sur la présence d'un cookie : le cookie voyage dans la requête, donc n'importe qui pourrait en poser un pour réclamer le budget élargi.

upload existe pour cette raison : un envoi découpé dépense une requête par tranche, et un fichier de deux gigaoctets en tranches de 8 Mio en fait 256. Sous le budget api, un seul gros envoi bloquerait toute l'API de cette adresse pendant deux minutes. À 8 Mio la tranche, 1200 requêtes par minute valent 9,4 Go par minute et par adresse : jamais atteint en usage normal, borné en cas d'abus.

API

checkRateLimit(identifier, action, options)

Vérifie qu'un identifiant est autorisé pour une action, et enregistre la tentative. Un appel n'est donc pas une simple lecture.

const { allowed, retryAfterMs } = checkRateLimit(ip, 'login');
if (!allowed) {
  return apiError('Too Many Requests', `Réessayer dans ${formatRetryAfter(retryAfterMs)}.`);
}

identifier est l'identité à qui le budget appartient : l'adresse IP pour un appel anonyme, l'identifiant de compte pour un appel authentifié. Préfixer les identifiants de compte, comme le fait le routeur avec user:<id>, garde les deux espaces de noms séparés.

options.authenticated triple le budget. À ne poser que sur une session déjà vérifiée, et avec un identifier de compte : le passer sur une clé d'adresse rendrait le budget élargi réclamable par n'importe qui derrière cette adresse.

const { allowed } = checkRateLimit(`user:${session.user.id}`, 'api', { authenticated: true });
RetourRôle
allowedfalse quand la limite est dépassée.
retryAfterMsMillisecondes restantes avant déblocage.
remainingTentatives restantes dans la fenêtre.

Une action inconnue de RATE_LIMITS retourne { allowed: true } : le limiteur ne bloque jamais sur une clé qu'il ne connaît pas.

getIpFromHeaders(headersList) et getIpFromRequest(request)

Extraient l'adresse IP du client. Elles ne lisent X-Forwarded-For et X-Real-IP que lorsque LRVO_TRUST_PROXY déclare un proxy : sans cette précaution, n'importe quel client pourrait forger l'en-tête et contourner la limitation en variant la valeur. La valeur lue est en plus validée comme adresse IPv4 ou IPv6 : une chaîne arbitraire ne devient jamais une clé de compteur.

LRVO_TRUST_PROXY porte le nombre de reverse proxys devant l'application, true valant 1. X-Forwarded-For est lu par la droite : chaque proxy ajoute en fin d'en-tête l'adresse qui vient de le joindre, donc avec N proxys déclarés, l'adresse du client est la N-ième en partant de la fin. Ce qui se trouve à sa gauche a voyagé avec la requête et reste falsifiable : la lecture ne part donc jamais de la première entrée.

En-tête reçuLRVO_TRUST_PROXYAdresse retenue
203.0.113.7true203.0.113.7
1.2.3.4, 203.0.113.7, dont 1.2.3.4 forgé par le clienttrue203.0.113.7
1.2.3.4, 203.0.113.7, 172.70.0.1, derrière Cloudflare puis nginx2203.0.113.7

Quand l'en-tête compte moins d'entrées que de proxys déclarés, la requête n'a pas traversé toute la chaîne : la première entrée est retenue, faute de mieux. X-Real-IP n'est lu que si X-Forwarded-For ne donne aucune adresse valide. Il ne porte qu'une valeur, et n'est donc fiable que si le proxy le plus proche de l'application l'écrase.

Sans proxy, laisser la variable à false. Le choix de la valeur est détaillé dans Déploiement.

Quand rien n'est exploitable, le retour dépend de l'environnement : 127.0.0.1 en développement, pour que la limitation reste active en local, et 'unknown' sinon. C'est l'appelant qui traite ce cas, pas ces fonctions.

formatRetryAfter(ms)

Formate une durée pour un message utilisateur.

Notes

Limitation critique : la Map est locale au processus. En déploiement serverless ou multi-worker, chaque instance tient son propre compteur : la limite effective est multipliée par le nombre d'instances. Pour un déploiement multi-worker, remplacer cette Map par un magasin atomique partagé, Redis ou équivalent.

Quand l'IP est inconnue, la limitation est suspendue plutôt qu'appliquée sur une valeur nulle : qui reviendrait à faire partager un seul compteur à tous les visiteurs. Un avertissement opérateur est émis une seule fois. Un appelant authentifié reste limité dans ce cas, puisque son compte l'identifie sans son adresse.

Le magasin survit au hot-reload : il est posé sur globalThis via Symbol.for, pour que l'invalidation de cache de modules de Next.js ne remette pas silencieusement tous les compteurs à zéro dans le même processus.

Les entrées expirées sont purgées toutes les dix minutes, ce qui évite que la Map croisse indéfiniment sur un serveur de longue durée.

Sur cette page