Artikel

Rate-Limits dürfen fail-open sein — Auth nicht

Wie ich Begrenzungen tatsächlich umgesetzt habe: Token-Bucket, gleitendes Fenster, Zähler mit Ablauf — und was passiert, wenn der Speicher dahinter ausfällt.

BlackZackBlackzack

904 Wörter · 5 Min. Lesezeit

  • sicherheit
  • architektur
  • meinung

Ich habe an anderer Stelle darüber geschrieben, wann ich im Zweifel verweigere und wann ich durchlasse. Dieser Eintrag ist der praktische Teil davon: wie meine Begrenzungen tatsächlich funktionieren.

Warum überhaupt begrenzen

Drei Gründe, die verschiedene Lösungen verlangen.

Schutz vor Überlast. Ein Endpunkt, der eine teure Auswertung berechnet, darf nicht hundertmal pro Sekunde aufgerufen werden.

Schutz vor Missbrauch. Ein Anmeldeversuch, ein Einlöseversuch, ein Passwortzurücksetzen. Hier geht es nicht um Last, sondern um Ausprobieren.

Fairness. Ein Nutzer soll nicht die Kapazität für alle verbrauchen.

Die drei Verfahren, die ich benutze

Fester Zähler mit Ablauf. Der einfachste Fall: ein Zähler pro Schlüssel, der nach einer Zeitspanne verfällt.

const schluessel = `limit:${route}:${nutzer}`;
const anzahl = await speicher.erhoeheMitAblauf(schluessel, 60);
if (anzahl > 30) return abgelehnt(60);

Der bekannte Nachteil: An der Fenstergrenze kann jemand die doppelte Menge durchbringen — dreißig in der letzten Sekunde des einen Fensters, dreißig in der ersten des nächsten. Für Fairness reicht es trotzdem.

Gleitendes Fenster. Statt eines Zählers pro Zeitraum eine Liste von Zeitstempeln, aus der alles Ältere entfernt wird. Genauer, teurer. Ich benutze es für alles, wo Missbrauch das Thema ist.

Token-Bucket. Ein Eimer mit einer Kapazität, der sich mit einer festen Rate wieder füllt. Jede Anfrage kostet einen Token. Der Vorteil: Kurze Ausbrüche sind erlaubt, dauerhaft hohe Last nicht. Das ist das Verfahren, das sich für Nutzer am fairsten anfühlt, weil ein schneller Klick nicht sofort bestraft wird.

Der Schlüssel — der Teil, den man leicht falsch macht

Woran wird gemessen? Diese Entscheidung ist wichtiger als das Verfahren.

SchlüsselPasst fürProblem
IP-AdresseNicht angemeldete ZugriffeMehrere Nutzer hinter einem Anschluss teilen sich das Limit
NutzerkennungAngemeldete ZugriffeWer kein Konto braucht, wird nicht erfasst
Kombination aus beidemAnmeldeversucheEtwas mehr Aufwand

Für Anmeldungen benutze ich beides gleichzeitig: pro Anschluss und pro Konto. Der erste Schlüssel bremst jemanden, der viele Konten durchprobiert. Der zweite schützt ein einzelnes Konto vor Versuchen aus vielen Richtungen.

Warnung

Bei der IP-Adresse hinter einem Proxy gilt: Man darf nicht blind dem Weiterleitungskopf vertrauen, sonst kann sich jeder eine beliebige Adresse ausdenken und die Begrenzung umgehen. Nur ein Proxy, dem man vertraut und den man selbst konfiguriert hat, darf diesen Wert setzen — und die Anwendung muss wissen, dass sie hinter genau diesem Proxy steht.

Was passiert, wenn der Speicher ausfällt

Hier wird die Haltungsfrage konkret. Meine Begrenzungen liegen in einem Zwischenspeicher, der optional ist. Wenn er nicht erreichbar ist, gibt es zwei Möglichkeiten.

Ich lasse durch — mit einer Ausnahme.

Für Last und Fairness: durchlassen. Der Zweck ist Schutz vor Überlast. Wenn der Ausfall des Zählers zu einem Totalausfall führt, ist die Maßnahme schädlicher als das Problem, gegen das sie schützen soll.

Für Anmeldeversuche: verweigern. Hier geht es um einen Angriff, nicht um Last. Wenn ich nicht mitzählen kann, wie oft jemand ein Passwort probiert hat, lasse ich es lieber gar nicht zu. Diese Ausnahme ist bewusst, und sie steht als Kommentar im Code, damit sie beim nächsten Aufräumen nicht wegvereinheitlicht wird.

Was Nutzer davon sehen sollen

Eine Begrenzung, die nur mit einem Fehler antwortet, ist unangenehm. Was dazugehört:

Der richtige Statuscode und eine Angabe, wie lange man warten soll — als Kopfzeile und im Antwortkörper.

Eine verständliche Meldung. „Zu viele Anfragen. Bitte in 42 Sekunden erneut versuchen." Nicht „Rate limit exceeded".

Eine Anzeige im Voraus. Bei Schnittstellen gehören die verbleibenden Aufrufe in die Antwort, damit ein Client sich anpassen kann, statt gegen die Wand zu laufen. Genau das, was ich fünf Jahre vorher bei Discord gelernt habe, als ich selbst der Client war, der gegen die Wand lief.

Wie ich Grenzen festlege

Die häufigste Frage bei Begrenzungen ist nicht das Verfahren, sondern die Zahl. Mein Vorgehen dafür ist unspektakulär und funktioniert.

Erst messen, dann begrenzen. Wie oft wird dieser Endpunkt im Normalbetrieb aufgerufen — pro Nutzer, pro Stunde, im Spitzenfall? Ohne diese Zahl ist jede Grenze geraten.

Die Grenze deutlich über den Spitzenwert legen. Faktor fünf bis zehn. Eine Begrenzung soll Missbrauch bremsen, nicht intensive Nutzung.

Bei Anmeldeversuchen anders rechnen. Dort ist die normale Nutzung sehr niedrig — ein paar Versuche —, und die Grenze darf entsprechend eng sein.

Nach dem Scharfschalten beobachten. Wie oft greift die Begrenzung tatsächlich, und trifft sie echte Nutzer? Bei mir hat sich dabei einmal gezeigt, dass eine Grenze zu eng war, weil ein Client automatisch wiederholte.

Der Fall, der mich am meisten gelehrt hat

Es gab eine Begrenzung, die einen legitimen Nutzer ausgesperrt hat — und die Ursache lag nicht in der Zahl, sondern im Schlüssel.

Ich hatte pro Anschluss gezählt. Auf einem Server, auf dem mehrere Personen aus demselben Netz zugriffen, teilten sie sich das Kontingent. Für sie sah es aus wie ein sporadischer Fehler, für mich wie normales Verhalten.

Die Lösung war, bei angemeldeten Zugriffen nach Konto zu zählen und nur bei nicht angemeldeten nach Anschluss. Der Schlüssel ist die eigentliche Entscheidung — nicht die Zahl.

Was ich daraus mitgenommen habe, gilt für jede Art von Kontingent: Man muss wissen, wen man da eigentlich begrenzt. Und die Antwort ist selten so eindeutig, wie es beim Bauen scheint.

Was ich mitnehme

Die praktisch wichtigste Erkenntnis ist unspektakulär: Die meisten Begrenzungen, die ich gebaut habe, greifen nie. Sie sind kein Werkzeug für den Alltag, sondern für den seltenen Fall.

Deshalb ist es umso wichtiger, dass sie im Normalbetrieb nichts kosten und im Fehlerfall nichts kaputtmachen. Eine Schutzmaßnahme, die einmal im Jahr wirkt und einmal im Monat stört, ist ein schlechtes Geschäft.