Ga naar hoofdinhoud

Cookies afschermen: httponly

De browser kan een cookie voor scripts afschermen. Dan stuurt hij hem nog wel bij elk verzoek mee, zodat je server de sessie herkent, maar document.cookie laat hem niet meer zien. Je zet het aan met één instelling in set_cookie: httponly.

In inloggen staat de regel die de cookie zet. Voeg httponly=True toe:

antwoord = JSONResponse({"bericht": f"Ingelogd als {naam}"})
antwoord.set_cookie(
key="sessie_id",
value=sessie_id,
max_age=60 * 60 * 24 * 30,
httponly=True,
)
return antwoord
  1. Regel 2-7:

    Dezelfde set_cookie als in Sessies, nu met httponly=True erbij. De naam, de waarde en de houdbaarheid blijven gelijk.

De naam httponly betekent: alleen voor HTTP-verzoeken, niet voor JavaScript. De cookie gaat dus wel over het netwerk mee, maar een script op de pagina kan hem niet lezen.

Doe de test opnieuw​

Log opnieuw in, zodat je een verse cookie krijgt. Open de Console op 127.0.0.1:8000 en typ weer document.cookie. Nu antwoordt de Console met een lege tekst:

Ontwikkelaarstools, tabblad Console geopend
Je typt: document.cookie
De Console antwoordt: ''

In het tabblad App staat sessie_id er nog, en in de kolom HttpOnly staat nu een vinkje:

Ontwikkelaarstools, tabblad App geopend
NaamWaardeHttpOnlySameSite
sessie_id85aa1aca2a96c3430cce6accaeefa4db✓Lax

De browser stuurt de cookie nog steeds mee. Verstuur nog een bericht en klik in het tabblad Netwerk op het verzoek bericht: onder Verzoekheaders staat de regel Cookie met je sessie-id. Alleen een script komt niet meer bij de cookie.

Waar de controle zit​

Hier zit de controle niet in een endpoint, maar in de instelling waarmee de server de cookie zet. De browser houdt zich eraan.

Waar de controle zit: cookies
  1. Hier zit de controleServer: De server zet de sessie-cookie. Met httponly=True in set_cookie. · httponly

  2. Browser: De browser bewaart hem. In het tabblad App staat een vinkje bij HttpOnly.

  3. Browser: Een script vraagt document.cookie. Het krijgt de sessie-cookie niet te zien. · Scripts lezen cookies

  4. Server: Bij een verzoek gaat hij wel mee. De browser stuurt hem in de kop Cookie; je server leest hem met Cookie().

  5. Browser: Het antwoord komt terug. De server heeft je herkend, zonder dat een script het sessie-id zag.

Controlevraag
In je eigen gastenboek sturen de htmx-knoppen hun verzoek vanuit JavaScript. Je zet httponly op de sessie-cookie. Weet je server bij een klik op zo'n knop nog wie je bent?
Kies wat jij denkt. Bij het goede antwoord zie je de uitleg.

Er gaat iets mis​

Je oude sessie-id staat er nog​

document.cookie toont je oude sessie-id nog, ook al heb je httponly=True gezet.

Oorzaak: dit is nog de cookie van vóór de wijziging. httponly geldt vanaf het moment dat de server de cookie zet, en de oude staat al in je browser.

Oplossing: log opnieuw in, zodat de server een nieuwe set_cookie stuurt. Verwijder je de cookie in het tabblad App, dan ben je uitgelogd, en krijg je de nieuwe cookie ook bij de volgende keer inloggen.

Andere cookies dan sessie_id​

document.cookie is niet leeg, maar geeft bijvoorbeeld:

'naam=sara; volgorde=nieuw'

Oorzaak: een cookie hoort bij het adres 127.0.0.1, niet bij de map of de poort. De cookies van eerdere projecten, zoals naam uit Onthouden met een cookie, staan dus nog in je browser. Ze hebben geen httponly, en een script ziet ze. Je sessie_id staat er niet bij: die is wel afgeschermd.

Oplossing: verwijder naam, volgorde en de andere oude cookies in het tabblad App, onder Cookies bij http://127.0.0.1:8000. Of open de pagina in een privévenster en log daar opnieuw in: daar begint de browser zonder cookies.

Opdrachten​

Opdracht 1: Predict - Twee cookies​

Je hebt httponly=True gezet en opnieuw ingelogd. Op de pagina met het formulier typ je in de Console eerst:

document.cookie = "thema=donker"

Die regel maakt een tweede cookie, zonder httponly. Daarna typ je weer document.cookie.

Vraag: wat antwoordt de Console nu?

Tip

httponly geldt per cookie, niet voor alle cookies van de site.

Antwoord

'thema=donker'. Het script ziet de cookie zonder httponly, en de sessie-cookie niet. In het tabblad App staan ze allebei, sessie_id met een vinkje bij HttpOnly en thema zonder.

Opdracht 2: Investigate - Waarom stuurt de browser hem nog mee?​

Met httponly kan een script de cookie niet lezen. Toch weet je server na een bericht nog wie je bent. Hoe kan dat?

Antwoord

httponly schermt de cookie alleen af voor scripts. De browser zelf stuurt hem bij elk verzoek naar 127.0.0.1:8000 mee, in de kop Cookie. Je server leest hem daaruit met sessie_id: str = Cookie(default=""), net als eerst. Alleen JavaScript is buitengesloten.

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 fastapi.staticfiles import StaticFiles
from sqlitedict import SqliteDict

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")

@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,
httponly=True,
)
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"}

@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:
van_mij = []
for sleutel, bericht in db.items():
if bericht["naam"] == mijn.get("naam"):
van_mij.append(sleutel)
for sleutel in van_mij:
del db[sleutel]
db.commit()
return {"bericht": "Jouw berichten zijn gewist"}

De sessie-cookie staat nu buiten bereik van scripts.

Door naar les 4: samesite.