0%

Hoofdstuk 7 · oefening

Invoer die code wordt

Accepteer alleen wat je verwacht

De leeslijst is voor dit hoofdstuk gegroeid. Een boek kan nu drie velden meer hebben: het jaar waarin het verscheen, hoe ver je ermee bent, en een met tags. Een body van een verzoek kan er zo uitzien:

{
  "title": "Dune",
  "author": "Frank Herbert",
  "year": 1965,
  "status": "reading",
  "tags": ["desert", "classic"]
}

Om zulke velden op te slaan, is ook create_book in reading_server.py veranderd. Deze code is nieuw en kwam mee met het project van dit hoofdstuk. Je hebt hem niet zelf geschreven:

def create_book(request, state, owner):
    """Store the body as a new book owned by owner and return the 201 response."""
    data = json_body(request)
    book_id = state["next_id"]
    state["next_id"] += 1
    book = {"id": book_id, "owner": owner}
    for key in data:
        book[key] = data[key]
    state["books"][book_id] = book
    response = json_response(201, book)
    response["headers"]["location"] = f"/books/{book_id}"
    return response

De kopieert elke sleutel van de body naar het boek. Dat is handig, want een nieuw veld vraagt geen nieuwe code. Het nodigt ook uit tot een fout. De server zet eerst id en owner, en daarna draait de lus, dus een sleutel in de body vervangt wat de server koos. replace_book, dat PUT afhandelt, heeft dezelfde lus.

Schrijf eerst de aanval

Hoofdstuk 6 maakte de eigenaar het ene feit waartegen elke leesactie en elke schrijfactie wordt gecontroleerd. Probeer hem dus te kiezen. Ben heeft een account dat mag schrijven, en een eigen token. Hij post een boek en voegt "owner": "ana" toe aan de body.

Schrijf dat als een test, test_attack_body_cannot_set_the_owner, onder het commentaar voor hoofdstuk 7 in tests/test_attacks.py:

  1. Bouw de state met steps.keyed_state(), en haal de voor Authorization van ben op met steps.bearer_for(state, "ben", "battery staple").

  2. Post een eerlijk boek met dat token en controleer met een assert dat de status 201 is. Zonder deze stap zou een server die elk boek weigert, je test halen.

  3. Post {"title": "Planted", "author": "Ben", "owner": "ana"} met hetzelfde token. Controleer dat de status 422 is, en dat geen enkel boek in state["books"] de titel Planted heeft.

Voer hem daarna uit. De optie -k owner kiest de tests met owner in hun naam:

python -m pytest -q -k owner

De run eindigt met 1 failed, 21 deselected, en dit is de regel die faalde:

E       assert 201 == 422

De server antwoordde 201 en sloeg het boek op met "owner": "ana". Het verschijnt nu in de lijst van Ana als ze om GET /books vraagt. Ben, die het maakte, krijgt er 404 voor. Met dezelfde lus kan een body ook de id kiezen die een boek toont, of een veld toevoegen dat niemand had gepland, zoals "is_admin": true.

Deze zwakheid heet mass assignment: de server wijst een hele body in één stap toe aan een record, en dus beslist de client over velden die de client nooit mocht zetten.

De aanval vraagt een geldig token dat mag schrijven: Ben is een gebruiker van de dienst, geen vreemde. Niemand anders werkt in je workspace, dus de test speelt beide rollen.

Een lijst van wat geweigerd wordt, mist er altijd één

De eerste oplossing die opkomt, noemt de sleutel die het probleem veroorzaakte:

if "owner" in body:
    raise ValueError("owner is set by the server")

Dat stopt de aanval van de test en laat id open. Een lijst van wat geweigerd wordt, heet een denylist. Hij laat alles door wat hij niet noemt, en daar horen ook velden bij die nog niet bestaan.

Een server weigert een body die owner bevat. Volgende maand voegt een ontwikkelaar een veld is_admin toe aan boeken. Wat gebeurt er met een body die het zet?

Een lijst van wat geaccepteerd wordt

Draai de lijst om. Een allowlist noemt wat geaccepteerd wordt, en al het andere wordt geweigerd. Je project heeft er een, als set:

ALLOWED_FIELDS = {"title", "author", "year", "status", "tags"}

De test is key not in ALLOWED_FIELDS. Een veld dat later aan boeken wordt toegevoegd, blijft buiten verzoeken tot iemand besluit het toe te laten.

validate_book(body) past de lijst toe. Hij werpt ValueError("the body must be a JSON object") op voor een body die geen is. Daarna bekijkt hij elke sleutel van de body, in de volgorde van de body zelf:

  • voor owner of id werpt hij ValueError(f"{key} is set by the server") op;

  • voor elke andere sleutel die niet in ALLOWED_FIELDS staat, werpt hij ValueError(f"{key} is not a field of a book") op.

Als er geen sleutel werd geweigerd, bouwt hij een nieuwe dict, kopieert hij de sleutels en waarden van de body erin, en geeft hij de nieuwe dict terug. Hij geeft nooit de body zelf terug, dus een sleutel die de validator niet las, kan het boek niet bereiken.

Deze verdediging heb je al eens gezien. In with Python, hoofdstuk 11, les 3, liet een Pydantic-model de velden vallen die het niet noemde, “zodat een client de id van een boek niet kan kiezen”. Dat laten vallen was een allowlist, en het was de verdediging tegen mass assignment.

Het model liet die velden zonder een woord vallen. Jouw weigert ze, om twee redenen. Een eerlijke client die een verkeerd gespelde sleutel stuurt, hoort van zijn fout. En een poging tot knoeien wordt een 422, die het log uit hoofdstuk 6 vastlegt met de naam van de gebruiker. Ook een model kan weigeren: de instelling daarvoor in Pydantic is extra="forbid".

Een onbekend veld laten vallen en het weigeren houden het allebei uit het boek. Wat voegt weigeren toe?

Je taak

De test is geschreven en rood. Ga nu in reading_server.py aan de slag:

  1. Schrijf validate_book(body) zoals hierboven beschreven.

  2. Roep in book_request_error(request), na de controles die er al staan, validate_book(json_body(request)) aan binnen try. Als die opwerpt, is de fout die je teruggeeft json_error(422, str(problem)). Laat book_error staan: die meldt nog steeds een ontbrekende titel.

  3. Loop in create_book en in replace_book over validate_book(json_body(request)) in plaats van over de body.

De tweede wijziging geldt voor POST en PUT allebei, dus beide schrijfroutes gaan in deze les dicht. De status volgt HTTP with Python, hoofdstuk 5, les 5. Een 400 zegt dat de body niet gelezen kon worden, en deze werd wel gelezen. Een 415 zegt dat het mediatype verkeerd is, en deze is gelabeld als application/json. Een 403 zegt dat het account niet mag schrijven, en dat van Ben mag het wel. De body werd begrepen, en één ding erin is niet acceptabel: dat is 422.

Een body is geldige JSON, correct gelabeld, en bevat "owner": "ana". Welke status past?

Voer python -m pytest -q -k owner opnieuw uit: 1 passed, 21 deselected. Zonder -k owner eindigt het hele bestand met 22 passed.

Deze les verandert met opzet een contract. In HTTP with Python werd een boekbody met een extra veld geaccepteerd en werd het veld genegeerd. Vanaf nu krijgt zo’n body een 422 die het veld noemt.

Druk daarna op Check my work. De juiste sleutels komen nu binnen, en verder niets. Wat er onder die sleutels staat, is nog niet gecontroleerd: een titel van elke lengte, een jaar van elk type. Dat is de volgende les.

Opdracht

Voorkom dat een body van een verzoek velden zet die bij de server horen, en bewijs het met een test.

Schrijf in tests/test_attacks.py, onder het commentaar voor hoofdstuk 7, test_attack_body_cannot_set_the_owner:

  • bouw de state met steps.keyed_state(), en haal de voor Authorization van ben op met steps.bearer_for(state, "ben", "battery staple");

  • post een eerlijk boek, zoals {"title": "Dune", "author": "Frank Herbert"}, via reading_server.handle(helpers.make_request("POST", "/books", book, headers), state), en controleer met een assert dat de status 201 is;

  • post {"title": "Planted", "author": "Ben", "owner": "ana"} met hetzelfde token, en controleer dat de status 422 is en dat geen enkel boek in state["books"] de titel Planted heeft.

Voer python -m pytest -q -k owner uit in de : de test moet eerst falen.

In reading_server.py:

  • validate_book(body) werpt ValueError("the body must be a JSON object") op als body geen is. Voor een sleutel owner of id werpt hij ValueError(f"{key} is set by the server") op. Voor elke andere sleutel die niet in ALLOWED_FIELDS staat, werpt hij ValueError(f"{key} is not a field of a book") op. Anders geeft hij een nieuwe dict terug met de sleutels en waarden van de body. Hij geeft de body zelf nooit terug en verandert hem nooit.

  • book_request_error(request) roept validate_book(json_body(request)) aan binnen try, na de controles die hij al heeft, en geeft json_error(422, str(problem)) terug als de aanroep opwerpt.

  • create_book en replace_book lopen over validate_book(json_body(request)) in plaats van over de body.

Voer de test opnieuw uit en zie hem slagen.

Druk daarna op Check my work. Het roept validate_book() aan met bodies die je niet hebt gezien: een , een getal en None; bodies met toegestane sleutels, die gelijk en als nieuwe dict terug moeten komen; sleutels die niet zijn toegestaan, waaronder een die alleen door een hoofdletter of een spatie van een toegestane sleutel verschilt; en owner en id. Elke weigering moet een ValueError zijn waarvan de melding de sleutel noemt. Daarna roept het handle() aan met eigen gebruikers, boeken en tokens: een POST en een PUT waarvan de body owner, id of een onbekende sleutel zet, moeten 422 krijgen en niets veranderen, en schone verzoeken moeten nog steeds 201 en 200 krijgen, met de id van de server en de gebruiker van het token als owner. Als laatste draait het je test op naam tegen vier eigen servers. Hij moet slagen tegen een die weigert wat niet is toegestaan, en falen tegen een die de hele body kopieert, een die alleen id weigert, en een die op elk boek 422 antwoordt. Niets uit je terminal wordt beoordeeld.