Zum Inhalt springen

Rate-Limiting mit HAProxy: Bots bremsen, bevor sie PHP erreichen

Veröffentlicht am

Ein Blick ins Access-Log einer Shop-Seite: Eine einzige IP hat in sechseinhalb Stunden gut 300.000 Requests abgesetzt. Das sind rund 13 pro Sekunde, nachts und ohne Pause. Ein Mensch lädt eine Seite, liest und klickt weiter. Er kommt im Schnitt auf eine Seite alle 10 bis 20 Sekunden.

Gegen solche Dauerläufer hilft ein Rate-Limit: höchstens X Requests pro IP in einem Zeitfenster, alles darüber wird abgewiesen.

Wo gehört das Limit hin?

Drei Stellen bieten sich an. Nur eine passt wirklich:

  • Firewall: Sie sieht Verbindungen, keine einzelnen HTTP-Requests. Unter HTTP/2 laufen tausende Requests über eine Handvoll Verbindungen. Für die Firewall sieht das harmlos aus.
  • Anwendung (PHP): Hier ist es zu spät. Wenn PHP den Request ablehnt, sind Webserver-Worker und PHP-Prozess schon belegt. Außerdem sieht die Anwendung keine Bilder, CSS- oder JS-Dateien.
  • HAProxy: Er sieht jeden Request mit Pfad und Absender. Er kann pro IP zählen und mit 429 Too Many Requests antworten, bevor der Webserver überhaupt etwas tut.

Steht ohnehin ein HAProxy vor den Webservern, ist das die richtige Stelle.

Was zählen?

Nicht jeder Request kostet gleich viel. Ein Bild kommt fertig von der Platte. Eine Seite startet PHP und fragt die Datenbank ab. Dazu kommt: Schon ein einziger Seitenaufruf lädt schnell 50 Bilder auf einmal.

Deshalb zwei getrennte Zähler: ein strenger für Seiten und PHP, ein großzügiger für statische Dateien.

Die Zähl-Tabellen

HAProxy bringt dafür sogenannte stick-tables mit. Das sind kleine Key-Value-Speicher im Arbeitsspeicher des Proxys:

backend ratelimit_table_page_rate
    stick-table type ipv6 size 1m expire 1m store http_req_rate(10s)

backend ratelimit_table_static_rate
    stick-table type ipv6 size 1m expire 1m store http_req_rate(10s)

Das backend ist hier nur ein Behälter. Pro Abschnitt erlaubt HAProxy genau eine stick-table, für zwei Tabellen braucht es also zwei Abschnitte. Es gibt keine Server darin, und kein Request wird je dorthin geschickt.

  • type ipv6: Der Schlüssel ist eine IP-Adresse. IPv4 passt auch hinein und wird als ::ffff:1.2.3.4 gespeichert.
  • size 1m: höchstens eine Million IPs gleichzeitig.
  • expire 1m: Eine IP, die eine Minute lang nichts schickt, fliegt raus. Das ist reines Aufräumen.
  • store http_req_rate(10s): der Wert pro IP, nämlich die Zahl der Requests in den letzten 10 Sekunden.

http_req_rate ist fest eingebaut. Programmiert wird hier nichts, nur festgelegt, über welches Fenster gezählt wird.

Die Regeln im Frontend

Der eigentliche Ablauf steht im frontend, das die Requests annimmt:

frontend https
    # ... bind, Zertifikat usw.

    acl is_static_request path_end -i .jpg .jpeg .png .gif .webp .svg .ico .css .js .woff .woff2 .mp3
    acl is_ratelimit_allowlisted src -f /usr/local/etc/haproxy/ratelimit-allow.lst
    acl is_ratelimit_excluded_path path_beg -f /usr/local/etc/haproxy/ratelimit-excluded-paths.lst

    http-request track-sc0 src table ratelimit_table_page_rate if !is_static_request !is_ratelimit_allowlisted !is_ratelimit_excluded_path
    http-request track-sc1 src table ratelimit_table_static_rate if is_static_request !is_ratelimit_allowlisted !is_ratelimit_excluded_path

    acl is_page_rate_over_limit sc_http_req_rate(0) gt 50
    acl is_static_rate_over_limit sc_http_req_rate(1) gt 300

    http-request capture str(page) len 6 if is_page_rate_over_limit
    http-request capture str(static) len 6 if is_static_rate_over_limit

    http-request deny deny_status 429 if is_page_rate_over_limit || is_static_rate_over_limit

    default_backend webservers

Das sieht nach viel aus. Es sind aber nur zwei Bausteine, die sich abwechseln.

acl: eine benannte Bedingung

Eine acl-Zeile legt eine Art Variable an, die wahr oder falsch ist. Ausgewertet wird sie erst, wenn eine Regel sie benutzt, und zwar bei jedem Request neu. Wie bei einer Funktionsdefinition ist es egal, wo sie steht.

Der Aufbau ist immer gleich: erst einen Wert holen, dann vergleichen.

  • path_end -i .jpg …: Der Pfad des Requests (ohne Query-String) endet auf eine dieser Endungen. -i ignoriert Groß- und Kleinschreibung.
  • src -f datei: Die Absender-IP steht in der Datei. Netze wie 10.0.0.0/8 gehen auch.
  • path_beg -f datei: Der Pfad beginnt mit einem Eintrag aus der Datei.

In den Listen-Dateien steht ein Eintrag pro Zeile. Zeilen mit # am Anfang sind Kommentare.

http-request: ein Hook in den Request

http-request hängt sich in die Verarbeitung jedes eingehenden Requests ein, bevor er an den Webserver geht. Die Zeilen laufen von oben nach unten. Hinter http-request steht die Aktion, am Ende optional ein if mit Bedingungen. Mehrere ACLs hintereinander sind ein UND, ! verneint, || ist ein ODER.

Das Gegenstück heißt http-response und läuft auf der Antwort des Webservers.

track-sc0: zählen und merken

http-request track-sc0 src table ratelimit_table_page_rate if !is_static_request ...

Das liest sich so: Für einen Request, der keine statische Datei ist, wird in ratelimit_table_page_rate unter der Absender-IP +1 gezählt. Die Tabellenzeile landet anschließend in sc0.

sc0, sc1 und sc2 sind feste Namen, die man sich nicht ausdenken kann. Standardmäßig gibt es genau drei („sticky counter“). Sie gelten nur für den aktuellen Request. Ein Bild füllt sc1, eine Seite sc0, der jeweils andere Platz bleibt leer.

Der Wert in sc0 ist der Stand nach dem +1. Der aktuelle Request ist also schon mitgezählt.

Zwei weitere Bedingungen

acl is_page_rate_over_limit sc_http_req_rate(0) gt 50

sc_http_req_rate(0) liest den Zählerstand aus sc0. Die 0 ist die Platznummer, kein Tabellenname. gt steht für „greater than“. Es gibt auch ge, lt und le. Mit gt 50 gehen die ersten 50 Requests in 10 Sekunden durch, ab dem 51. ist die Bedingung wahr.

capture: eine Markierung im Log

http-request capture str(page) len 6 if is_page_rate_over_limit

capture schreibt keinen eigenen Logeintrag. Es hängt einen Text an die normale Logzeile des Requests an, in geschweiften Klammern:

... 200 78439 - - --NI 8/8/7/7/0 0/0 {page|} "GET /some/page HTTP/2.0"

Jede capture-Zeile belegt ein festes Feld, getrennt durch |. Greift die Bedingung nicht, bleibt das Feld leer. Ein normaler Request hat deshalb {|}, eine zu schnelle Seite {page|} und eine zu schnelle statische Datei {|static}. Mit grep -F '{page|}' findet man alle Treffer.

deny: abweisen

http-request deny deny_status 429 if is_page_rate_over_limit || is_static_rate_over_limit

Über einem der beiden Limits antwortet HAProxy selbst mit 429 Too Many Requests. Der Webserver bekommt den Request nie zu sehen. Ohne deny_status wäre es ein 403. 429 ist aber der passende Code: Saubere Crawler verstehen ihn als „langsamer“ statt „verboten“.

Ausnahmen

Nicht jeder, der viele Requests schickt, ist ein Problem:

  • Interne Netze, z. B. Büro, Lager oder ein PDF-Generator, der Seiten abruft, gehören in die Allowlist der IPs.
  • Webhooks von Zahlungsanbietern gehören in die Pfad-Ausnahmen. Ein 429 an dieser Stelle heißt im schlimmsten Fall: Eine Zahlung wird nicht verbucht.
  • Suchmaschinen-Crawler veröffentlichen ihre IP-Netze. Die kann man ebenfalls in die Allowlist übernehmen.

Beide Listen werden beim Start eingelesen. Nach einer Änderung braucht HAProxy einen Reload.

Erst zählen, dann blocken

Bevor die deny-Zeile aktiv wird, sollte sie ein paar Tage auskommentiert bleiben. Die capture-Markierungen zeigen dann, wen das Limit treffen würde, ohne dass ein echter Besucher eine Fehlerseite sieht.

Für die Grenzwerte lohnt ein Blick ins vorhandene Access-Log: Wie viele Seitenaufrufe macht eine IP in einem 10-Sekunden-Fenster höchstens? In Projekten, in denen das ausgewertet wurde, lagen echte Besucher bei Seiten und PHP bei etwas über 30. Der Grund sind die AJAX-Calls, die jede Seite nachlädt. Ein Limit von 30 hätte also Menschen getroffen, über 50 lagen nur noch Bots. Bei statischen Dateien schaffen auch Menschen kurzzeitig 200 Requests.

Live reinschauen

Über den Stats-Socket zeigt HAProxy die Tabelle mit jeder IP und ihrem Zählerstand:

echo "show table ratelimit_table_page_rate" | nc 127.0.0.1 9999
# table: ratelimit_table_page_rate, type: ipv6, size:1048576, used:1
0x7ec2431594a0: key=::ffff:172.19.0.1 use=0 exp=57506 shard=0 http_req_rate(10000)=74

Testen lässt es sich mit parallelen Requests:

seq 80 | xargs -P 20 -I{} curl -sk -o /dev/null -w '%{http_code}\n' https://example.test/ | sort | uniq -c
     50 200
     30 429

Geblockte Requests stehen im Log mit <NOSRV>, Status 429 und dem Flag PR--. HAProxy hat selbst geantwortet.

Was ein 10-Sekunden-Limit nicht kann

Ein Rate-Limit begrenzt das Tempo, nicht die Menge. Wer laufend 50 Requests mit F5 erzeugt, pendelt an der Grenze: einmal Seite, einmal 429, nach 10 Sekunden ist alles wieder frei.

Unangenehmer sind Bots, die gleichmäßig knapp unter der Grenze bleiben. Ein Bot mit 43 Requests pro 10 Sekunden rutscht durch und kommt trotzdem auf über 15.000 PHP-Requests pro Stunde.

Dagegen hilft ein zweites, langes Fenster in einer weiteren Tabelle, etwa http_req_rate(10m). Ein Mensch, der alle 10 bis 20 Sekunden eine Seite liest, kommt mit AJAX-Calls auf einige hundert Requests in 10 Minuten. Ein Dauerläufer kommt auf ein Vielfaches. Wichtig: expire muss dann mindestens so lang sein wie das Fenster.

Wer Ausreißer nicht nur bremsen, sondern für ein paar Minuten ganz aussperren will, nimmt eine dritte Tabelle mit einem gpc0-Zähler. Das ist ein frei verwendbarer Zähler, der beim Überschreiten hochgesetzt wird und die IP bis zum Ablauf sperrt. Das ist aber ein eigenes Thema.

Genannte Produkt-, Firmen- und Markennamen gehören ihren jeweiligen Inhabern und werden hier allein beschreibend verwendet. Eine Verbindung zu ihnen oder eine Empfehlung durch sie besteht nicht.