Aller au contenu
English
Compte : non connecté, synchronisation possible Mon apprentissage
Selectionner le thème
English

Limiter les abus avec les stick-tables de HAProxy

Par mis à jour le

Améliorer cette page

Pour limiter les abus avec HAProxy, comptez les requêtes dans une stick-table dont la clé est l'adresse du client, jamais une valeur qu'il envoie, et refusez au-delà d'un seuil. Cette leçon s'adresse à qui publie déjà un service derrière HAProxy 3.4 et veut qu'un client trop bavard reçoive des refus sans gêner les autres. Elle ne redit pas la recette de dix lignes de Protéger et observer HAProxy : elle enseigne ce qui la rend fausse en silence, c'est-à-dire la clé, la fenêtre, l'expiration et la portée de la table.

La leçon va de la table qui se remplit sans rien protéger à la table dont on connaît la portée.

  • Déclarer une stick-table avec son type, sa taille, son expiration et ce qu'elle stocke.
  • Suivre un client avec track-sc0 et refuser au-delà d'un seuil avec un 429.
  • Choisir une clé que le client ne peut pas modifier, et le prouver.
  • Distinguer la fenêtre de la cadence et l'expiration de l'entrée, et le lire dans show table.
  • Choisir entre compter des requêtes, des connexions ou des sessions.
  • Dire ce que la table ne protège pas : autre instance, adresse partagée, mémoire.

La leçon suppose un HAProxy qui tourne et un compte capable d'utiliser sudo. Les gestes ont été joués sur HAProxy 3.4.7, le paquet du dépôt trixie-backports-3.4 de Debian 13, avec un client distinct de la machine qui porte le proxy. Les commandes de levée de blocage de la section dédiée n'ont pas été rejouées, et la page le dit à l'endroit où elle les donne.

Qu'est-ce qu'une stick-table HAProxy et que stocke-t-elle ?

Section intitulée « Qu'est-ce qu'une stick-table HAProxy et que stocke-t-elle ? »

Une stick-table est une table en mémoire, propre à un processus HAProxy, qui associe une clé (une adresse, une chaîne) à des compteurs qu'on choisit. Elle se déclare par stick-table avec quatre réglages. Le type dit la nature de la clé : ip pour une adresse IPv4, string pour une chaîne. Une table de type string ne peut donc pas être clavée sur une adresse, c'est-à-dire avoir une adresse pour clé. La taille (size) borne le nombre d'entrées, donc la mémoire. L'expiration (expire) dit combien de temps une entrée survit sans activité. Enfin store liste les données conservées par entrée, par exemple http_req_rate(1m), la cadence de requêtes mesurée sur une minute.

Une table ne compte rien toute seule : elle attend qu'une règle lui confie une clé à suivre. C'est le rôle de track-sc, présenté juste après. La table se lit ensuite par le socket d'administration, avec show table <nom>, ce qui permet de voir ce que HAProxy a réellement retenu.

Comment suivre un client et refuser au-delà d'un seuil ?

Section intitulée « Comment suivre un client et refuser au-delà d'un seuil ? »

On suit la clé avec http-request track-sc0 <clé> table <nom>, puis on refuse quand sc_http_req_rate(0) dépasse le seuil. Le 0 désigne le premier emplacement de suivi : track-sc0 l'alimente, sc_http_req_rate(0) lit son compteur. L'exemple ci-dessous place la table dans un backend qui ne sert rien d'autre, ce qui la nomme : on la lit par son nom et plusieurs frontends peuvent la partager. Les valeurs (1m, 2m, 100, 50k) sont celles de l'exemple, non rejouées telles quelles et à adapter à votre trafic. Le lab, lui, emploie d'autres valeurs : ne les mélangez pas.

frontend public
bind :80
http-request track-sc0 src table limites
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }
default_backend applicatif
backend limites
stick-table type ip size 50k expire 2m store http_req_rate(1m)

Lisez le seuil avec son sens exact. gt 100 signifie « strictement plus de cent » : une rafale de cent requêtes passe, la cent-unième est refusée. La raison tient à l'ordre des opérations : track-sc0 compte la requête courante avant la comparaison, et gt est strict. Ce compte vaut pour une entrée au repos ; si la cadence n'est pas retombée, moins de cent requêtes passent, et les requêtes refusées comptent aussi. Un contrôle plus strict que ce qui a été prévu refuse du trafic que le service était dimensionné pour absorber, et c'est aussi une faute. Le code de refus se choisit avec deny_status ; sans lui, deny rend un 403, qui ne dit pas au client qu'il doit ralentir. Le 429 (Too Many Requests) le dit.

Une dernière condition fait la différence entre une limite et un décor : le refus doit porter sur la clé, non sur le service. Tant qu'un client est refusé, un autre client doit encore être servi. Sinon la limite ne protège pas le service, elle le remplace.

Pourquoi la clé ne doit-elle jamais être une valeur envoyée par le client ?

Section intitulée « Pourquoi la clé ne doit-elle jamais être une valeur envoyée par le client ? »

Un compteur clavé sur un en-tête est remis à zéro par celui même qu'il devait limiter. Un en-tête est choisi par qui l'envoie : un client qui change la valeur de X-Client-Id à chaque requête repart d'un compteur neuf à chaque fois, et n'atteint jamais le seuil. La table se remplit, show table affiche des entrées, et rien n'est protégé. C'est la première faute du lab de cette leçon, et elle ne se voit pas à la lecture de la configuration.

Le lab la fait prouver et non seulement corriger : une fois la source bloquée, la validation rejoue la requête avec cinq valeurs d'en-tête différentes, et toutes doivent rester refusées. Le correctif tient en un mot, src, l'adresse de la connexion TCP, la seule valeur de la requête que le client ne choisit pas. Il impose aussi un type adapté : une table type string ne peut pas être clavée sur une adresse, et changer la clé sans changer le type ne charge pas.

Ce principe n'est pas propre à HAProxy. Le guide Nginx limites arrive à la même règle avec limit_req : la clé de regroupement doit être impossible à forger.

Un cas déplace le problème sans le supprimer : derrière un autre proxy, src est l'adresse du relais, et tous les clients se retrouvent sous la même clé. Lire l'adresse que le relais a annoncée dans un en-tête n'est acceptable que si ce relais est de confiance, qu'il écrase l'en-tête reçu du client et que HAProxy n'accepte cet en-tête que de lui. Cette variante n'est pas jouée dans le lab ; traitez-la comme un point à valider sur votre architecture.

La fenêtre dit sur combien de temps la cadence est calculée, l'expiration dit combien de temps l'entrée survit dans la table : ce sont deux bornes distinctes, et une entrée retombée à une cadence basse existe toujours. Les valeurs de cette section sont celles du lab, et non celles de l'exemple précédent. Dans store http_req_rate(10s), les dix secondes sont la fenêtre ; dans expire 30s, les trente secondes sont la durée de vie de l'entrée après la dernière requête. La cadence est une moyenne glissante que HAProxy fait décroître : le manuel 3.4 écrit que http_req_rate(<période>) « reports the AVERAGE HTTP request rate over that period ». Ce n'est pas un seau qui se vide à l'échéance. Mesurée sur une fenêtre de dix secondes après une rafale de 5 requêtes, sous le seuil, la cadence valait encore 3 treize secondes après la dernière, sans aucun refus. Elle redescend, puis repasse sous le seuil : c'est à ce moment que la source retrouve le service.

Le schéma suit la vie d'une entrée. Regardez le nœud en évidence : la clé est de nouveau servie, et pourtant l'entrée est toujours dans la table. Seule l'expiration la retire.

Une entrée est créée à la première requête, la cadence monte avec les requêtes, un franchissement du seuil provoque un refus 429 pour cette clé seulement, puis la cadence redescend quand les requêtes cessent, la clé est de nouveau servie alors que l'entrée existe toujours, et l'entrée disparaît à l'expiration.nonouiPremière requête : l'entrée est crééeLes requêtes s'enchaînent : la cadence monteCadence au-dessus du seuil ?Refus 429 pour cette clé seulementLes requêtes cessent : la cadence redescend, c'est la fenêtreCadence sous le seuil : la clé est servie de nouveau, l'entrée existetoujoursAucune requête jusqu'à l'expiration : l'entrée disparaît, c'estexpire
La fenêtre règle la descente de la cadence, expire règle la disparition de l'entrée. Source · SVG
Échap pour fermer, + et − pour zoomer, défilement pour se déplacer. 100 %

Deux conséquences pratiques. La première : n'attendez pas un zéro exact. Pour décider que votre blocage ne se lève pas, patientez jusqu'à deux fenêtres et regardez si la cadence repasse sous le seuil. Conclure plus tôt revient à accuser une configuration juste. Ce piège a coûté deux tests au lab lui-même, qui exigeaient un zéro exact et réclamaient une propriété que l'outil ne promet pas. La seconde : réglez expire au moins aussi long que la fenêtre. Avec une expiration plus courte, l'entrée disparaît avant que la cadence ait fini d'être calculée, et le compteur repart de zéro sans rien dire.

Cette lenteur a un effet sur les blocages, et il se mesure sur une autre rafale que la précédente. Avec une fenêtre de 10 secondes, un seuil de 20 et une rafale de 25 requêtes, au-dessus du seuil, une requête de contrôle sur deux était encore refusée treize secondes après. Une cadence reste donc au-dessus du seuil plus longtemps qu'une fenêtre après un dépassement : un blocage se lève plus tardivement qu'on ne le croit. Ne le confondez pas avec la mesure précédente, où la cadence de 3 restait sous le seuil et ne refusait rien.

Pour le constater, lisez la table par le socket pendant et après une rafale :

Fenêtre de terminal
echo 'show table limites' | sudo socat -t2 stdio /run/haproxy/admin.sock

La première ligne rapporte le type et la taille de la table. Chaque entrée porte sa clé (key=) et sa cadence. Après la rafale, la cadence baisse alors que la clé reste listée ; elle ne disparaît qu'après expire sans requête. La disparition de l'entrée, et non une cadence nulle, est la preuve que la mémoire est libérée.

Faut-il compter des requêtes, des connexions ou des sessions ?

Section intitulée « Faut-il compter des requêtes, des connexions ou des sessions ? »

Comptez ce que vous voulez borner : http_req_rate pour des requêtes HTTP, conn_rate pour des connexions, sess_rate pour des sessions. Les trois ne sont pas interchangeables. Une connexion HTTP persistante porte souvent des dizaines de requêtes : un client qui n'ouvre qu'une connexion peut saturer un backend sans que conn_rate bouge. À l'inverse, compter les requêtes ne protège pas du client qui ouvre mille connexions lentes et n'en termine aucune ; une donnée de connexions simultanées comme conn_cur répond mieux à ce risque.

Le lab compte des requêtes, avec un client qui ouvre une connexion par requête, ce qui rend les deux nombres égaux et masque la différence. Ces trois données sont citées d'après le manuel ; leur comparaison n'est pas rejouée dans le lab. Choisissez la donnée d'après l'abus que vous redoutez, puis vérifiez-la avec show table.

Un blocage de cadence se lève tout seul quand la moyenne repasse sous le seuil ; pour le lever à la main, on efface l'entrée de la table par le socket. La commande attendue est clear table <nom> key <clé>, par exemple clear table limites key 192.0.2.10, que cette leçon n'a pas rejouée. Elle supprime l'entrée, donc le compteur repart de zéro.

Le point à retenir est le droit. Un socket de niveau operator peut exécuter set table et clear table, donc effacer un bannissement : distribuer un accès « en lecture » à un collecteur de métriques ne suffit pas à l'empêcher de modifier vos limites. La leçon Modifier l'état de HAProxy à chaud avec des droits limités traite les niveaux de socket et la manière de les séparer.

Ce droit éclaire aussi un choix de conception. Un blocage qui ne se lève pas n'est pas une limite de cadence, c'est un bannissement, et une adresse bannie par erreur se rouvre à la main. Le lab vérifie les deux moitiés : le refus est reproductible, et la source retrouve le service une fois la cadence redescendue sous le seuil.

Une stick-table ne protège que dans une instance de HAProxy et ne distingue pas les clients qui partagent une adresse. Ce sont les deux limites que le dossier du lab exige, parce que la configuration ne les dit pas. Aucune des deux ne se corrige avec une directive. Les deux dernières entrées de la liste complètent l'inventaire.

  • Portée d'une instance. Deux HAProxy derrière un répartiteur ont deux tables : un seuil de 100 par instance laisse passer 200. La section peers synchronise les tables, au prix d'une convergence qui n'est pas immédiate. La réplication ne fait donc pas d'une limite locale un quota global exact.
  • Adresse partagée. Derrière un NAT ou un réseau mobile, des milliers de clients partagent une adresse, et la limite les punit ensemble. Compter par adresse est le mieux que la connexion offre, et c'est aussi sa limite.
  • Mémoire bornée. size plafonne le nombre d'entrées, donc la mémoire : une taille démesurée laisse le trafic décider de ce que le proxy consomme. Plus la clé est variée (cardinalité élevée), plus la table se remplit vite. Quand elle est pleine, HAProxy « will flush a few of the oldest entries » par défaut (manuel 3.4) ; c'est l'option nopurge qui fait refuser les nouvelles entrées. Ce comportement est lu dans le manuel, non rejoué.
  • Pas un journal. La table vit en mémoire, elle n'est ni un historique ni une preuve durable. Pour retrouver qui a été refusé la semaine dernière, comptez sur les journaux, pas sur show table.

Un dossier honnête dit ces limites noir sur blanc. C'est ce que la dernière mesure du lab demande, relu par une personne et non par un test.

Pourquoi un client IPv6 échappe-t-il à une table de type ip ?

Section intitulée « Pourquoi un client IPv6 échappe-t-il à une table de type ip ? »

Un client IPv6 dont l'adresse atteint une table type ip n'est pas limité du tout : track-sc0 ne suit rien pour lui. Le manuel 3.4 écrit « In 3.2, "ip" is an alias for "ipv4"… In a future version, "ip" will instead correspond to "ipv6" », et il déconseille ip au profit de ipv4 ou ipv6 explicites. La conversion d'une adresse IPv6 en clé IPv4 n'aboutit que pour une adresse IPv4-mapped, IPv4-compatible ou 6to4 (manuel, section 7.1) ; sinon la clé est nulle. Ce point vient de la lecture du code (smp_to_stkey dans stick_table.c, v3.4.0) et du manuel : il n'a pas été rejoué.

Le défaut est un trou de protection : on croit son service protégé, et un client IPv6 passe sans être compté. La parade est de déclarer type ipv6 pour les clients IPv6, ou de prévoir les deux familles avec une table par type. L'exemple de cette leçon comme le lab emploient type ip, une forme qui changera de sens : relisez-les avec cette réserve.

Le lab ne permet pas de constater ce trou : son réseau ne porte aucune adresse IPv6 globale, seulement la boucle locale et le lien local, et son périmètre est IPv4. Le défaut se constate en production, là où un service est joignable en IPv6. La leçon est celle du périmètre d'une clé : une famille d'adresses non couverte est une porte ouverte, et un test au vert ne mesure que ce qu'il emprunte.

Le lab part d'une stick-table qui se remplit et se lit, mais qui ne protège de rien : sa clé est un en-tête que le client envoie, son seuil n'est jamais atteint, et sa fenêtre et son expiration sont confondues. Vous la corrigez à partir d'une recette de valeurs imposées, puis vous rédigez un dossier. La validation porte sur quatre mesures : la table compte l'adresse du client sur la fenêtre annoncée, un dépassement refuse sans bloquer une autre source, la clé ne se choisit pas côté client et le blocage se lève, et les bornes de taille et d'expiration sont celles annoncées. Le dossier, relu par une personne, dit ce que la table ne protège pas.

Une stick-table protège si la clé est fiable, si le seuil est atteignable et si l'on connaît sa portée.

  • Une stick-table se règle par son type, sa taille, son expire et ce qu'elle stocke avec store.
  • track-sc0 suit une clé, sc_http_req_rate(0) la lit, deny_status 429 refuse ; gt 100 refuse dès que la moyenne dépasse cent, soit la cent-unième requête d'une rafale partie d'une entrée au repos.
  • La clé ne doit jamais être une valeur que le client envoie : un en-tête se change, et le compteur repart de zéro. src est la clé fiable.
  • La fenêtre dit sur combien de temps la cadence est calculée, expire combien de temps l'entrée survit, et show table montre les deux.
  • http_req_rate(<période>) est une moyenne glissante : elle redescend et repasse sous le seuil, elle ne tombe pas à zéro à l'échéance. Attendez jusqu'à deux fenêtres avant de conclure.
  • Requêtes, connexions et sessions sont trois compteurs différents : choisissez selon l'abus redouté.
  • Un socket operator peut exécuter clear table : il peut lever un blocage.
  • Une table type ip ne suit pas les clients IPv6 : déclarez ipv4 et ipv6 explicitement.
  • La table vit dans une instance, ne distingue pas les clients derrière une même adresse, et n'est pas un journal durable.

Huit questions vérifient que vous savez choisir la clé, lire la fenêtre et l'expiration, et connaître les limites de la table, une fois le lab joué.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

8 questions
8 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

Ce projet vous est utile ?

Les guides, formations, quiz et labs restent gratuits. Si vous les utilisez régulièrement, vous pouvez contribuer à leur maintenance et à leur développement.

Devenir membre de soutien (nouvel onglet) Soutenir une fois (nouvel onglet) En savoir plus

Suivez aussi le projet sur LinkedIn