0%

Wie mag er verbinding maken? · oefening

Controleer een token vóór 101

Een scorebijhouderspagina kan haar token alleen in de querystring meesturen, een Python-client kan een Bearer-header sturen, en vandaag leest de server geen van beide. Deze les schrijft de beslissing: mag deze client scoren, gegeven het pad en de headers van een ? De volgende les laat de bibliotheek die vraag stellen tijdens de handshake.

Vóór 101, of nooit

De handshake is een -uitwisseling, en 101 Switching Protocols is het laatste HTTP-bericht ervan. Daarna spreekt de verbinding -, en draait de handler al. Een weigering op dat moment kan alleen nog een zijn, verstuurd op een verbinding die de server net heeft geaccepteerd. Een weigering ervóór is een gewone HTTP-status, en er bestaat nooit een WebSocket-verbinding.

Je hebt eerder upgrades geweigerd. In hoofdstuk 2 beantwoordde check_upgrade een misvormde upgrade met 400 en een verkeerde versie met 426, in plaats van 101. Een ontbrekend token is hetzelfde soort antwoord: een status, bepaald op basis van de verzoekregel en de headers, verstuurd vóór enig frame.

Waarom moet het token gecontroleerd worden voordat de server 101 antwoordt?

Waar het token staat

De is die uit HTTP with Python: één Authorization-, gesplitst bij de eerste spatie met partition(" "), het schema bearer in hoofd- of kleine letters, en de rest gestript.

Wat drukt dit af?

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

Het token komt terug met een spatie ervoor, en daarom wordt de rest gestript voordat hij wordt vergeleken.

Een header kan meer dan één keer in een verzoek voorkomen, en het Headers- van de bibliotheek bewaart elke kopie. Zijn get werpt MultipleValuesError op als een naam herhaald wordt, dus lees de header met headers.get_all("authorization"), dat een met elke waarde teruggeeft (leeg als de header ontbreekt). Twee waarden worden geweigerd in plaats van dat er tussen gegokt wordt.

Zonder enige Authorization-header kijk je in de query. parse_qs, uit dezelfde urllib.parse als urlsplit, maakt van een querystring een die elke naam koppelt aan een lijst met zijn waarden, en decodeert onderweg procent-escapes en +:

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"))

De eerste regel drukt /score af: het pad zonder query. De andere drukken {'token': ['abc/def', '2']} af, met %2F gedecodeerd tot / en beide waarden bewaard in de lijst; {}, omdat een lege waarde wordt weggelaten; en {'token': ['a b']}, met + gedecodeerd tot een spatie.

De query moet dus precies één token-waarde bevatten: een lege token= laat er geen over, en twee waarden worden geweigerd net als twee headers. find_token(path, headers) geeft het gevonden token terug of None, volgens deze regels:

Wat het verzoek draagtWat beslist
precies één Authorization-header van de vorm Bearer <token>het token van die header
elke andere Authorization-header: een ander schema, geen token, of twee headersniemand: None, en de query wordt niet gelezen
geen Authorization-headerde ene token-waarde in de query, anders None

De middelste rij doet ertoe. Een slechte header valt niet terug op de query: een client die een inloggegeven stuurde, wordt beoordeeld op dat inloggegeven.

Vergelijk zonder tijd te lekken

token == expected geeft het juiste antwoord, maar == op twee stopt bij het eerste teken dat verschilt. Een verkeerde gok waarvan de eerste tien tekens kloppen, duurt iets langer om te weigeren dan een die vanaf het begin fout is. Gemeten over veel pogingen kan dat verschil een aanvaller vertellen hoeveel van een gok klopte, en hem het token teken voor teken laten opbouwen. Zulke timingaanvallen over een netwerk zijn moeilijk maar echt, en de standaardverdediging kost één regel: hmac.compare_digest(a, b) doet er even lang over, welk deel van de waarden ook overeenkomt. (Timing kan nog steeds de lengte van de waarden verraden, maar niet welke tekens klopten.) De cursus gebruikt dat hulpmiddel in plaats van te redeneren over wanneer de aanval zou werken.

Eén detail: compare_digest weigert str-waarden met tekens buiten ASCII.

import hmac

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

Een token uit de query kan zo’n string zijn nadat parse_qs het heeft gedecodeerd (caf%C3%A9 wordt café), en een headerwaarde ook. Vergelijk daarom bytes: hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")) werkt voor elke tekst. Roep het aan via de module, als hmac.compare_digest, met de regel import hmac die al bovenaan scoreboard_server.py staat.

Waarom hmac.compare_digest in plaats van == voor het token?

401, en waarom niet 403

HTTP with Python trok de grens: 401 als de server niet weet wie je bent, 403 als hij het weet en toch nee zegt. Het scorebord heeft één token en één recht: scoren. Een client zonder token, of met een verkeerd token, is iemand die de server niet kan identificeren, dus elke weigering op /score is 401. Een 403 kom je in les 4 tegen, van een andere controle. /watch heeft geen token nodig, dus check_request(path, headers, expected) vergelijkt eerst urlsplit(path).path met "/score" en geeft None terug voor al het andere; zo kan een querystring het pad niet verbergen.

Een scorebijhouder stuurt het verkeerde token. Waarom hier 401 en niet 403?

Het tokenbestand

Het verwachte token staat in token.txt in je werkruimte. Het bevat een verzonnen oefentoken, geen geheim: een echt token staat nooit in een broncodeboom, waar elke kopie van de code het meedraagt. Echte services laden het uit de omgeving of uit een geheimenopslag. read_token(path) opent het bestand met encoding="utf-8" in een with-blok en geeft de tekst terug, ontdaan van de newline en eventuele spaties eromheen.

Je opdracht

Schrijf in scoreboard_server.py read_token, find_token en check_request zoals hun docstrings en deze les beschrijven. Nog niets roept check_request aan tijdens een handshake.

Klik op Run. De sectie van les 2 bouwt vijf verzoeken met de Headers van de bibliotheek en drukt voor elk ervan af: het pad zonder query, of er een Authorization-header werd gestuurd, of de query een token bevatte, en wat check_request antwoordde. Ze drukt nooit een token af. Een afgemaakte versie, op de site uitgevoerd, drukte af:

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

De kijker komt er zonder iets door. De scorebijhouder zonder iets krijgt 401. Het derde en vierde verzoek dragen het verwachte token van de demo, een in een header en een in de query. Het laatste stuurt een gok in een header. Klik daarna op Submit.

De volgende les geeft check_request aan de bibliotheek, zodat er een 401 uitgaat in plaats van een 101.

Opdracht

In scoreboard_server.py:

  1. Schrijf read_token(path): open het bestand op path met encoding="utf-8" in een with-blok en geef de tekst terug met .strip().

  2. Schrijf find_token(path, headers). De docstring geeft de 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.” Kort gezegd: is er een Authorization-header, dan beslissen alleen de headers (het token van precies één Bearer-header, anders None, zonder terug te vallen op de query); alleen zonder Authorization-header geef je de ene querywaarde token terug, anders None. Lees de header met headers.get_all("authorization"), splits de ervan met partition(" "), vergelijk het schema na .lower() met "bearer", en strip het token; een leeg token telt als geen token. Lees de query met parse_qs(urlsplit(path).query).get("token"), wat een van precies één waarde moet zijn.

  3. Schrijf check_request(path, headers, expected): geef None terug tenzij urlsplit(path).path gelijk is aan "/score"; geef 401 terug als find_token geen token vindt of als hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")) onwaar is; geef anders None terug. Houd de regel import hmac en roep compare_digest aan via de .

Klik op Run: de sectie van les 2 drukt None af voor /watch, 401 voor /score zonder iets, None voor het verwachte token in een header en in de query, en 401 voor een gok.

Klik daarna op Submit. De controle gebruikt een verwacht token dat je nog niet hebt gezien. Ze stuurt verzoeken die het dragen in een Bearer-header (met het schema en de headernaam in andere hoofd- en kleine letters, en extra spaties), in de query, alleen of naast andere waarden, en verzoeken die 401 moeten krijgen: geen token, een verkeerd token, het token met één teken extra, alleen het einde van het token, een ander schema, Bearer met niets erachter, twee Authorization-headers, een slechte header naast het juiste querytoken, twee token-waarden, een lege, en tokens met tekst buiten ASCII, die geen mogen opwerpen. /watch wordt doorgelaten, wat de query ook bevat. Om te zien dat compare_digest de beslissing neemt, vervangt de controle scoreboard_server.hmac door een stand-in waarvan compare_digest een vast antwoord geeft, dus een regel from hmac import wordt geweigerd; elke aanroep moet twee waarden van één type meegeven, en de gevallen buiten ASCII hebben dat type nodig als bytes. read_token leest token.txt en een tweede bestand dat de controle ernaast schrijft. Er mag niets worden afgedrukt.