Wie mag wat: de oplossing
De controle hoort op de plek waar het verzoek binnenkomt: in het endpoint. De server weet welke berichten deze bezoeker heeft geplaatst, want ze staan in zijn sessie. Staat de sleutel daar niet in, dan verwijdert het endpoint niets.
De controle in het endpoint
Vervang bericht_verwijderen in je main.py door deze:
@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, {})
if sleutel not in mijn.get("berichten", []):
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")
with SqliteDict("gastenboek.db") as db:
if sleutel in db:
del db[sleutel]
db.commit()
return {"bericht": "Verwijderd"}
- Regel 2:
Het endpoint vraagt nu ook de cookie op, net als
bericht_plaatsen. - Regel 3-4:
De sessie van deze bezoeker. Geen cookie of een onbekend sessie-id geeft een lege dictionary.
- Regel 5-6:
Eerst de controle, vóór er iets verandert. Is de sleutel niet van deze bezoeker, dan stopt de functie met 403 Forbidden: de server begrijpt wat je vraagt, maar jij mag het niet.
- Regel 8-11:
Pas nu gaat het bericht weg.
if sleutel in dbvangt een bericht op dat al weg is, bijvoorbeeld omdat je het in een ander tabblad al had verwijderd.
Heb je in Sessies opdracht 5
gemaakt, dan herken je deze controle: het is dezelfde, nu op het
DELETE-endpoint.
Doe de test opnieuw
Gooi gastenboek.db en sessies.db weg, start de server opnieuw, en draai
test_verwijderen.py uit stap 1:
Alex verwijdert het bericht van Sara: 403
Nog in het gastenboek: ['Sara', 'Alex']
Alex stuurt nog steeds hetzelfde verzoek, maar de server weigert. Het bericht van Sara staat er nog.
Elk endpoint apart
Een controle beschermt alleen het endpoint waar hij in staat. Het htmx-overzicht
heeft ook een knop Alles wissen, met een eigen
endpoint DELETE /berichten. Staat daar geen controle in, dan wist Alex daarmee
alsnog het bericht van Sara, en alle andere berichten erbij.
Loop daarom elk endpoint langs dat iets verandert of verwijdert, en stel bij elk dezelfde vraag: wie mag dit? In opdracht 4 doe je dat voor Alles wissen.
Er gaat iets mis
Je controleert op de naam bij het bericht, in plaats van op de sleutels in de sessie:
# FOUT
if db[sleutel]["naam"] != mijn.get("naam"):
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")
# GOED
if sleutel not in mijn.get("berichten", []):
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")
De test uit stap 1 geeft nog netjes 403. Maar laat Alex in het script bij zijn
eigen bericht "naam": "Sara" invullen, en je krijgt:
Alex verwijdert het bericht van Sara: 200
Alex verwijdert het bericht van Sara: 200
Nog in het gastenboek: []
Oorzaak: de naam komt uit het formulier, en dat vult de bezoeker zelf in. Alex noemt zich Sara, en zijn sessie heeft daarna de naam Sara. De controle laat hem door, bij haar bericht en bij zijn eigen.
Oplossing: controleer op iets dat alleen de server kan geven. De sleutels in de sessie heeft de server er zelf in gezet, bij het plaatsen van het bericht.
Meer uitleg over wat je van een bezoeker kunt geloven: Server of browser?
Opdrachten
Opdracht 1: Run - Sara mag wel
Zet onderaan test_verwijderen.py een stuk erbij waarin Sara haar eigen bericht
verwijdert. Lukt dat, en wat staat er daarna nog in het gastenboek?
Tip
Het is dezelfde lus als voor Alex, maar dan met sara.get en sara.delete.
Antwoord
for sleutel, bericht in sara.get("/berichten").json().items():
if bericht["naam"] == "Sara":
antwoord = sara.delete(f"/bericht/{sleutel}")
print(f"Sara verwijdert haar eigen bericht: {antwoord.status_code}")
namen = [bericht["naam"] for bericht in sara.get("/berichten").json().values()]
print(f"Nog in het gastenboek: {namen}")
Uitvoer, na de twee regels van Alex:
Sara verwijdert haar eigen bericht: 200
Nog in het gastenboek: ['Alex']
De Client van Sara stuurt haar cookie mee, en de sleutel staat in haar sessie.
Opdracht 2: Predict - Een nieuw venster
Sara opent het gastenboek in een privévenster en klikt daar op Verwijderen bij
haar eigen bericht. In het script is dat een derde httpx.Client, zonder
cookie.
Vraag: krijgt ze 200 of 403?
Antwoord
403. De server herkent een bezoeker aan zijn sessie-id, en een privévenster
heeft een ander, of nog geen. Voor de server is dat een nieuwe bezoeker, ook al
typt die "Sara". Wil je dat iemand overal bij zijn eigen berichten kan, dan
heb je een account nodig met een wachtwoord: zie de praktijk.
Opdracht 3: Investigate - Een sleutel die niet bestaat
Alex stuurt alex.delete("/bericht/bestaat_niet"). Welke statuscode krijgt hij,
en waarom is het goed dat het geen 404 is?
Antwoord
403, met {'detail': 'Dit is niet jouw bericht'}: de sleutel staat niet in
zijn sessie, en de controle komt vóór de vraag of het bericht bestaat. Zo leert
Alex niets over sleutels van anderen: een bestaand bericht van Sara en een
verzonnen sleutel geven hetzelfde antwoord. Dat is hetzelfde idee als de ene
melding bij inloggen.
Opdracht 4: Make - Alles wissen, maar alleen van jou
Voeg een endpoint DELETE /berichten toe dat alleen de berichten van de
bezoeker zelf wist. Test het met een scriptje waarin Alex alles wist, en kijk
of het bericht van Sara blijft staan.
Tip
Loop niet over alle sleutels in gastenboek.db, maar over de sleutels in de
sessie.
Antwoord
@app.delete("/berichten")
async def alles_wissen(sessie_id: str = Cookie(default="")):
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
with SqliteDict("gastenboek.db") as db:
for sleutel in mijn.get("berichten", []):
if sleutel in db:
del db[sleutel]
db.commit()
return {"bericht": "Jouw berichten zijn gewist"}
Het testscript:
import httpx
adres = "http://127.0.0.1:8000"
sara = httpx.Client(base_url=adres)
alex = httpx.Client(base_url=adres)
sara.post("/bericht", data={"naam": "Sara", "bericht": "Hoi allemaal"})
alex.post("/bericht", data={"naam": "Alex", "bericht": "Leuk gastenboek"})
print(alex.delete("/berichten").json())
namen = [bericht["naam"] for bericht in alex.get("/berichten").json().values()]
print(f"Nog in het gastenboek: {namen}")
Uitvoer, met een lege database:
{'bericht': 'Jouw berichten zijn gewist'}
Nog in het gastenboek: ['Sara']
Hier is er geen 403 nodig: wie Alles wissen kiest, wist wat van hem is, en de
rest blijft staan.
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("/bericht")
async def bericht_plaatsen(
naam: str = Form(...),
bericht: str = Form(...),
sessie_id: str = Cookie(default=""),
):
if not sessie_id:
sessie_id = secrets.token_hex(16)
sleutel = f"bericht_{time.time_ns()}"
with SqliteDict("gastenboek.db") as db:
db[sleutel] = {"naam": naam, "bericht": bericht}
db.commit()
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {"naam": naam, "berichten": []})
mijn["naam"] = naam
mijn["berichten"].append(sleutel)
sessies[sessie_id] = mijn
sessies.commit()
antwoord = JSONResponse({"sleutel": sleutel})
antwoord.set_cookie(key="sessie_id", value=sessie_id, max_age=60 * 60 * 24 * 30)
return antwoord
@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, {})
if sleutel not in mijn.get("berichten", []):
raise HTTPException(status_code=403, detail="Dit is niet jouw bericht")
with SqliteDict("gastenboek.db") as db:
if sleutel in db:
del db[sleutel]
db.commit()
return {"bericht": "Verwijderd"}
@app.delete("/berichten")
async def alles_wissen(sessie_id: str = Cookie(default="")):
with SqliteDict("sessies.db") as sessies:
mijn = sessies.get(sessie_id, {})
with SqliteDict("gastenboek.db") as db:
for sleutel in mijn.get("berichten", []):
if sleutel in db:
del db[sleutel]
db.commit()
return {"bericht": "Jouw berichten zijn gewist"}
Elk endpoint dat iets verwijdert, controleert nu zelf.
Door naar stap 3: in de praktijk.