0%

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 aus:

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 , als 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 -Fehler, der kein Konto nennt, und 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- nicht sehen kann: eine Adresse, ein Gerät, ein zweiter Faktor. Ein echter Dienst verwendet diese, zusammen mit einer Warnung, die einen Menschen benachrichtigt.

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

  1. Schreib test_attack_guessing_is_limited() und sieh zu, wie er fehlschlägt.

  2. Schreib login_wait(state, credentials): Keine Zugangsdaten bedeuten 0; frag andernfalls limits.allow_attempt für den Schlüssel des Benutzers, dann für "all", und nimm den ersten Versuch zurück, wenn die zweite Antwort ablehnt.

  3. Ruf im Anmeldezweig login_wait vor login_user auf, antworte mit too_many(wait), wenn die Wartezeit über 0 liegt, und entferne nach einem korrekten Passwort den Schlüssel des Benutzers.

  4. Fülle die Zeile F9 von SECURITY.md aus, nenne deinen Test und setz ihren Status auf closed 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.

  1. Schreib in tests/test_attacks.py test_attack_guessing_is_limited(). Sende mit state = steps.keyed_state() reading_server.USER_LIMIT-mal POST /login fü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ühr python -m pytest -q aus: Dieser Test muss zuerst fehlschlagen.

  2. Schreib in reading_server.py login_wait(state, credentials). Ohne Zugangsdaten gib 0 zurück. Lies now aus state["clock"](). Der Schlüssel des Benutzers ist "user:" + credentials["user"]. Frag limits.allow_attempt(state["attempts"], key, now, limit=USER_LIMIT, window=WINDOW). Wenn das 0 zurückgibt, frag noch einmal mit dem Schlüssel "all" und limit=GLOBAL_LIMIT; wenn diese zweite Antwort über 0 liegt, nimm den ersten Versuch mit limits.forget_last(state["attempts"], key) zurück. Gib die Wartezeit zurück.

  3. Im Anmeldezweig von route: Hol die Zugangsdaten, ruf login_wait auf und antworte mit too_many(wait), wenn die Wartezeit über 0 liegt, ohne login_user aufzurufen; too_many ist mitgeliefert. Entferne nach einem korrekten Passwort den Schlüssel dieses Benutzers mit state["attempts"].pop("user:" + user, None).

  4. Fülle die Zeile F9 von SECURITY.md aus, nenne deinen Test in ihrer Zelle Test und setz den Status auf closed 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 für 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 wird bewertet.