0%

Eingaben, die zu Code werden · Abschlussprojekt

Abschlussprojekt: Projekt: Nichts kommt ungeprüft durch

Dieses Kapitel hat eine Regel in zwei Hälften. Prüfe einen Body auf dem Weg hinein, mit einer Allowlist und Grenzen. Schütze jede Stelle, an der Text hinausgeht in etwas, das geparst wird: einen Header, eine , einen Dateipfad, eine Seite. Keine Hälfte steht für die andere ein. <b>Dune</b> ist ein gültiger Titel, und er muss in der Seite trotzdem escapet werden.

Der Validator und die vier Schutzmaßnahmen sind geschrieben. Zwei der fünf haben einen Angriffstest. Dieses Projekt schreibt die anderen drei Tests, fügt eine Schutzmaßnahme für alles hinzu, was handle() verlässt, und schließt zwei Befunde.

Drei weitere Angriffstests

Jeder Test führt einen Angriff aus und stellt sicher, dass er abgelehnt wird, wie in den Lektionen 1 und 4. Jeder stellt außerdem sicher, dass eine ehrliche Anfrage funktioniert, damit er nicht gegen einen Server bestehen kann, auf dem nichts funktioniert.

test_attack_next_cannot_leave_the_site. Lass eine über drei von next laufen: %2F%2Fother.example%2Fwelcome, %2F%5Cother.example und https:%2F%2Fother.example%2F. Melde für jeden davon ana mit einem frischen Zustand an und stelle sicher, dass der Status 303 ist und dass location genau / ist. Die 303 ist die ehrliche Hälfte: Das Login hat funktioniert.

test_attack_cover_name_cannot_leave_the_folder. Dieser Test bringt seinen eigenen Ordner mit. Pytest gibt jeder Testfunktion, die einen namens tmp_path hat, einen frischen temporären Ordner als Path. Fang so an:

def test_attack_cover_name_cannot_leave_the_folder(tmp_path):
    """A cover name with an encoded ../ in it must not reach a file outside the covers folder."""
    covers = tmp_path / "covers"
    covers.mkdir()
    (covers / "kindred.txt").write_text("a cover", encoding="utf-8")
    (tmp_path / "secret.txt").write_text("not a cover", encoding="utf-8")
    state = reading_server.sample_state()
    state["covers_dir"] = covers

Stelle dann 200 für GET /covers/kindred.txt sicher. Stelle 404 für GET /covers/..%2fsecret.txt sicher, und dass b"not a cover" nicht in diesem Body steht.

test_attack_title_cannot_become_markup. Lege mit steps.keyed_state() und einem Token für ana ein Buch mit dem Titel <b>Dune</b> an und stelle 201 sicher. Hol dir ihr mit steps.login_cookie(state, "ana", "correct horse"), sende damit GET / in einem Cookie-Header und stelle 200 sicher. Dekodiere den Body und stelle sicher, dass "<b>" nicht im Text steht und "&lt;b&gt;Dune&lt;/b&gt;" schon.

Alle drei bestehen, sobald du sie schreibst, weil die Schutzmaßnahmen schon da sind. Das beweist wenig. Sieh jeden Test einmal fehlschlagen: Schalte seine Schutzmaßnahme von Hand ab, führe python -m pytest -q aus und schalte sie wieder ein.

  • Setze return value als erste Zeile in safe_redirect_target.

  • Setze return pathlib.Path(folder) / name als erste Zeile in safe_cover_path.

  • Ändere in render_page html.escape(book["title"]) zu book["title"].

Jedes Mal endet der Lauf mit einem Fehlschlag, und es ist der Test für diese Schutzmaßnahme.

Check my work macht dasselbe gründlicher. Es führt jeden deiner fünf Tests gegen einen eigenen Server aus, der alle Schutzmaßnahmen hat, wo der Test bestehen muss. Dann führt es den Test gegen einen Server aus, dem genau diese eine Schutzmaßnahme fehlt, wo der Test fehlschlagen muss.

Dein Test für die Cover-Route besteht. Check my work setzt einen Server ein, dessen Cover-Route nichts prüft, und dein Test besteht immer noch. Was sagt dir das?

Eine Schutzmaßnahme für alles, was hinausgeht

redirect() schützt einen Header. Dein Server schreibt weitere, etwa etag, allow und set-cookie, und nächsten Monat baut ein neuer Zweig vielleicht einen aus etwas, das eine Anfrage gesendet hat. Jede Antwort läuft durch handle(), also deckt eine Schleife dort sie alle ab:

    try:
        for name in response["headers"]:
            values = response["headers"][name]
            if isinstance(values, str):
                values = [values]
            for value in values:
                header_value(value)
    except (ValueError, TypeError):

Ein Header-Wert ist ein Text oder eine von Texten, und http_adapter schreibt jedes Element einer Liste als eigene Zeile. Also macht die Schleife aus einem einzelnen Text eine Liste mit einem Element und prüft jedes Element. Das except nennt auch : Ein Zweig, der eine Zahl schreibt, etwa 0, lässt header_value diesen Fehler auslösen, und dieser Fehler sollte in derselben festen 500 enden.

Den except-Zweig schreibst du. Er ersetzt die Antwort durch die feste 500 aus Kapitel 6: json_error(500, ...) mit derselben Meldung, cache-control auf no-store gesetzt und ein leerer Body, wenn die HEAD ist. Setze den ganzen Block hinter das try, das route() aufruft, und vor die Log-Zeile, damit das Log den Status zeigt, der gesendet wurde.

Das ist „fail closed“, die Regel aus Kapitel 5, angewendet auf die Ausgabe. Ein Header-Fehler in irgendeinem Zweig wird zu einem Fehler, den du im Log sehen kannst, und nicht zu einer Antwort mit einer Zeile, die jemand anderes geschrieben hat. Die Schleife prüft nur Header. Einen Body prüft der Code, der ihn schreibt, so wie render_page es tut.

Meilensteine

  1. Die drei Tests, jeder einmal fehlschlagend gesehen, mit abgeschalteter Schutzmaßnahme. Mit allen Schutzmaßnahmen zurück endet python -m pytest -q mit 26 passed.

  2. Die Schleife in handle(). Die Tests enden weiterhin mit 26 passed.

  3. Das ganze Szenario:

python pl_sec.py injection

Lies alle vier Teile. Das Ergebnis lautet:

--- result ---
0 of 4 attacks worked
  1. Die Befunde. Fülle in SECURITY.md die Zeilen F10 und F11 mit deinen eigenen Worten aus. F10 ist der Body, der den Eigentümer eines Buchs gesetzt hat, und sein Test ist test_attack_body_cannot_set_the_owner. F11 ist Anfragetext, der in einem Header, einer Weiterleitung, einem Pfad und einer Seite als Anweisungen gelesen wurde, und er nennt die anderen vier Tests. Setze beide auf closed in 7.8.

Was noch offen ist

Sieh dir das Löschformular noch einmal an. POST /books/delete wird allein durch das Session-Cookie autorisiert, und nichts in der Anfrage zeigt, dass sie von deiner Seite kam. Kapitel 8 baut ein kleines Modell eines Browsers und sendet damit dieses Formular von der Seite eines anderen aus.

Aufgabe

Schreib die letzten drei Angriffstests des Kapitels, schütze jeden Header, der handle() verlässt, und schließe zwei Befunde.

  1. Schreib in tests/test_attacks.py unter dem Kommentar für Kapitel 7 drei Tests. Erreiche den Server über reading_server.handle(...).

    • test_attack_next_cannot_leave_the_site(): Melde für jedes next aus %2F%2Fother.example%2Fwelcome, %2F%5Cother.example und https:%2F%2Fother.example%2F ana mit POST /login?next= und diesem an und stelle sicher, dass der Status 303 ist und dass response["headers"]["location"] == "/" gilt.

    • test_attack_cover_name_cannot_leave_the_folder(tmp_path): Lege tmp_path / "covers" mit einer Datei kindred.txt an, schreib secret.txt neben den Ordner, setze state["covers_dir"] auf den Ordner, stelle 200 für GET /covers/kindred.txt sicher und stelle 404 für GET /covers/..%2fsecret.txt sicher, und dass der Text des Geheimnisses nicht im Body steht.

    • test_attack_title_cannot_become_markup(): Lege mit steps.keyed_state() und steps.bearer_for(state, "ana", "correct horse") ein Buch mit dem Titel <b>Dune</b> an und stelle 201 sicher; hol GET / mit dem von steps.login_cookie(state, "ana", "correct horse") ab und stelle 200 sicher; stelle sicher, dass "<b>" nicht im Text der Seite steht und "&lt;b&gt;Dune&lt;/b&gt;" schon.

  2. Lass in handle in reading_server.py nach dem try, das route() aufruft, und vor der Log-Zeile eine über response["headers"] laufen und rufe header_value() für jeden Wert auf, innerhalb eines try. Ein Wert ist ein Text oder eine von Texten: Prüfe jedes Element einer Liste. Ersetze in except (ValueError, TypeError): die Antwort durch json_error(500, ...) mit der festen Meldung, setze ihr cache-control auf no-store und leere ihren Body, wenn die HEAD ist.

  3. Führe python -m pytest -q aus, bis es mit 26 passed endet, und führe dann python pl_sec.py injection aus.

  4. Fülle in SECURITY.md die Zeilen F10 und F11 aus. F10 nennt test_attack_body_cannot_set_the_owner. F11 nennt mindestens einen der anderen vier Tests dieses Kapitels. Beide sagen closed in 7.8.

Klicke dann auf Check my work. Es wiederholt die Prüfungen der Lektionen 1, 2 und 4 bis 7 mit neuen Werten: Bodys, Header-Werte, Weiterleitungsziele, ein eigener Ordner mit Covern, eine Seite, die mit Pythons HTML-Parser gelesen wird, und Formulare. Es setzt anstelle von reading_server.make_etag eine ein, deren Wert einen Zeilenumbruch enthält, und anstelle von reading_server.route eine Funktion, die einen Header mit einer Liste als Wert zurückgibt: handle() muss mit der festen 500 und no-store antworten, eine 500 loggen und keinen Zeilenumbruch hinauslassen, und eine gewöhnliche Anfrage muss weiterhin mit 200 antworten. Es führt deine fünf Tests dieses Kapitels, über ihren Namen ausgewählt, gegen Server aus, die es anstelle von reading_server.handle und der Funktionen einsetzt, in denen die Schutzmaßnahmen stecken: Jeder Test muss gegen einen Server mit allen Schutzmaßnahmen bestehen, gegen einen Server, dem seine eigene Schutzmaßnahme fehlt, fehlschlagen und gegen einen Server, der alles ablehnt, fehlschlagen. Kein Test darf eine Verbindung öffnen. Es liest die Zeilen F10 und F11 von SECURITY.md; ihre Formulierung wird nicht beurteilt. Nichts aus deinem wird bewertet.