Wie mag wat: controleer op wat de server weet
Je controle uit les 3 vergelijkt de naam bij het bericht met de naam in de sessie. Er is een controle die er bijna hetzelfde uitziet: de naam bij het bericht vergelijken met een naam die de bezoeker zelf meestuurt. Die gaat mis, en dat bouw je nu na.
Een naam uit de URL
Stel dat de verwijderknop de naam van de bezoeker meestuurt, in de URL, en dat
het endpoint daarop controleert. Vervang bericht_verwijderen in je main.py
tijdelijk door deze versie:
@app.delete("/bericht/{sleutel}")
async def bericht_verwijderen(sleutel: str, naam: str = ""):
with SqliteDict("gastenboek.db") as db:
bericht = db.get(sleutel)
if bericht is None or bericht["naam"] != naam:
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")
del db[sleutel]
db.commit()
return {"bericht": "Verwijderd"}
naam is een query-parameter, zoals in Een waarde na een vraagteken: query-parameters:
de knop zou /bericht/…?naam=Alex versturen. Gooi gastenboek.db,
sessies.db en gebruikers.db weg en draai test_verwijderen.py, met het stuk
van Sara uit les 3:
Alex verwijdert het bericht van Sara: 403
Nog in het gastenboek: ['Sara', 'Alex']
Sara verwijdert haar eigen bericht: 403
Nog in het gastenboek: ['Sara', 'Alex']
Alex stuurt geen naam mee en krijgt 403. Maar Sara ook: zij stuurt evenmin
een naam mee, en dus blokkeert deze controle zelfs de eigenaar. Verander nu in
het script de regel waarmee Alex verwijdert, zodat hij de naam van Sara
meestuurt:
antwoord = alex.delete(f"/bericht/{sleutel}?naam=Sara")
Gooi de databases weer weg en draai het script opnieuw.
De naam komt uit de URL, en die bepaalt de bezoeker zelf. Alex stuurt "Sara" mee, en de controle gelooft wat hij stuurt. Het endpoint vraagt zijn sessie niet eens op.
Wie bepaalt deze waarde?
Bij elke waarde in een controle is de vraag: wie heeft hem bepaald?
| Waarde | Wie bepaalt hem |
|---|---|
een veld uit het formulier of de URL, zoals naam | de bezoeker: hij typt wat hij wil |
de sleutel in het pad, /bericht/{sleutel} | de bezoeker: hij kan elk pad sturen |
de cookie sessie_id | de bezoeker stuurt hem, maar hij is te lang om te raden |
de naam in sessies.db | de server: die zette hem erin bij het inloggen, nadat het wachtwoord klopte |
de naam bij een bericht in gastenboek.db | de server: die nam hem bij het plaatsen uit de sessie |
Alleen wat de server zelf heeft vastgelegd, kun je vertrouwen. Het sessie-id is de sleutel waarmee de server zijn eigen gegevens terugvindt.
Zet de goede controle terug
Zet bericht_verwijderen uit les 3 terug, en zet in het script de
regel van Alex terug. Het verschil zit in één regel:
# FOUT
if bericht is None or bericht["naam"] != naam:
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")
# GOED
if bericht is None or bericht["naam"] != mijn.get("naam"):
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")
Meer over wat je van een bezoeker kunt geloven: Server of browser?
Er gaat iets mis
Meer namen, of twee regels van Alex
Het script print meer namen dan Sara en Alex, of Alex verwijdert twee
berichten van Sara.
Oorzaak: de databases van de vorige keer staan er nog. Het script maakt dan bij elke keer twee nieuwe berichten, naast de oude.
Oplossing: gooi gastenboek.db, sessies.db en gebruikers.db weg voordat
je het script opnieuw draait.
Opdrachten
Opdracht 1: Predict - De naam uit een cookie
In Onthouden met een cookie zette je de naam van de bezoeker in een cookie. Stel dat de controle vergelijkt met die cookie in plaats van met de sessie:
if bericht["naam"] != naam_uit_cookie:
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")
Vraag: is dat wel veilig?
Tip
Wie bepaalt wat er in een cookie staat?
Antwoord
Nee. In die les veranderde je zelf de cookie in het tabblad App, en de server geloofde het. Een cookie met een naam is net zo goed door de bezoeker te bepalen als een formulierveld of de URL. Daarom staat in een sessie-cookie alleen een sessie-id, en zoekt de server de naam zelf op.
Opdracht 2: Investigate - Waarom helpt een lange sleutel niet?
De sleutels van berichten zijn lang, zoals bericht_1767000000000000000. Waarom
is dat geen reden om te geloven dat alleen de schrijver de sleutel kent?
Antwoord
Omdat de sleutel in de pagina staat, voor iedereen die de lijst opvraagt. Je
zag in les 2 dat Alex hem zo uit /berichten haalde. Een sleutel
zegt welk bericht, niet wie het mag.
Zo ziet je main.py er nu uit
import secrets
import time
from fastapi import Cookie, FastAPI, Form, HTTPException
from fastapi.responses import JSONResponse
from sqlitedict import SqliteDict
app = FastAPI()
@app.post("/registreer")
async def registreer(naam: str = Form(...), wachtwoord: str = Form(...)):
with SqliteDict("gebruikers.db") as gebruikers:
if naam in gebruikers:
raise HTTPException(status_code=400, detail="Deze naam is al bezet")
gebruikers[naam] = wachtwoord
gebruikers.commit()
return {"bericht": f"Welkom, {naam}"}
@app.post("/inloggen")
async def inloggen(naam: str = Form(...), wachtwoord: str = Form(...)):
with SqliteDict("gebruikers.db") as gebruikers:
opgeslagen = gebruikers.get(naam)
if opgeslagen != wachtwoord:
raise HTTPException(status_code=401, detail="Naam of wachtwoord klopt niet")
sessie_id = secrets.token_hex(16)
with SqliteDict("sessies.db") as sessies:
sessies[sessie_id] = {"naam": naam}
sessies.commit()
antwoord = JSONResponse({"bericht": f"Ingelogd als {naam}"})
antwoord.set_cookie(key="sessie_id", value=sessie_id, max_age=60 * 60 * 24 * 30)
return antwoord
@app.post("/bericht")
async def bericht_plaatsen(bericht: str = Form(...), sessie_id: str = Cookie(default="")):
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
if not mijn:
raise HTTPException(status_code=401, detail="Log eerst in")
sleutel = f"bericht_{time.time_ns()}"
with SqliteDict("gastenboek.db") as db:
db[sleutel] = {"naam": mijn["naam"], "bericht": bericht}
db.commit()
return {"sleutel": sleutel}
@app.get("/berichten")
async def berichten():
with SqliteDict("gastenboek.db") as db:
return dict(db)
@app.delete("/bericht/{sleutel}")
async def bericht_verwijderen(sleutel: str, sessie_id: str = Cookie(default="")):
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
with SqliteDict("gastenboek.db") as db:
bericht = db.get(sleutel)
if bericht is None or bericht["naam"] != mijn.get("naam"):
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")
del db[sleutel]
db.commit()
return {"bericht": "Verwijderd"}
Na het terugzetten is het hetzelfde bestand als in les 3: de controle kijkt naar de naam in de sessie.
Door naar les 5: elk endpoint apart.