0%

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 -Antwort bleibt er Text. In der Seite liest ein Parser ihn als HTML.

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 row und end 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 &lt;b&gt;Dune&lt;/b&gt; aus. Ein Browser, der das liest, zeigt <b>Dune</b> auf dem Bildschirm an und beginnt kein Element. Das kaufmännische Und wird zu &amp;, das doppelte Anführungszeichen zu &quot; und das einfache zu &#x27;.

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 in einem 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 , kurz XSS. Dieser Kurs kann dir keinen vorführen, weil in deinem Arbeitsbereich kein Browser die Seite öffnet. 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 &lt; 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 &amp;. Der zweite Durchgang escapet auch das & dieses Namens, und der Leser sieht &amp; 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-, also liest 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:

  1. Löse ValueError("send the form as application/x-www-form-urlencoded") aus, wenn nicht media_type(request) == FORM_TYPE gilt.

  2. Dekodiere den Body als und lies ihn mit parse_qs(text, keep_blank_values=True), damit ein leerer Wert erhalten bleibt. Du kannst auch strict_parsing=text != "" und max_num_fields=20 übergeben, die einen fehlerhaften und einen sehr langen Body ablehnen. Ein Body, der kein UTF-8 ist, lässt decode UnicodeDecodeError auslösen, und das ist ebenfalls ein .

  3. 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 von Paaren sein. Probier einmal ein wiederholtes Feld aus, mit einem Session-Cookie von 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">&lt;b&gt;Dune&lt;/b&gt;</span> by <span class="author">Frank Herbert</span>'
  b'<button aria-label="Delete &lt;b&gt;Dune&lt;/b&gt;">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 als Text in die Seite und lies das Löschformular streng.

In reading_server.py:

  • render_page(books, user) leitet jeden Wert vor fill() genau einmal durch html.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 als aus Text zurück, oder löst aus:

    1. ValueError("send the form as application/x-www-form-urlencoded"), wenn nicht media_type(request) == FORM_TYPE gilt;

    2. der Body wird als dekodiert und mit parse_qs(text, keep_blank_values=True) gelesen;

    3. ValueError(f"the field {name} is repeated"), wenn ein Feld mehr als einen Wert hat.

  • delete_from_form(request, state, session) antwortet zuerst mit json_error(415, "send the form as application/x-www-form-urlencoded"), wenn der Medientyp nicht der Formulartyp ist, und mit json_error(400, str(problem)), wenn form_fields ValueError auslöst. Der Rest der bleibt, wie er ist.

Führe python -m pytest -q und python pl_sec.py injection im aus.

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 -Body, einfacher Text und kein Content-Type müssen 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 für POST /books/delete und für GET / auf. Nichts aus deinem Terminal wird bewertet.