Wer was darf, und wie oft · Übung
Mit 429 antworten und die Sperre abwägen
Du hast einen Zähler, den niemand fragt. Sieh dir an, was das für die Anmeldung bedeutet. Führ im
python pl_sec.py guessing
pl_sec: guessing, your server on 127.0.0.1 (a port the system chose)
--- six wrong passwords for ana ---
wrong password 1 POST /login -> 401
wrong password 2 POST /login -> 401
wrong password 3 POST /login -> 401
wrong password 4 POST /login -> 401
wrong password 5 POST /login -> 401
wrong password 6 POST /login -> 401
--- then her right password ---
ana's own password POST /login -> 200 {"user": "ana"}
(the clock moves 300 seconds)
ana's own password POST /login -> 200 {"user": "ana"}
--- result ---
6 wrong passwords, 0 refused before a password was checked
serve() returned after 8 connections; the server is closed
pl_sec: every thread has stopped
Sechs Versuche, sechs Passwortprüfungen, und nichts hätte sechstausend aufgehalten. Das ist Befund F9.
Schreib zuerst den Angriff
Der Test sendet reading_server.USER_LIMIT-mal ein falsches Passwort für ana und sichert jedes Mal 401 zu. Diese Zusicherungen sind die Vorbedingung: Das Limit darf nicht zu früh ablehnen. Dann sendet er noch einen Versuch und sichert den Status 429 zu und dass "retry-after" in den Headern der Antwort steht.
Lies das Limit aus dem reading_server.USER_LIMIT, und schreib die Zahl nicht hin. Führ python -m pytest -q aus: 1 failed, 18 passed, mit assert 401 == 429.
Fragen, bevor du prüfst
login_wait(state, credentials) läuft vor login_user. Es gibt 0 zurück oder die Sekunden, die der Client warten muss. Wenn es Warten sagt, antwortet der Anmeldezweig mit 429 und sieht sich das Passwort gar nicht an, auch wenn das Passwort richtig ist. Ein Limit, das den richtigen Versuch durchlässt, begrenzt das Raten nicht: Der Ratende würde einfach erfahren, dass der Versuch richtig war.
Es bedeutet auch, dass ein abgelehnter Versuch deinen Server keinen scrypt-Aufruf kostet.
Warum fragt der Server den Begrenzer, bevor er das Passwort prüft?
Dasselbe für einen Namen, den es nicht gibt
Der Schlüssel ist "user:" plus der gesendete Name, egal ob dieser Name ein Benutzer ist oder nicht. Wenn nur echte Namen gezählt würden, würden nur echte Namen je mit 429 beantwortet. Das Limit würde einem Fremden dann verraten, welche Namen existieren, was Kapitel 3 zu verbergen versucht hat.
Eine Anfrage ohne Zugangsdaten testet kein Passwort. Sie wird nicht gezählt, und login_wait gibt für sie 0 zurück.
Warum Versuche für einen Namen zählen, der kein Benutzer ist?
Ein zweites, viel höheres Limit
Ein Passwort, das gegen 300 verschiedene Namen ausprobiert wird, löst nie einen Zähler pro Name aus. Deshalb zählt ein zweiter Schlüssel, "all", jeden Versuch, den das Limit pro Name durchgelassen hat, gegen GLOBAL_LIMIT.
Die Reihenfolge zählt. Frag zuerst das eigene Limit des Namens und das globale Limit nur, wenn das erste den Versuch erlaubt hat. Dann wird ein Client, der auf einen Namen einhämmert, vom Limit dieses Namens gestoppt und verbraucht nicht den Anteil aller anderen.
Wenn das globale Limit ablehnt, wurde der Versuch schon unter dem Namen festgehalten. Nimm ihn mit dem mitgelieferten limits.forget_last(state["attempts"], key) zurück. Ein abgelehnter Versuch wird nie festgehalten, unter keinem der beiden Schlüssel. Ohne diese Zeile würde jemand, der Namen streut, die Tabelle mit Namen füllen und die fünf eigenen Versuche jedes echten Benutzers verbrauchen, während das globale Limit voll ist.
Das Präfix user: hält die beiden Arten von Schlüsseln auseinander. Ohne es würde ein Benutzer, der all heißt, einen Zähler mit allen teilen.
Die Antwort
Das mitgelieferte too_many(wait) baut die Ablehnung: Status 429, einen retry-after auf die Wartezeit gesetzt. Die Regel am Ende von route fügt no-store hinzu, wie bei jeder Antwort von /login.
Entferne nach einem korrekten Passwort den Schlüssel dieses Benutzers mit state["attempts"].pop("user:" + user, None). Nichts anderes setzt eine Zählung zurück. Ein fehlgeschlagener Versuch darf sie nie zurücksetzen.
Was ein Limit kostet
Diese Abwehr hat einen Preis, und du solltest ihn benennen können.
Ein Limit pro Name lässt jeden ana aussperren. Er sendet falsche Passwörter unter ihrem Namen, und dieser Server kann diesen Absender nicht von ana unterscheiden.
Das globale Limit begrenzt, wie schnell irgendjemand über alle Namen hinweg raten kann. Ein einzelner Client kann es erreichen: 300 Versuche in 300 Sekunden sind eine Anfrage pro Sekunde. Sobald es voll ist, ist jeder Benutzer für ein Fenster ausgesperrt. Eine niedrige Zahl macht das billig. Eine höhere Zahl erhöht nur die Kosten für diesen Client und beseitigt sie nie.
Was diesen Client von allen anderen unterscheiden würde, ist etwas, das dieses Anfrage-
Und das Limit hält einen geduldigen Ratenden nicht auf. Fünf Versuche pro fünf Minuten sind 1440 am Tag für einen Namen. Das Limit verwandelt schnelles Raten in langsames Raten, und die nächste Lektion macht es sichtbar.
Was kann ein Fremder ana mit dem Limit pro Name antun, ohne ihr Passwort zu kennen?
GLOBAL_LIMIT beträgt 300 Versuche in 300 Sekunden. Was kann ein einzelner Client damit tun?
Deine Aufgabe
Schreib
test_attack_guessing_is_limited()und sieh zu, wie er fehlschlägt.Schreib
login_wait(state, credentials): Keine Zugangsdaten bedeuten 0; frag andernfallslimits.allow_attemptfür den Schlüssel des Benutzers, dann für"all", und nimm den ersten Versuch zurück, wenn die zweite Antwort ablehnt.Ruf im Anmeldezweig
login_waitvorlogin_userauf, antworte mittoo_many(wait), wenn die Wartezeit über 0 liegt, und entferne nach einem korrekten Passwort den Schlüssel des Benutzers.Fülle die Zeile F9 von
SECURITY.mdaus, nenne deinen Test und setz ihren Status aufclosed in 6.4.
Führ python -m pytest -q aus: 19 passed. Führ dann noch einmal python pl_sec.py guessing aus. Die ersten fünf falschen Passwörter bekommen weiterhin 401. Die sechste Zeile lautet jetzt:
wrong password 6 POST /login -> 429 retry-after: 300
Auch anas richtiges Passwort bekommt 429, bis sich die Uhr um 300 Sekunden bewegt hat, und der Lauf endet mit the limit answered 429 after 5 attempts; Retry-After: 300.
Dein Server lehnt jetzt auf vier Arten ab: 401, 403, 404 und 429. Er schreibt nichts davon auf.
Aufgabe
Setz den Begrenzer vor die Anmeldung.
Schreib in
tests/test_attacks.pytest_attack_guessing_is_limited(). Sende mitstate = steps.keyed_state()reading_server.USER_LIMIT-malPOST /loginfür ana mit einem falschen Passwort ({"Authorization": helpers.basic("ana", "a guess")}als Header) und sichere jedes Mal den Status 401 zu. Sende es noch einmal und sichere den Status 429 zu und dass"retry-after"in den Headern der Antwort steht. Führpython -m pytest -qaus: Dieser Test muss zuerst fehlschlagen.Schreib in
reading_server.pylogin_wait(state, credentials). Ohne Zugangsdaten gib 0 zurück. Liesnowausstate["clock"](). Der Schlüssel des Benutzers ist"user:" + credentials["user"]. Fraglimits.allow_attempt(state["attempts"], key, now, limit=USER_LIMIT, window=WINDOW). Wenn das 0 zurückgibt, frag noch einmal mit dem Schlüssel"all"undlimit=GLOBAL_LIMIT; wenn diese zweite Antwort über 0 liegt, nimm den ersten Versuch mitlimits.forget_last(state["attempts"], key)zurück. Gib die Wartezeit zurück.Im Anmeldezweig von
route: Hol die Zugangsdaten, ruflogin_waitauf und antworte mittoo_many(wait), wenn die Wartezeit über 0 liegt, ohnelogin_useraufzurufen;too_manyist mitgeliefert. Entferne nach einem korrekten Passwort den Schlüssel dieses Benutzers mitstate["attempts"].pop("user:" + user, None).Fülle die Zeile F9 von
SECURITY.mdaus, nenne deinen Test in ihrer Zelle Test und setz den Status aufclosed in 6.4.
Führ python -m pytest -q aus, bis es mit 19 passed endet, und klicke dann auf Check my work. Es setzt USER_LIMIT, GLOBAL_LIMIT und WINDOW auf eigene, kleinere Zahlen und zählt Antworten unter einer Uhr, die es steuert: falsche Passwörter bis zum Limit und eins mehr, das richtige Passwort während der Wartezeit und nach dem Fenster, ein Name, den es nicht gibt, neben einem, den es gibt, die Zählung nach einer korrekten Anmeldung, Anfragen ohne Zugangsdaten, ein Name, der viele Male versucht wird, ein Passwort, das unter vielen Namen versucht wird, und ein Benutzer, dessen Name all ist. Es zählt scrypt-Aufrufe, um zu sehen, dass ein abgelehnter Versuch kein Passwort prüft; es misst keine Zeit. Es führt deinen Test viermal aus, gegen Server, die es an die Stelle von reading_server.handle und reading_server.sample_state setzt, jeder mit einem eigenen reading_server.USER_LIMIT: einen korrekten, wo dein Test durchlaufen muss; einen, der eine Anmeldung nie begrenzt, einen, dessen 429 kein Retry-After hat, und einen, der jede Anfrage ablehnt, wo er fehlschlagen muss. Es liest die Zeile F9 von SECURITY.md. Nichts aus deinem