Ga naar hoofdinhoud

Invoer controleren: een bestandsnaam van een bezoeker

In HTML in bestanden kreeg elke pagina een eigen endpoint met FileResponse. Met tien pagina's wil je liever één endpoint, waarin de bezoeker de pagina kiest met een query-parameter, zoals ?term= in Een waarde na een vraagteken: query-parameters. Maar die naam komt van de bezoeker, net als een formulierveld. Hij kan er meer in zetten dan een paginanaam.

Eén endpoint voor alle pagina's​

Maak naast formulier.html een map static/pages met twee pagina's. Eerst home.html:

<!doctype html>
<html>
<body>
<h1>Welkom in het gastenboek</h1>
</body>
</html>

Maak over.html net zo, met de kop Over dit gastenboek.

FileResponse komt uit fastapi.responses. Zet deze regel bij de imports bovenaan main.py:

from fastapi.responses import FileResponse

Zet dan dit endpoint onder bericht_plaatsen:

@app.get("/pagina")
async def pagina(naam: str):
return FileResponse(f"static/pages/{naam}")
  1. Regel 2:

    naam heeft geen Form en staat niet tussen accolades in het pad, dus FastAPI haalt hem uit het adres, na ?naam=.

  2. Regel 3:

    Het pad naar het bestand, met de naam die de bezoeker koos.

Open http://127.0.0.1:8000/pagina?naam=home.html, en daarna ?naam=over.html. Je ziet de twee koppen.

Probeer ook ?naam=oeps.html, een pagina die er niet is. Je browser toont Internal Server Error, een 500, en in de terminal van je server staat:

RuntimeError: File at path static/pages/oeps.html does not exist.

Het endpoint geeft elke naam door aan FileResponse, ook een naam waar geen bestand bij hoort.

Doe het na, op je eigen server​

In een pad betekent .. de map erboven. Vanuit static/pages is ../.. twee mappen omhoog: de map van je project, waar main.py staat. Een scriptje pagina.py vraagt om dat bestand:

import httpx

antwoord = httpx.get("http://127.0.0.1:8000/pagina?naam=../../main.py")
print(antwoord.status_code)
print(antwoord.text)

Uitvoer, het begin:

200
import time

from fastapi import FastAPI, Form, HTTPException
from fastapi.responses import FileResponse

Je server stuurt zijn eigen code. Zo leest een bezoeker elk bestand waar je server bij kan, zoals gastenboek.db met alle berichten, of in een echt project een bestand met een geheime sleutel. Met .. uit de bedoelde map klimmen heet path traversal.

Alleen wat in je lijst staat​

Een bestandsnaam controleer je net als de stemming in les 7: met een lijst met wat mag.

PAGINAS = ["home.html", "over.html"]

@app.get("/pagina")
async def pagina(naam: str):
if naam not in PAGINAS:
raise HTTPException(status_code=404, detail="Deze pagina bestaat niet")
return FileResponse(f"static/pages/{naam}")
  1. Regel 1:

    De pagina's die een bezoeker mag zien, bovenaan main.py, onder STEMMINGEN.

  2. Regel 5-6:

    Staat de naam niet in de lijst, dan bestaat de pagina voor de bezoeker niet: een 404, zoals in Een 404 sturen. FileResponse komt pas daarna.

Vervang je /pagina door deze. Een script paden.py probeert er drie:

import httpx

for naam in ["home.html", "../../main.py", "oeps.html"]:
antwoord = httpx.get(f"http://127.0.0.1:8000/pagina?naam={naam}")
print(naam, "->", antwoord.status_code)

Je hoeft niet te bedenken welke trucs er met .., / of \ bestaan. Wat niet in je lijst staat, komt er niet door. Met het endpoint van daarnet gaf oeps.html een 500, zoals je zelf zag. Nu is dat een nette 404.

Controlevraag
Iemand vraagt /pagina?naam=./home.html. Wat krijgt hij van je endpoint met PAGINAS?
Kies wat jij denkt. Bij het goede antwoord zie je de uitleg.

Er gaat iets mis​

NameError: name 'FileResponse' is not defined​

http://127.0.0.1:8000/pagina?naam=home.html geeft Internal Server Error, en in de terminal van je server staat:

NameError: name 'FileResponse' is not defined

Oorzaak: de regel from fastapi.responses import FileResponse ontbreekt bovenaan main.py. Je server start wel, maar loopt vast zodra het endpoint draait.

Oplossing: zet de import bovenaan, onder die uit fastapi. Een script dat antwoord.json() doet, stopt bij zo'n 500 met een JSONDecodeError: de echte fout staat in de terminal van je server.

RuntimeError: File at path static/pages/over.html does not exist.​

http://127.0.0.1:8000/pagina?naam=over.html geeft Internal Server Error, en in de terminal van je server staat:

RuntimeError: File at path static/pages/over.html does not exist.

Oorzaak: over.html staat in je lijst, maar het bestand staat niet in static/pages, of het heet daar anders, zoals over-ons.html. De controle laat de naam door, en FileResponse vindt het bestand niet.

Oplossing: zet het bestand in static/pages, met precies de naam uit je lijst.

{"detail":"Deze pagina bestaat niet"} bij een pagina die er wel is​

?naam=home geeft een 404, terwijl home.html wel in static/pages staat.

Oorzaak: in de lijst staat home.html, en home is een andere tekst.

Oplossing: vraag de naam op zoals hij in de lijst staat:

# FOUT
"http://127.0.0.1:8000/pagina?naam=home"

# GOED
"http://127.0.0.1:8000/pagina?naam=home.html"

Meer over FileResponse en de map static/pages: HTML in bestanden.

Opdrachten​

Opdracht 1: Predict - Met een path-parameter​

Zet dit endpoint tijdelijk onder je andere endpoints. De naam staat nu in het pad, en .html plakt de server er zelf achter:

@app.get("/pagina/{naam}")
async def pagina_pad(naam: str):
return FileResponse(f"static/pages/{naam}.html")

Een / in een adres schrijf je als %2F als hij geen scheiding mag zijn. Een script stuurt twee verzoeken:

import httpx

print(httpx.get("http://127.0.0.1:8000/pagina/home").status_code)
print(httpx.get("http://127.0.0.1:8000/pagina/..%2F..%2Fmain").status_code)

Vraag: welke twee statuscodes print het?

Antwoord
200
404

FastAPI maakt van %2F weer een /, en een path-parameter houdt op bij een /. Er is dus geen endpoint dat bij /pagina/../../main past, en FastAPI stuurt {"detail":"Not Found"}.

Dat is geluk en geen controle: het slot zit in hoe FastAPI een endpoint zoekt, niet in jouw code. Met {naam:path}, dat wel een / doorlaat, komt dit verzoek wél in je functie, en zoekt de server buiten static/pages. Je krijgt dan een 500 en niet je code, maar alleen omdat er geen main.html is: ook dat is geluk. Op Windows is ook \ een scheiding in een pad, en een \ past wel in een path-parameter. De lijst werkt in alle gevallen. Haal het endpoint weer weg.

Opdracht 2: Modify - Een derde pagina​

Voeg een pagina contact.html toe, zodat http://127.0.0.1:8000/pagina?naam=contact.html een 200 geeft.

Tip

Er zijn twee plekken: de map static/pages en je main.py.

Antwoord

Maak static/pages/contact.html, net als home.html met een eigen kop, en zet de naam in je lijst:

PAGINAS = ["home.html", "over.html", "contact.html"]

Maak je alleen het bestand, dan krijg je een 404, want de naam staat niet in de lijst. Zet je alleen de naam in de lijst, dan krijg je een 500, want het bestand bestaat niet.

Opdracht 3: Make - In je eigen project​

Open je gastenboek, of een eigen project dat je bouwde. Geef elk formulierveld een grens op de server, en zet in je HTML dezelfde grens als hulp voor de bezoeker. Test het met een script dat een te lang veld stuurt.

Tip

Zoek in je main.py naar Form(...). Elk van die velden krijgt een max_length, en een getal wordt een int met ge en le. Zoek ook naar FileResponse(f": daar kiest een bezoeker misschien een bestand.

Antwoord

Loop deze lijst af:

  • Elk tekstveld heeft max_length in Form.
  • Elk getal is een int met ge en le.
  • Een veld dat niet uit alleen spaties mag bestaan, controleer je met strip() en een 400.
  • Een keuze uit een paar woorden controleer je tegen een lijst.
  • Een bestandsnaam van een bezoeker gaat pas naar FileResponse als hij in je lijst staat. Heeft je project geen endpoint met FileResponse(f", dan sla je dit punt over.
  • Een script dat een te lang veld stuurt, krijgt een 422.

In het gastenboek uit de FastAPI-lessen zet je de grenzen in de parameters die je endpoints al hebben, en de controle op spaties bovenaan de functie. Met de sessies uit Onthouden op de server: sessies komt in POST /gastenboek alleen nog het bericht uit het formulier:

@app.post("/gastenboek")
async def gastenboek_opslaan(
bericht: str = Form(..., max_length=80),
sessie_id: str = Cookie(default=""),
):
if not bericht.strip():
raise HTTPException(status_code=400, detail="Vul een bericht in")

Daaronder blijft je functie zoals hij was. Laat sessie_id niet weg: de rest van de functie gebruikt hem. De naam typt een bezoeker nu bij /registreer; geef naam daar max_length=30, en weiger daar een naam van alleen spaties. Heb je de uitbreiding cookies en sessies nog niet gedaan, dan heeft je POST /gastenboek ook nog naam en wachtwoord, en zet je de grens ook op naam.

Het bericht krijgt hier 80 tekens en geen 280 zoals in deze reeks. Zo past het bij Server of browser?, waar je gastenboek maxlength="80" krijgt; zet die ook in je HTML. Deed je die les al, en staat de if len(bericht) > 80 daaruit nog in je functie, dan mag hij weg: max_length=80 doet hetzelfde, alleen krijgt een te lang bericht nu een 422 in plaats van jouw 400.

Zo ziet je main.py er nu uit
import time

from fastapi import FastAPI, Form, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlitedict import SqliteDict

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

STEMMINGEN = ["blij", "neutraal", "boos"]
PAGINAS = ["home.html", "over.html"]

@app.post("/bericht")
async def bericht_plaatsen(
naam: str = Form(..., max_length=30),
bericht: str = Form(..., max_length=280),
cijfer: int = Form(..., ge=1, le=10),
stemming: str = Form(...),
):
if not naam.strip():
raise HTTPException(status_code=400, detail="Vul een naam in")
if stemming not in STEMMINGEN:
raise HTTPException(status_code=400, detail="Kies blij, neutraal of boos")
with SqliteDict("gastenboek.db") as db:
db[f"bericht_{time.time_ns()}"] = {
"naam": naam,
"bericht": bericht,
"cijfer": cijfer,
"stemming": stemming,
}
db.commit()
return {"lengte": len(bericht), "cijfer": cijfer, "stemming": stemming}

@app.get("/pagina")
async def pagina(naam: str):
if naam not in PAGINAS:
raise HTTPException(status_code=404, detail="Deze pagina bestaat niet")
return FileResponse(f"static/pages/{naam}")

Naast main.py staan static/formulier.html en de map static/pages met home.html en over.html. Een bezoeker kiest nu alleen uit jouw lijst.

Door naar les 9: in de praktijk.