0%

Wer darf sich verbinden? · Übung

Ein Token vor 101 prüfen

Eine Punktezählerseite kann ihr Token nur im Query- tragen, ein Python-Client kann einen Bearer-Header senden, und heute liest der Server keins von beiden. Diese Lektion schreibt die Entscheidung: Darf dieser Client , gegeben den Pfad und die Header eines ? Die nächste Lektion bringt die Bibliothek dazu, diese Frage während des Handshakes zu stellen.

Vor 101 oder nie

Der Handshake ist ein -Austausch, und 101 Switching Protocols ist seine letzte HTTP-Nachricht. Danach spricht die Verbindung -, und der Handler läuft bereits. Eine Ablehnung an diesem Punkt könnte nur ein sein, gesendet auf einer Verbindung, die der Server gerade angenommen hat. Eine Ablehnung davor ist ein gewöhnlicher HTTP-Status, und es entsteht nie eine WebSocket-Verbindung.

Du hast schon Upgrades abgelehnt. In Kapitel 2 hat check_upgrade ein fehlerhaftes Upgrade mit 400 und eine falsche Version mit 426 beantwortet, statt mit 101. Ein fehlendes Token ist dieselbe Art von Antwort: ein Status, entschieden anhand der Zeile und der Header der Anfrage, gesendet vor jedem Frame.

Warum muss das Token geprüft werden, bevor der Server 101 antwortet?

Wo das Token steckt

Die Regel für den Header ist die aus HTTP with Python: ein einziger Authorization-Wert, mit partition(" ") am ersten Leerzeichen geteilt, das Schema bearer in beliebiger Groß- und Kleinschreibung, und der Rest von Leerzeichen befreit.

Was gibt das aus?

print("bearer  tok".partition(" "))

Das Token kommt mit einem Leerzeichen davor zurück, und deshalb wird der Rest von Leerzeichen befreit, bevor er verglichen wird.

Ein Header kann in einer Anfrage mehr als einmal vorkommen, und das Headers- der Bibliothek behält jede Kopie. Sein get löst MultipleValuesError aus, wenn sich ein Name wiederholt, also lies den Header mit headers.get_all("authorization"), das eine aller Werte zurückgibt (leer, wenn der Header fehlt). Zwei Werte werden abgelehnt, statt zwischen ihnen zu raten.

Gibt es überhaupt keinen Authorization-Header, schau in die Query. parse_qs, aus demselben urllib.parse wie urlsplit, verwandelt einen Query-String in ein , das jeden Namen einer Liste seiner Werte zuordnet, und decodiert dabei Prozent-Escapes und +:

from urllib.parse import parse_qs, urlsplit

print(urlsplit("/score?token=abc%2Fdef&x=1").path)
print(parse_qs("token=abc%2Fdef&token=2"))
print(parse_qs("token="))
print(parse_qs("token=a+b"))

Die erste Zeile gibt /score aus: den Pfad ohne seine Query. Die anderen geben {'token': ['abc/def', '2']} aus, mit %2F zu / decodiert und beiden Werten in der Liste; {}, weil ein leerer Wert verworfen wird; und {'token': ['a b']}, mit + zu einem Leerzeichen decodiert.

Die Query muss also genau einen token-Wert enthalten: Ein leeres token= lässt keinen übrig, und zwei Werte werden abgelehnt wie zwei Header. find_token(path, headers) gibt das gefundene Token oder None zurück, nach diesen Regeln:

Was die Anfrage trägtWas entscheidet
genau ein Authorization-Header der Form Bearer <token>das Token dieses Headers
jeder andere Authorization-Header: ein anderes Schema, kein Token oder zwei Headerniemand: None, und die Query wird nicht gelesen
kein Authorization-Headerder eine token-Wert in der Query, sonst None

Die mittlere Zeile ist wichtig. Ein schlechter Header fällt nicht auf die Query zurück: Ein Client, der einen Nachweis gesendet hat, wird nach diesem Nachweis beurteilt.

Vergleichen, ohne Zeit zu verraten

token == expected liefert die richtige Antwort, aber == auf zwei Strings hört beim ersten Zeichen auf, das sich unterscheidet. Ein falscher Versuch, dessen erste zehn Zeichen stimmen, braucht etwas länger bis zur Ablehnung als einer, der von Anfang an falsch ist. Über viele Versuche gemessen kann dieser Unterschied einem Angreifer verraten, wie viel von einem Versuch richtig war, und ihn das Token Zeichen für Zeichen aufbauen lassen. Solche Timing-Angriffe über ein Netzwerk sind schwierig, aber real, und die übliche Abwehr kostet eine Zeile: hmac.compare_digest(a, b) braucht dieselbe Zeit, egal welcher Teil der Werte übereinstimmt. (Das Timing kann weiterhin die Länge der Werte zeigen, aber nicht, welche Zeichen richtig waren.) Der Kurs verwendet dieses Werkzeug, statt darüber nachzudenken, wann der Angriff funktionieren würde.

Ein Detail: compare_digest lehnt str-Werte ab, die Zeichen außerhalb von ASCII enthalten.

import hmac

hmac.compare_digest("café", "cafe")

Ein Token aus der Query kann so ein String sein, nachdem parse_qs es decodiert hat (caf%C3%A9 wird zu café), und ein Header-Wert ebenso. Vergleiche stattdessen Bytes: hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")) funktioniert für jeden Text. Rufe es über das Modul auf, als hmac.compare_digest, mit der Zeile import hmac, die schon oben in scoreboard_server.py steht.

Warum hmac.compare_digest statt == für das Token?

401, und warum nicht 403

HTTP with Python hat die Grenze gezogen: 401, wenn der Server nicht weiß, wer du bist, 403, wenn er es weiß und trotzdem nein sagt. Die Anzeigetafel hat ein Token und eine Berechtigung, das Werten. Ein Client ohne Token oder mit einem falschen ist jemand, den der Server nicht identifizieren kann, also ist jede Ablehnung auf /score eine 401. Eine 403 triffst du in Lektion 4, aus einer anderen Prüfung. /watch braucht kein Token, also vergleicht check_request(path, headers, expected) zuerst urlsplit(path).path mit "/score" und gibt für alles andere None zurück; ein Query-String kann den Pfad so nicht verstecken.

Ein Punktezähler sendet das falsche Token. Warum hier 401 und nicht 403?

Die Token-Datei

Das erwartete Token steht in token.txt in deinem Arbeitsbereich. Die Datei enthält ein erfundenes Übungstoken, kein Geheimnis: Ein echtes Token liegt nie in einem Quellcodebaum, wo jede Kopie des Codes es mit sich trägt. Echte Dienste laden es aus der Umgebung oder aus einem Geheimnisspeicher. read_token(path) öffnet die Datei mit encoding="utf-8" in einem with-Block und gibt ihren Text ohne den Zeilenumbruch und ohne umgebende Leerzeichen zurück.

Deine Aufgabe

Schreibe in scoreboard_server.py read_token, find_token und check_request so, wie ihre Docstrings und diese Lektion sie beschreiben. Noch ruft nichts check_request während eines Handshakes auf.

Klicke auf Run. Der Abschnitt zu Lektion 2 baut fünf Anfragen mit den Headers der Bibliothek und gibt für jede aus: den Pfad ohne seine Query, ob ein Authorization-Header gesendet wurde, ob die Query ein Token enthielt und was check_request geantwortet hat. Er gibt nie ein Token aus. Eine fertige Version hat auf der Seite ausgegeben:

Lesson 2: who may score?
    /watch, no Authorization header, a token in the query: False -> None
    /score, no Authorization header, a token in the query: False -> 401
    /score, an Authorization header, a token in the query: False -> None
    /score, no Authorization header, a token in the query: True -> None
    /score, an Authorization header, a token in the query: False -> 401

Der Zuschauer kommt ohne alles durch. Der Punktezähler ohne alles bekommt 401. Die dritte und die vierte Anfrage tragen das erwartete Token der Demo, eine in einem Header und eine in der Query. Die letzte sendet einen geratenen Wert in einem Header. Klicke dann auf Submit.

Die nächste Lektion übergibt check_request an die Bibliothek, damit statt einer 101 eine 401 hinausgeht.

Aufgabe

In scoreboard_server.py:

  1. Schreibe read_token(path): Öffne die Datei unter path mit encoding="utf-8" in einem with-Block und gib ihren Text mit .strip() zurück.

  2. Schreibe find_token(path, headers). Ihr Docstring nennt die Regel: “If any Authorization header is present, decide from the headers alone: the token of exactly one Bearer header, else None (a bad or repeated header never falls back to the query). Only with no Authorization header, return the one token query value, else None.” Sie besagt: Ist irgendein Authorization-Header vorhanden, entscheiden allein die Header; ohne Authorization-Header zählt nur der eine Token- in der Query. Lies den Header mit headers.get_all("authorization"), teile seinen Wert mit partition(" "), vergleiche das Schema nach .lower() mit "bearer" und befreie das Token von Leerzeichen; ein leeres Token zählt als keins. Lies die Query mit parse_qs(urlsplit(path).query).get("token"), was eine mit genau einem Wert sein muss.

  3. Schreibe check_request(path, headers, expected): Gib None zurück, wenn urlsplit(path).path nicht "/score" ist; gib 401 zurück, wenn find_token kein Token findet oder wenn hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")) falsch ist; gib andernfalls None zurück. Behalte die Zeile import hmac und rufe compare_digest über das auf.

Klicke auf Run: Der Abschnitt zu Lektion 2 gibt None für /watch aus, 401 für /score ohne alles, None für das erwartete Token in einem Header und in der Query und 401 für einen geratenen Wert.

Klicke dann auf Submit. Die Prüfung verwendet ein erwartetes Token, das du nicht gesehen hast. Sie sendet Anfragen, die es in einem Bearer-Header tragen (mit Schema und Header-Namen in anderer Groß- und Kleinschreibung und mit zusätzlichen Leerzeichen), in der Query allein oder neben anderen Werten, und Anfragen, die 401 bekommen müssen: kein Token, ein falsches Token, das Token mit einem Zeichen mehr, nur das Ende des Tokens, ein anderes Schema, Bearer ohne etwas danach, zwei Authorization-Header, ein schlechter Header neben dem richtigen Query-Token, zwei token-Werte, ein leerer und Tokens mit Text außerhalb von ASCII, die keine auslösen dürfen. /watch wird durchgelassen, egal was seine Query enthält. Um zu sehen, dass compare_digest die Entscheidung trifft, ersetzt die Prüfung scoreboard_server.hmac durch einen Stellvertreter, dessen compare_digest eine feste Antwort gibt; eine Zeile from hmac import wird deshalb abgelehnt. Jeder Aufruf muss zwei Werte desselben Typs übergeben, und die Fälle außerhalb von ASCII verlangen, dass dieser bytes ist. read_token liest token.txt und eine zweite Datei, die die Prüfung daneben schreibt. Nichts darf ausgegeben werden.