0%

Wer was darf, und wie oft · Übung

Jedes Buch hat einen Eigentümer

Lektion 1 hat vorhergesagt, dass ben anas Buch löschen kann. Sieh zu, wie dein Server es tut. Führ im aus:

python pl_sec.py other-reader

pl_sec.py startet deinen Server auf 127.0.0.1 und sendet ihm sechs Anfragen:

pl_sec: other-reader, your server on 127.0.0.1 (a port the system chose)
pl_sec sends the __Host-session cookie by hand; a browser would not send it over plain HTTP
--- ben gets a token of his own ---
ben logs in with a password         POST /login   -> 200
ben asks for a token, with the cookie  POST /tokens  -> 200
--- what ben can reach with it ---
ben lists the books             GET /books      -> 200  {"books": [{"id": 1, "title": "Kindred", "author": "Octavia E. Butler"}, {"id": 2, "title": "Café Society", "author": "Ana Ruiz"}]}
ben deletes book 1, ana's       DELETE /books/1 -> 204
ben reads book 1                GET /books/1    -> 404  {"error": "no book has that id"}
a request with no token         GET /books      -> 200  {"books": [{"id": 2, "title": "Café Society", "author": "Ana Ruiz"}]}
--- result ---
ben deleted ana's book: 204
serve() returned after 6 connections; the server is closed
pl_sec: every thread has stopped

Jeder Berechtigungsnachweis in diesem Lauf ist echt. ben hat sich mit seinem eigenen Passwort angemeldet und sein eigenes Token benutzt. Das ist Befund F8.

Schreib zuerst den Angriff

Der Test wiederholt bens Anfrage. steps.keyed_state() liefert einen Beispielzustand mit dem Testschlüssel darin, und steps.bearer_for(state, "ben", "battery staple") meldet ben an, fragt POST /tokens nach einem Token und gibt den für Authorization zurück.

Lass ben zuerst ein eigenes Buch hinzufügen und sichere 201 zu. Das ist die Vorbedingung: Der Test betrifft einen Schreiber, dessen Token funktioniert. Sende dann DELETE /books/1 mit demselben Token und sichere zu, dass der Status 404 ist und dass 1 noch in state["books"] steht.

Führ python -m pytest -q aus. Es endet mit 1 failed, 17 passed, und der Fehlschlag lautet assert 204 == 404.

Ein Eigentümer, den der Client nicht wählen kann

Ein Buch braucht einen Eigentümer, bevor eine Regel nach einem fragen kann. Drei Stellen setzen ihn.

Gib in sample_state() beiden Büchern "owner": "ana".

create_book bekommt einen dritten , owner, und speichert ihn im neuen Buch. route übergibt claims["user"]. Dieser Name kommt aus einem Token, das dein Server signiert hat, also kann der Client ihn nicht wählen. Ein Body, der "owner": "ana" sagt, ändert nichts, weil create_book weiterhin nur title und author aus dem Body liest.

replace_book behält den Eigentümer, den das gespeicherte Buch schon hat. Kapitel 7 geht einen Schritt weiter und lehnt einen Body ab, der Felder trägt, nach denen niemand gefragt hat.

Eine Regel, zwei Fragen

may_view(user, book) entscheidet über jeden Lesezugriff und may_change(user, book) über jeden Schreibzugriff. Heute sind beide derselbe Satz: Der Eigentümer des Buchs ist dieser Benutzer. Es sind zwei , weil es zwei Fragen sind. An dem Tag, an dem man ein Buch mit einer Freundin teilen kann, ändert sich nur eine davon.

Beide geben False zurück, wenn user None ist oder book None ist, bevor sie irgendetwas vergleichen. Das schließt die Falle None == None aus Lektion 1.

Ablehnen, sofern keine Regel erlaubt

Schreib jetzt item_refusal neu. Die mitgelieferte Version listet auf, was abgelehnt wird, und erlaubt am Ende den Rest. Dreh das um:

  • Ohne Claims gib unauthorized("Bearer") zurück, für jede .

  • GET und HEAD geben None zurück, wenn may_view es erlaubt.

  • PUT und DELETE geben die 403 zurück, wenn das Konto überhaupt nicht schreiben darf, und None, wenn may_change es erlaubt.

  • Jede andere Methode gibt not_allowed("GET, HEAD, PUT, DELETE") zurück, aber nur für ein Buch, das der Aufrufer sehen darf.

  • Die letzte Zeile gibt json_error(404, "no book has that id") zurück.

return None steht jetzt nur noch innerhalb einer Regel. Alles, was keine Regel erlaubt hat, und jeder Fall, an den niemand gedacht hat, endet in der letzten Zeile. Diese Form heißt standardmäßig verweigern (deny by default).

Drei Ablehnungen stehen jetzt nebeneinander. 403 ist für ein Konto, das überhaupt nicht schreiben darf, wie in with Python. 404 ist für ein Buch, das nicht dem Aufrufer gehört, ob es existiert oder nicht. 405 gibt es nur für ein Buch, das der Aufrufer sehen darf, denn eine 405 für das Buch eines anderen würde bestätigen, dass die ID existiert.

Nächstes Jahr lässt jemand PATCH bis zu item_refusal durch und schreibt keine Regel dafür. Was bekommt ein PATCH von ben auf anas Buch mit der Funktion, die du geschrieben hast?

Lesezugriffe sind jetzt persönlich

collection_refusal bekommt dieselbe erste Zeile: keine Claims, kein Zugriff, für jede Methode. /books zu lesen braucht jetzt ein Token, und die Listenschleife behält nur die Bücher, die may_view erlaubt. Das ist eine bewusste Änderung gegenüber HTTP with Python, wo die für alle offen war.

Eine Antwort, die davon abhängt, wer gefragt hat, darf nicht aus einem Cache an jemand anderen ausgeliefert werden. Das Projekt setzt bei beiden Lesezugriffen auf Bücher bereits Cache-Control: private, no-cache. private bedeutet, dass ein gemeinsamer Cache die Antwort nicht speichern darf. no-cache bedeutet, was es in HTTP with Python bedeutet hat: Du darfst sie behalten, aber frag mich, bevor du sie benutzt.

Das zweite Wort ist nötig, weil der eigene Cache eines Browsers eine gespeicherte Antwort über ihre findet, nicht über das Token. Mit einem max-age könnte eine Liste, die für einen Leser gespeichert wurde, eine Minute lang für eine Anfrage angezeigt werden, die das Token eines anderen Lesers trägt. Mit no-cache fragt der Browser mit If-None-Match erneut, und der Server, der das Token sieht, entscheidet jedes Mal: 304, wenn die eigene Liste des Aufrufers unverändert ist, sonst 200 mit der Liste des Aufrufers.

Ob ein Cache gehorcht, ist Sache des Caches. Der Teil deines Servers ist, es zu sagen. GET /about bleibt öffentlich und behält max-age=60.

ben fragt nach /books/1, das ana gehört, und nach /books/4100, das nicht existiert. Warum sollten die beiden Antworten gleich sein?

Was verlangt Cache-Control: private, no-cache?

Deine Aufgabe

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

  2. Gib beiden Beispielbüchern einen Eigentümer, gib create_book seinen Parameter owner und behalte in replace_book den gespeicherten Eigentümer.

  3. Schreib may_view und may_change.

  4. Schreib collection_refusal und item_refusal so um, dass None nur unter einer Regel zurückgegeben wird.

  5. Behalte in der Listenschleife in route nur die Bücher, die may_view(claims["user"], book) erlaubt.

  6. Fülle die Zeile F8 von SECURITY.md aus, nenne darin deinen Test und setz ihren Status auf closed in 6.2.

Führ python -m pytest -q noch einmal aus: 18 passed. Führ dann noch einmal python pl_sec.py other-reader aus. bens Liste ist leer, sein Löschen und sein Lesen von Buch 1 bekommen beide 404, die Anfrage ohne Token bekommt 401, und der Lauf endet mit ben was refused: 404, and book 1 is still there.

Eines bleibt sichtbar. Buch-IDs zählen weiterhin für alle aufwärts, also zeigt die ID, die ein neues Buch bekommt, wie viele Bücher es insgesamt gibt. Und die Anmeldung, die ben sein Token gegeben hat, kann immer noch so oft versucht werden, wie jemand will. Das sind die nächsten zwei Lektionen.

Aufgabe

Gib jedem Buch einen Eigentümer und sorg dafür, dass die Zugriffsentscheidung ablehnt, sofern keine Regel erlaubt.

  1. Schreib in tests/test_attacks.py test_attack_another_reader_cannot_delete_a_book(). Hol dir mit state = steps.keyed_state() bearer = steps.bearer_for(state, "ben", "battery staple"). Sende POST /books mit einem gültigen Buch und bens Token im Header Authorization und sichere den Status 201 zu. Sende DELETE /books/1 mit bens Token. Sichere den Status 404 zu und dass 1 noch in state["books"] steht. Führ python -m pytest -q aus: Dieser Test muss zuerst fehlschlagen.

  2. Gib in reading_server.py in sample_state() beiden Büchern "owner": "ana".

  3. Gib create_book einen dritten , owner, speichere ihn im Buch und übergib claims["user"] dort, wo route es aufruft. Behalte in replace_book den Eigentümer, den das gespeicherte Buch schon hat.

  4. Schreib may_view(user, book) und may_change(user, book): False, wenn user None ist oder book None ist; andernfalls, ob book.get("owner") gleich user ist.

  5. Schreib collection_refusal(method, claims) neu: Keine Claims bedeuten unauthorized("Bearer") für jede ; GET und HEAD sind erlaubt; POST ist erlaubt, wenn claims["can_write"], und sonst eine 403; alles andere ist not_allowed("GET, HEAD, POST").

  6. Schreib item_refusal(method, claims, book) so um, dass es None nur zurückgibt, wenn eine Regel erlaubt: Keine Claims bedeuten 401; GET und HEAD brauchen may_view; PUT und DELETE antworten mit 403, wenn das Konto nicht schreiben darf, und brauchen may_change; jede andere Methode auf einem Buch, das der Aufrufer sehen darf, ist not_allowed("GET, HEAD, PUT, DELETE"); die letzte Zeile gibt json_error(404, "no book has that id") zurück.

  7. Behalte in der Listenschleife in route nur die Bücher, die may_view(claims["user"], book) erlaubt. Lass die zwei Zeilen mit PRIVATE_CACHE, wie sie sind.

  8. Fülle die Zeile F8 von SECURITY.md aus, nenne deinen Test in ihrer Zelle Test und setz den Status auf closed in 6.2.

Führ python -m pytest -q aus, bis es mit 18 passed endet, und klicke dann auf Check my work. Es ruft may_view, may_change, collection_refusal und item_refusal mit eigenen Benutzern und Büchern auf. Es sendet Anfragen über reading_server.handle(...) mit Tokens, die es selbst signiert: die eines Lesers und ihr Cache-Control, das Buch eines anderen Lesers mit jeder Methode, ein Konto, das nur lesen darf, und Bodys, die einen Eigentümer nennen. Es führt deinen Test dreimal aus, gegen Server, die es an die Stelle von reading_server.handle und reading_server.sample_state setzt: einen korrekten, wo dein Test durchlaufen muss; einen, dessen Schreibzugriffe nicht gegen den Eigentümer geprüft werden, wo er fehlschlagen muss; und einen, der jede Anfrage ablehnt, wo er ebenfalls fehlschlagen muss. Es liest die Zeile F8 von SECURITY.md; die Formulierung einer Zelle wird nicht bewertet. Nichts aus deinem wird bewertet.