Eingaben, die zu Code werden · Übung
Setz Text in eine Seite, kein Markup
Die Leseliste hat jetzt eine Seite. GET / listet die Bücher des angemeldeten Lesers als HTML auf. Der vierte Teil von python pl_sec.py injection speichert ein Buch mit dem Titel <b>Dune</b> und gibt zwei Zeilen der Seite aus:
--- a page: a book titled <b>Dune</b>, and the rows GET / writes for it ---
POST /tokens, with ana's cookie -> 200
POST /books, with the token -> 201
GET /, with ana's cookie -> 200
b'<span class="title"><b>Dune</b></span> by <span class="author">Frank Herbert</span>'
b'<button aria-label="Delete <b>Dune</b>">Delete</button>'
the title reached the page as markup: a browser would read <b> as a tag
validate_book hat diesen Titel angenommen, und das zu Recht: Der Titel ist 11 Zeichen Text. In einer
Lies die Seite einmal
Öffne page.html. Du schreibst in diesem Kurs kein HTML, und fünf Dinge reichen, um diese Datei zu lesen:
Ein Tag wie
<li>beginnt ein Element, und</li>beendet es. Was dazwischen steht, ist der Text des Elements.Ein Tag kann Attribute tragen: einen Namen, ein Gleichheitszeichen und einen
in doppelten Anführungszeichen, wie in name="id".Fünf Markierungen in geschweiften Klammern:
{user}, zweimal{title},{author}und{id}.Zwei Zeilen sind Kommentare, die ein Parser überspringt. Sie enthalten die Wörter
rowundend row. Die Zeilen dazwischen sind ein Buch, und sie werden für jedes Buch wiederholt.Das
<form>, das unten einen eigenen Abschnitt hat.
render_page() füllt die Markierungen über fill(), das str.format_map verwendet. Es liest die Vorlage einmal und liest nie einen Wert erneut, also bleibt ein Titel {author} genau dieser Text. Zwei replace()-Aufrufe, einer pro Markierung, würden diesen Titel mit dem Namen des Autors füllen.
Text, kein Markup
html.escape() ersetzt fünf Zeichen, auf die ein HTML-Parser reagieren kann. Jedes wird zu einem Code, den ein Browser als das Zeichen anzeigt und auf den er nie reagiert:
import html
print(html.escape("<b>Dune</b>"))
Es gibt <b>Dune</b> aus. Ein Browser, der das liest, zeigt <b>Dune</b> auf dem Bildschirm an und beginnt kein Element. Das kaufmännische Und wird zu &, das doppelte Anführungszeichen zu " und das einfache zu '.
Was gibt das aus?
import html
print(html.escape('<b>"Tom" & Jerry</b>'))
Escapen reicht an zwei Stellen: bei Text zwischen Tags und beim Wert eines gewöhnlichen Attributs in doppelten Anführungszeichen, etwa value oder aria-label. page.html verwendet nur diese beiden. Der Titel erscheint an beiden: zwischen den <span>-Tags und im aria-label des Buttons, einer Beschriftung für Screenreader. Innerhalb der Anführungszeichen ist das gefährliche Zeichen das Anführungszeichen selbst. Ein Titel " onclick="x würde den Wert beenden und ein eigenes Attribut beginnen. html.escape ersetzt Anführungszeichen, außer du übergibst quote=False, also übergib es nicht. Es reicht nicht für eine href, weil javascript:alert(1) keines der fünf Zeichen enthält. Es reicht nicht für ein Attribut, das ein Browser als Code liest, etwa onclick oder style, selbst in doppelten Anführungszeichen: Der Browser macht das Escapen rückgängig, bevor er den Wert ausführt. Und es reicht nicht für irgendetwas innerhalb von <script> oder für ein Attribut ohne Anführungszeichen.
Was das verhindert, ist ein Skript des Angreifers, das in einer Seite läuft, der dein Leser vertraut. Dieser Angriff heißt Content-Security-Policy ist eine zweite Verteidigungslinie, und Kapitel 10 fügt sie hinzu.
Wo reicht html.escape nicht?
Einmal, und auf dem Weg hinaus
Speichere den Titel so, wie der Client ihn gesendet hat, und escape ihn, wenn du HTML schreibst. Beim Hereinkommen zu escapen würde < in deine JSON-Antworten bringen, wo niemand diesen Namen für ein Zeichen auflöst. Und ein zweimal escapeter Wert zeigt sein eigenes Escapen. Der erste Durchgang macht aus & ein &. Der zweite Durchgang escapet auch das & dieses Namens, und der Leser sieht & auf der Seite.
Also gibt es eine Stelle dafür: render_page, wo Text zu HTML wird. Das ist noch einmal die Regel aus Lektion 3. Die Verteidigung gehört dorthin, wo der Text verwendet wird.
Ein Titel Tom & Jerry wird beim Speichern escapet und noch einmal, wenn die Seite geschrieben wird. Was sieht der Leser?
Das Löschformular
Jede Zeile hat ein Formular mit einer versteckten id und einem Button. Wenn ein Leser den Button drückt, sendet der Browser POST /books/delete mit content-type: application/x-www-form-urlencoded, dem Body id=1 und dem
Dieser Body hat die Form eines Query-parse_qs ihn und dekodiert die Prozentkodierung jedes Werts. form_fields(request) kam mit dem Projekt dieses Kapitels, und es nimmt den ersten Wert jedes Felds, ohne genauer hinzusehen. Du machst es auf zwei Arten strenger. Der Medientyp muss der Formulartyp sein. Und ein Feld, das zweimal vorkommt, wird abgelehnt, weil Code, der den ersten Wert prüft und auf den letzten hin handelt, ein klassischer Weg ist, wie zwei Teile eines Programms uneins werden.
Eines solltest du bemerken, bevor du weitermachst: Nichts in dieser Anfrage beweist, dass der Leser den Button auf deiner Seite gedrückt hat. Dort setzt Kapitel 8 an.
Ein Formular-Body ist id=7&id=41. Warum lehnt form_fields ihn ab?
Deine Aufgabe
Leite in render_page jeden Wert einmal durch html.escape(), vor fill(): den Benutzer, jeden Titel, jeden Autor und jede ID als Text, str(book["id"]).
Schreib form_fields(request) neu:
Löse
ValueError("send the form as application/x-www-form-urlencoded")aus, wenn nichtmedia_type(request) == FORM_TYPEgilt.Dekodiere den Body als
und lies ihn mit parse_qs(text, keep_blank_values=True), damit ein leerer Wert erhalten bleibt. Du kannst auchstrict_parsing=text != ""undmax_num_fields=20übergeben, die einen fehlerhaften und einen sehr langen Body ablehnen. Ein Body, der kein UTF-8 ist, lässtdecodeUnicodeDecodeErrorauslösen, und das ist ebenfalls ein. Löse
ValueError(f"the field {name} is repeated")aus, wenn ein Feld mehr als einen Wert hat.
Antworte in delete_from_form zuerst mit json_error(415, ...), wenn der Medientyp nicht der Formulartyp ist, so wie die JSON-Routen es tun. Rufe dann form_fields innerhalb von try auf und antworte mit json_error(400, str(problem)), wenn es ValueError auslöst.
helpers.form_request(target, fields, headers) baut einen Formular-POST wie der des Browsers, und fields darf eine steps.login_cookie:
python -c "import sys; sys.path.insert(0, 'tests'); import helpers, steps, reading_server; s = reading_server.sample_state(); c = steps.login_cookie(s, 'ana', 'correct horse'); r = helpers.form_request('/books/delete', [('id', '1'), ('id', '2')], {'Cookie': c}); print(reading_server.handle(r, s)['status'])"
Es gibt 400 aus.
python -m pytest -q endet mit 23 passed, und der letzte Teil des Szenarios lautet:
b'<span class="title"><b>Dune</b></span> by <span class="author">Frank Herbert</span>'
b'<button aria-label="Delete <b>Dune</b>">Delete</button>'
the title reached the page as text
--- result ---
0 of 4 attacks worked
Klicke dann auf Check my work. Es liest deine Seite mit Pythons HTML-Parser, der kein Browser ist. Der Validator und vier Schutzmaßnahmen stehen jetzt. Zwei der fünf haben einen Angriffstest, und das Projekt schreibt die anderen drei.
Aufgabe
Schreib jeden
In reading_server.py:
render_page(books, user)leitet jeden Wert vorfill()genau einmal durchhtml.escape(): den Benutzer und Titel, Autor und ID jedes Buchs (str(book["id"])). Dort, wo gespeichert wird, wird nichts escapet.form_fields(request)gibt die Felder eines Formular-Bodys alsaus Text zurück, oder löst aus: ValueError("send the form as application/x-www-form-urlencoded"), wenn nichtmedia_type(request) == FORM_TYPEgilt;der Body wird als
dekodiert und mit parse_qs(text, keep_blank_values=True)gelesen;ValueError(f"the field {name} is repeated"), wenn ein Feld mehr als einen Wert hat.
delete_from_form(request, state, session)antwortet zuerst mitjson_error(415, "send the form as application/x-www-form-urlencoded"), wenn der Medientyp nicht der Formulartyp ist, und mitjson_error(400, str(problem)), wennform_fieldsValueErrorauslöst. Der Rest derbleibt, wie er ist.
Führe python -m pytest -q und python pl_sec.py injection im
Klicke dann auf Check my work. Es ruft render_page() mit Büchern und einem Benutzer auf, die du noch nicht gesehen hast, deren Texte Tags, kaufmännische Und-Zeichen und beide Arten von Anführungszeichen enthalten, und liest die Seite mit Pythons HTML-Parser: Kein Wert darf zu einem Element oder einem Attribut werden, jeder Wert muss genau so zurückkommen, wie er übergeben wurde, und die Seite behält eine Zeile und ein Formular für jedes Buch. Es ruft form_fields() mit eigenen Anfrage-Dicts auf: Ein wiederholtes Feld, ein ValueError auslösen; ein kodierter Wert, ein leerer Wert, ein leerer Body und Text außerhalb von ASCII müssen gelesen werden; ein Body, der kein UTF-8 ist, muss etwas auslösen. Es ruft handle() mit einer eigenen POST /books/delete und für GET / auf. Nichts aus deinem Terminal wird bewertet.