Kapitel 7 · Übung
Eingaben, die zu Code werden
Nimm nur an, was du erwartest
Die Leseliste ist für dieses Kapitel gewachsen. Ein Buch kann jetzt drei weitere Felder haben: das Jahr, in dem es erschienen ist, wie weit du damit bist, und eine
{
"title": "Dune",
"author": "Frank Herbert",
"year": 1965,
"status": "reading",
"tags": ["desert", "classic"]
}
Um solche Felder zu speichern, hat sich auch create_book in reading_server.py geändert. Dieser Code ist neu und kam mit dem Projekt dieses Kapitels. Du hast ihn nicht geschrieben:
def create_book(request, state, owner):
"""Store the body as a new book owned by owner and return the 201 response."""
data = json_body(request)
book_id = state["next_id"]
state["next_id"] += 1
book = {"id": book_id, "owner": owner}
for key in data:
book[key] = data[key]
state["books"][book_id] = book
response = json_response(201, book)
response["headers"]["location"] = f"/books/{book_id}"
return response
Die id und owner, und die Schleife läuft danach, also ersetzt ein Schlüssel im Body, was der Server gewählt hat. replace_book, das PUT bedient, hat dieselbe Schleife.
Schreib zuerst den Angriff
Kapitel 6 hat den Eigentümer zu der einen Tatsache gemacht, gegen die jedes Lesen und jedes Schreiben geprüft wird. Also versuch, ihn zu wählen. Ben hat ein Konto, das schreiben darf, und ein eigenes Token. Er legt ein Buch an und fügt dem Body "owner": "ana" hinzu.
Schreib das als Test, test_attack_body_cannot_set_the_owner, unter den Kommentar für Kapitel 7 in tests/test_attacks.py:
Baue den Zustand mit
steps.keyed_state()und hol dir BensAuthorization-mit steps.bearer_for(state, "ben", "battery staple").Sende mit diesem Token ein ehrliches Buch und stelle sicher, dass der Status 201 ist. Ohne diesen Schritt würde ein Server, der jedes Buch ablehnt, deinen Test bestehen.
Sende
{"title": "Planted", "author": "Ben", "owner": "ana"}mit demselben Token. Stelle sicher, dass der Status 422 ist und dass kein Buch instate["books"]den TitelPlantedhat.
Dann führe ihn aus. Die Option -k owner wählt die Tests aus, die owner im Namen haben:
python -m pytest -q -k owner
Der Lauf endet mit 1 failed, 21 deselected, und das ist die Zeile, die fehlgeschlagen ist:
E assert 201 == 422
Der Server hat mit 201 geantwortet und das Buch mit "owner": "ana" gespeichert. Es taucht jetzt in Anas Liste auf, wenn sie GET /books anfragt. Ben, der es angelegt hat, bekommt dafür 404. Dieselbe Schleife lässt einen Body die id wählen, die ein Buch zeigt, oder ein Feld hinzufügen, das niemand geplant hat, etwa "is_admin": true.
Diese Schwachstelle heißt Mass Assignment: Der Server weist einem Datensatz in einem Schritt einen ganzen Body zu, und so bestimmt der Client Felder, die er nie bestimmen durfte.
Der Angriff braucht ein gültiges Token, das schreiben darf: Ben ist ein Benutzer des Dienstes, kein Fremder. In deinem Arbeitsbereich arbeitet sonst niemand, also spielt der Test beide Rollen.
Eine Liste dessen, was abgelehnt wird, vergisst immer etwas
Die erste Lösung, die einem einfällt, nennt den Schlüssel, der das Problem verursacht hat:
if "owner" in body:
raise ValueError("owner is set by the server")
Das stoppt den Angriff des Tests und lässt id offen. Eine Liste dessen, was abgelehnt wird, heißt Denylist. Sie lässt alles durch, was sie nicht nennt, und dazu gehören auch Felder, die es noch gar nicht gibt.
Ein Server lehnt einen Body ab, der owner enthält. Nächsten Monat fügt eine Entwicklerin den Büchern ein Feld is_admin hinzu. Was passiert mit einem Body, der es setzt?
Eine Liste dessen, was angenommen wird
Dreh die Liste um. Eine Allowlist nennt, was angenommen wird, und alles andere wird abgelehnt. Dein Projekt hat eine, als Set:
ALLOWED_FIELDS = {"title", "author", "year", "status", "tags"}
Der Test lautet key not in ALLOWED_FIELDS. Ein Feld, das Büchern später hinzugefügt wird, bleibt aus Anfragen draußen, bis jemand entscheidet, es hereinzulassen.
validate_book(body) wendet die Liste an. Für einen Body, der kein ValueError("the body must be a JSON object") aus. Dann sieht es sich jeden Schlüssel des Bodys an, in der Reihenfolge des Bodys:
für
owneroderidlöst esValueError(f"{key} is set by the server")aus;für jeden anderen Schlüssel, der nicht in
ALLOWED_FIELDSsteht, löst esValueError(f"{key} is not a field of a book")aus.
Wenn kein Schlüssel abgelehnt wurde, baut es ein neues Dict, kopiert die Schlüssel und Werte des Bodys hinein und gibt das neue Dict zurück. Es gibt nie den Body selbst zurück, also kann ein Schlüssel, den der Validator nicht gelesen hat, das Buch nicht erreichen.
Diese Verteidigung hast du schon gesehen. In
Das Modell hat diese Felder stillschweigend verworfen. Deine extra="forbid".
Ein unbekanntes Feld zu verwerfen und es abzulehnen hält es beides aus dem Buch heraus. Was bringt das Ablehnen zusätzlich?
Deine Aufgabe
Der Test ist geschrieben und rot. Jetzt, in reading_server.py:
Schreib
validate_book(body)wie oben beschrieben.Rufe in
book_request_error(request)nach den vorhandenen Prüfungenvalidate_book(json_body(request))innerhalb vontryauf. Wenn esauslöst, ist der zurückzugebende Fehler json_error(422, str(problem)). Lassbook_errorstehen: Es meldet weiterhin einen fehlenden Titel.Lass die Schleife in
create_bookund inreplace_bookstatt über den Body übervalidate_book(json_body(request))laufen.
Die zweite Änderung bedient POST und PUT gleichermaßen, also werden in dieser Lektion beide Schreib-Routen geschlossen. Der Status folgt HTTP with Python, Kapitel 5, Lektion 5. Eine 400 sagt, dass der Body nicht gelesen werden konnte, und dieser wurde gelesen. Eine 415 sagt, dass sein Medientyp falsch ist, und dieser ist als application/json gekennzeichnet. Eine 403 sagt, dass das Konto nicht schreiben darf, und Bens Konto darf. Der Body wurde verstanden, und eine Sache darin ist nicht annehmbar: Das ist 422.
Ein Body ist gültiges JSON, richtig gekennzeichnet und enthält "owner": "ana". Welcher Status passt?
Führe python -m pytest -q -k owner noch einmal aus: 1 passed, 21 deselected. Ohne -k owner endet die ganze Datei mit 22 passed.
Diese Lektion ändert absichtlich einen Vertrag. In HTTP with Python wurde ein Buch-Body mit einem zusätzlichen Feld angenommen und das Feld ignoriert. Von jetzt an bekommt ein solcher Body eine 422, die das Feld nennt.
Klicke dann auf Check my work. Jetzt kommen die richtigen Schlüssel an, und sonst nichts. Was unter diesen Schlüsseln steht, ist noch ungeprüft: ein Titel beliebiger Länge, ein Jahr beliebigen Typs. Darum geht es in der nächsten Lektion.
Aufgabe
Verhindere, dass ein Anfrage-Body Felder setzt, die dem Server gehören, und beweise es mit einem Test.
Schreib in tests/test_attacks.py unter dem Kommentar für Kapitel 7 test_attack_body_cannot_set_the_owner:
baue den Zustand mit
steps.keyed_state()und hol dir BensAuthorization-mit steps.bearer_for(state, "ben", "battery staple");sende ein ehrliches Buch, etwa
{"title": "Dune", "author": "Frank Herbert"}, überreading_server.handle(helpers.make_request("POST", "/books", book, headers), state)und stelle sicher, dass der Status 201 ist;sende
{"title": "Planted", "author": "Ben", "owner": "ana"}mit demselben Token und stelle sicher, dass der Status 422 ist und dass kein Buch instate["books"]den TitelPlantedhat.
Führe python -m pytest -q -k owner im
In reading_server.py:
validate_book(body)löstValueError("the body must be a JSON object")aus, wennbodykeinist. Für einen Schlüssel owneroderidlöst esValueError(f"{key} is set by the server")aus. Für jeden anderen Schlüssel, der nicht inALLOWED_FIELDSsteht, löst esValueError(f"{key} is not a field of a book")aus. Andernfalls gibt es ein neues Dict mit den Schlüsseln und Werten des Bodys zurück. Es gibt den Body selbst nie zurück und verändert ihn nie.book_request_error(request)ruft nach seinen vorhandenen Prüfungenvalidate_book(json_body(request))innerhalb vontryauf und gibtjson_error(422, str(problem))zurück, wenn der Aufrufauslöst. create_bookundreplace_booklassen ihrestatt über den Body über validate_book(json_body(request))laufen.
Führe den Test noch einmal aus und sieh, dass er besteht.
Klicke dann auf Check my work. Es ruft validate_book() mit Bodys auf, die du noch nicht gesehen hast: eine None; Bodys aus erlaubten Schlüsseln, die gleich und als neues Dict zurückkommen müssen; Schlüssel, die nicht erlaubt sind, darunter einer, der sich von einem erlaubten Schlüssel nur durch einen Großbuchstaben oder ein Leerzeichen unterscheidet; und owner und id. Jede Ablehnung muss ein ValueError sein, dessen Meldung den Schlüssel nennt. Danach ruft es handle() mit eigenen Benutzern, Büchern und Tokens auf: Ein POST und ein PUT, deren Bodys owner, id oder einen unbekannten Schlüssel setzen, müssen 422 bekommen und nichts verändern, und saubere müssen weiterhin 201 und 200 bekommen, mit der id des Servers und dem Benutzer des Tokens als owner. Zuletzt führt es deinen Test über seinen Namen gegen vier eigene Server aus. Er muss gegen einen bestehen, der ablehnt, was nicht erlaubt ist, und fehlschlagen gegen einen, der den ganzen Body kopiert, einen, der nur id ablehnt, und einen, der jedes Buch mit 422 beantwortet. Nichts aus deinem Terminal wird bewertet.