Palvelinpuolen rajapinnat: fetch, CORS ja htmx
Aiheen toinen osa: Avoimet yhteydet: SSE ja WebSocket
TIEA2120-kurssin luennolla Fetch ja asynkroniset funktiot käsiteltiin, miten javascript-ohjelma tekee pyyntöjä palvelimelle ja käsittelee vastauksia. Tällä luennolla katsotaan saman asian toista puolta: miten Flask-sovellus vastaa fetch-pyyntöihin niin, että selaimen koodi voi luottaa vastauksiin, sovellus toimii myös ilman javascriptiä ja rajapinta on turvallinen. Luennon asioita voi kokeilla esimerkkisovelluksessa.
Tekniikkaa kutsuttiin aikanaan nimellä Ajax (Asynchronous JavaScript and XML). Nimi on jäänyt käyttöön, vaikka XMLHttpRequest on korvautunut fetchillä ja XML useimmiten JSONilla.
Luennon toinen osa, Avoimet yhteydet: SSE ja WebSocket, jatkaa tästä: siinä käsitellään pitkäkestoiset yhteydet, joissa palvelin lähettää tietoa selaimelle ilman erillistä pyyntöä.
Selaimen ja palvelimen työnjako
Palvelin ei tiedä, tuleeko pyyntö fetchiltä, tavalliselta lomakkeelta vai komentoriviltä. Se näkee vain HTTP-pyynnön: metodin, osoitteen, HTTP-otsakkeet ja rungon. Siksi samat säännöt pätevät kaikkiin pyyntöihin, ja palvelin päättää vastauksesta kolme asiaa:
- tilakoodin, josta selaimen koodi päättelee, onnistuiko pyyntö
(
vastaus.okjavastaus.status) - mediatyypin
Content-Type-otsakkeessa, josta selaimen koodi tietää, miten runko luetaan - rungon eli varsinaisen sisällön
Accept). Vastauksessa palvelin kertoo tilakoodin, mediatyypin ja rungon. Yhtenäinen nuoli on pyyntö, katkoviiva vastaus.Mediatyyppi
WWW-palvelin päättelee tavallisten tiedostojen mediatyypin tiedostopäätteestä,
mutta oman sovelluksen on ilmoitettava se itse. Jos JSON-data palautetaan tyypillä
text/html, selaimen vastaus.json() toimii kyllä, mutta
kehittäjätyökalut, välimuistit ja muut ohjelmat tulkitsevat vastauksen väärin.
| Mediatyyppi | Sisältö |
|---|---|
application/json | JSON-data |
text/html | HTML-dokumentti tai -palanen |
application/xhtml+xml | XHTML-dokumentti tai -palanen |
application/xml | muu XML-data (vanhempi muoto
text/xml) |
text/plain | pelkkä teksti |
text/css, text/javascript |
tyylit ja skriptit |
application/octet-stream |
binääridata, jolle ei ole omaa tyyppiä |
Kaikki rekisteröidyt tyypit löytyvät IANA:n
luettelosta. Tekstimuotoisille tyypeille kerrotaan lisäksi merkistö:
application/xhtml+xml; charset=utf-8.
JSON-rajapinta Flaskilla
Vastauksen palauttaminen
Kun näkymäfunktio palauttaa sanakirjan tai listan, Flask muuntaa sen JSONiksi ja
asettaa mediatyypiksi application/json. Tilakoodi annetaan tarvittaessa
toisena ja HTTP-otsakkeet kolmantena arvona:
POSTINUMEROT = {"40100": "Jyväskylä", "00100": "Helsinki", "33100": "Tampere"}
@app.get("/api/postinumero/<numero>")
def postinumero(numero):
toimipaikka = POSTINUMEROT.get(numero)
if toimipaikka is None:
return {"virhe": "Tuntematon postinumero"}, 404
return {"postinumero": numero, "toimipaikka": toimipaikka}
Tällainen rajapinta sopii esimerkiksi lomakkeelle, joka täyttää postitoimipaikan heti, kun käyttäjä on kirjoittanut postinumeron.
Pyynnön tietojen lukeminen
Flask lukee pyynnön tiedot eri paikoista sen mukaan, miten selain ne lähetti:
| Selain lähettää | Pyynnön Content-Type |
Flask lukee |
|---|---|---|
kyselyparametrit osoitteessa (?haku=…) |
— | request.args |
tavallinen lomake tai URLSearchParams |
application/x-www-form-urlencoded |
request.form |
FormData tai lomake, jossa on tiedostoja |
multipart/form-data |
request.form ja request.files |
JSON.stringify(…) | application/json |
request.get_json() |
Kentällä voi olla useita arvoja, esimerkiksi valintaruuduilla, joilla on sama nimi.
request.form.get("valinta") palauttaa niistä vain ensimmäisen,
request.form.getlist("valinta") kaikki.
request.get_json() on tiukka, ja se on hyvä asia:
- jos pyynnön mediatyyppi ei ole
application/json, Flask vastaa tilakoodilla 415 Unsupported Media Type - jos runko ei ole kelvollista JSONia, Flask vastaa tilakoodilla 400 Bad Request
Kelvollinen JSON ei silti välttämättä ole sitä, mitä odotettiin: runko voi olla lista, luku tai merkkijono. Tarkista siksi myös tyyppi ennen kuin käytät tietoja.
Tilakoodit ja virheet
Selaimen koodi päättää tilakoodin perusteella, mitä vastaukselle tehdään.
TIEA2120-luennon haeJson-funktio heittää poikkeuksen aina, kun
vastaus.ok on epätosi. Jos palvelin palauttaa virheilmoituksen
tilakoodilla 200, selaimen koodi luulee pyynnön onnistuneen.
| Koodi | Käyttö |
|---|---|
| 200 OK | pyyntö onnistui, vastauksessa on dataa |
| 201 Created | uusi kohde luotiin; sen osoite
Location-otsakkeessa |
| 204 No Content | onnistui, mutta vastauksessa ei ole runkoa (esimerkiksi poisto) |
| 400 Bad Request | pyyntö on rakenteeltaan virheellinen |
| 401 Unauthorized | käyttäjä ei ole kirjautunut |
| 403 Forbidden | käyttäjä on kirjautunut, mutta hänellä ei ole oikeutta |
| 404 Not Found | kohdetta ei ole |
| 405 Method Not Allowed | osoite ei hyväksy metodia |
| 409 Conflict | pyyntö on ristiriidassa nykytilan kanssa, esimerkiksi nimi on jo käytössä |
| 413 Content Too Large | pyyntö on liian suuri |
| 415 Unsupported Media Type | väärä
Content-Type |
| 422 Unprocessable Content | pyyntö on rakenteeltaan kunnossa, mutta tiedot eivät kelpaa (kenttäkohtaiset virheet) |
| 500 Internal Server Error | virhe palvelimen koodissa |
Kts. MDN: HTTP response status codes.
Yhtenäinen virhemuoto
Selaimen koodia on helpompi kirjoittaa, jos jokainen virhe palautetaan samassa
muodossa. Tässä luennossa käytetään muotoa, jossa virhe on aina mukana
ja kenttäkohtaiset virheet ovat tarvittaessa kentat-sanakirjassa:
{"virhe": "Tarkista tiedot",
"kentat": {"nimi": "Nimi puuttuu.", "postinumero": "Postinumerossa on viisi numeroa."}}
Flaskin omat virhesivut, kuten 404 ja 405, ovat oletuksena HTML:ää. Rajapinnan osoitteissa ne kannattaa muuttaa JSONiksi virheenkäsittelijällä:
from werkzeug.exceptions import HTTPException
@app.errorhandler(HTTPException)
def virhe(e):
if request.path.startswith("/api/"):
return {"virhe": e.description}, e.code
return e # muualla Flaskin tavallinen virhesivu
Nyt myös olemattomaan rajapinnan osoitteeseen tehty pyyntö saa JSON-vastauksen, ja
selaimen koodi voi lukea virheen samalla tavalla kuin kaikki muutkin. Werkzeugin
valmiit virhekuvaukset ovat englanniksi; omat kuvaukset annetaan kutsulla
abort(404, "Tilausta ei löydy").
Tietojen tarkistaminen palvelimella
Selaimessa tehty tarkistus on käyttömukavuutta, ei tietoturvaa. Javascriptin voi
ohittaa, ja pyynnön voi lähettää suoraan esimerkiksi curl-komennolla.
Palvelin tarkistaa siksi aina kaiken, mitä se vastaanottaa.
Tarkistus kannattaa kirjoittaa omaksi funktiokseen, joka palauttaa kenttäkohtaiset
virheet. Sama funktio käy sekä tavalliselle lomakkeelle (request.form)
että JSON-rajapinnalle (request.get_json()), koska kumpikin toimii kuin
sanakirja:
def tarkista_tilaus(tiedot):
"""Palauttaa kenttäkohtaiset virheet. Tyhjä sanakirja = ei virheitä."""
virheet = {}
if not tiedot.get("nimi", "").strip():
virheet["nimi"] = "Nimi puuttuu."
numero = tiedot.get("postinumero", "").strip()
if not (len(numero) == 5 and numero.isdigit()):
virheet["postinumero"] = "Postinumerossa on viisi numeroa."
elif numero not in POSTINUMEROT:
virheet["postinumero"] = "Tuntematon postinumero."
return virheet
@app.post("/api/tilaukset")
def uusi_tilaus():
tiedot = request.get_json()
if not isinstance(tiedot, dict):
return {"virhe": "Odotettiin JSON-objektia"}, 400
virheet = tarkista_tilaus(tiedot)
if virheet:
return {"virhe": "Tarkista tiedot", "kentat": virheet}, 422
tilaus = tallenna_tilaus(tiedot)
return tilaus, 201, {"Location": url_for("tilaus", tid=tilaus["id"])}
Rajapinnan voi kokeilla komentoriviltä ilman selainta. Valitsin -i
näyttää myös tilakoodin ja HTTP-otsakkeet:
curl -i -X POST http://localhost:5000/api/tilaukset \
-H "Content-Type: application/json" \
-d '{"nimi": "", "postinumero": "4010"}'
Progressiivinen parantaminen
Sovelluksen pitää toimia myös ilman javascriptiä: skripti voi jäädä latautumatta, se voi kaatua virheeseen, tai käyttäjä on estänyt sen. Progressiivisessa parantamisessa (engl. progressive enhancement) lomake toimii ensin tavallisena HTML-lomakkeena, ja javascript vain parantaa käyttökokemusta: sivu ei lataudu uudelleen, ja virheet näkyvät heti.
Palvelimelle tämä tarkoittaa, että sama osoite vastaa kahdella tavalla. Tavallinen
lomake saa HTML-sivun tai uudelleenohjauksen, fetch saa JSONia. Selain kertoo
toiveensa Accept-otsakkeessa:
def haluaa_json():
paras = request.accept_mimetypes.best_match(["text/html", "application/json"])
return paras == "application/json"
@app.route("/tilaus", methods=["GET", "POST"])
def tilauslomake():
if request.method == "GET":
return render_template("tilaus.xhtml", tiedot={}, virheet={})
virheet = tarkista_tilaus(request.form)
if haluaa_json():
if virheet:
return {"virhe": "Tarkista tiedot", "kentat": virheet}, 422
tallenna_tilaus(request.form)
return {"viesti": "Tilaus vastaanotettu"}, 201
if virheet:
return render_template("tilaus.xhtml", tiedot=request.form, virheet=virheet), 422
tallenna_tilaus(request.form)
return redirect(url_for("kiitos"), code=303)
Fetchin oletusarvoinen HTTP-otsake on Accept: */*, joka hyväksyy minkä tahansa
tyypin. Yllä oleva palvelin palauttaa silloin HTML-sivun, ja selaimen
vastaus.json() epäonnistuu. JSON-vastausta haluavan fetch-pyynnön on
siksi lähetettävä HTTP-otsake Accept: application/json.
Selaimen puolella skripti ottaa lomakkeen lähetyksen haltuunsa ja lähettää saman lomakkeen samaan osoitteeseen fetchillä. Jos skripti ei lataudu, lomake lähtee tavalliseen tapaan:
const lomake = document.getElementById("tilaus");
lomake.addEventListener("submit", async function (e) {
e.preventDefault();
const tila = document.getElementById("tila");
for (const virhe of lomake.querySelectorAll(".virhe")) {
virhe.textContent = "";
}
try {
const vastaus = await fetch(lomake.action, {
method: "POST",
headers: { "Accept": "application/json" },
body: new FormData(lomake)
});
const tulos = await vastaus.json();
if (vastaus.status === 422) {
for (const [kentta, viesti] of Object.entries(tulos.kentat)) {
document.getElementById(`virhe-${kentta}`).textContent = viesti;
}
tila.textContent = tulos.virhe;
}
else if (!vastaus.ok) {
throw new Error(tulos.virhe ?? `HTTP-virhe ${vastaus.status}`);
}
else {
tila.textContent = tulos.viesti;
lomake.reset();
}
}
catch (virhe) {
tila.textContent = `Lähetys epäonnistui: ${virhe.message}`;
}
});
Virheilmoitusten elementeissä on attribuutti aria-live="polite":
<span class="virhe" id="virhe-nimi" aria-live="polite">{{ virheet.nimi }}</span>
Ilman sitä ruudunlukija ei kerro käyttäjälle sisällöstä, joka ilmestyy sivulle ilman sivun latautumista. Kts. MDN: aria-live.
JSON vai HTML-palanen?
Palvelin voi palauttaa fetch-pyyntöön myös valmiin HTML-palasen. Se muodostetaan samalla Jinja-templatella kuin muukin sivu, joten näkymän rakennetta ei tarvitse kirjoittaa kahteen kertaan, Pythoniin ja javascriptiin.
| Ominaisuus | JSON | HTML-palanen |
|---|---|---|
| Näkymän rakenne | javascriptissä | Jinja-templatessa |
| Escapaus | selaimen koodissa (textContent) |
Jinja hoitaa sen palvelimella |
| Muut käyttäjät | toimii myös mobiilisovelluksille ja muille ohjelmille | sopii vain tälle sivulle |
| Sopii | datalle, jota käsitellään selaimessa | sivun osan päivittämiseen |
XHTML-palasen upottaminen
XHTML-sivulle upotettavan palasen on oltava hyvinmuodostunutta XML:ää, ja sen juurielementissä on oltava XHTML-nimiavaruus:
<ul xmlns="http://www.w3.org/1999/xhtml" class="nimet">
{% for n in nimet %}<li>{{ n }}</li>{% endfor %}
</ul>
Selaimessa palanen jäsennetään DOMParser-luokalla ja tuodaan sivun
dokumenttiin importNode-metodilla ennen upottamista:
const vastaus = await fetch("/api/palanen");
if (!vastaus.ok) {
throw new Error(`HTTP-virhe ${vastaus.status}`);
}
const teksti = await vastaus.text();
const palanen = new DOMParser().parseFromString(teksti, "application/xhtml+xml");
if (palanen.querySelector("parsererror")) {
throw new Error("Virheellinen XHTML");
}
const solmu = document.importNode(palanen.documentElement, true);
document.getElementById("kohde").replaceChildren(solmu);
Jos palasen juurielementistä puuttuu xmlns-määritys, jäsennetyt
elementit eivät kuulu mihinkään nimiavaruuteen. Ne näkyvät sivun DOM-puussa, mutta
selain ei tunnista niitä HTML-elementeiksi: ul- ja
li-elementit piirretään tavallisena tekstinä ilman luettelomerkkejä,
eikä CSS-tyyleissä valitsin li osu niihin.
htmx
htmx on pieni javascript-kirjasto, joka vie edellisen luvun ajatuksen loppuun asti: selaimen ja palvelimen välillä liikkuu pelkkää HTML:ää, eikä sovellukseen kirjoiteta omaa javascriptiä lainkaan. Pyynnöt syntyvät HTML-attribuuteista:
<form hx-post="/varaus" hx-target="#lomake-alue" hx-swap="outerHTML">
…kentät…
</form>
<button hx-delete="/varaus/7" hx-target="closest li" hx-swap="delete"
hx-confirm="Poistetaanko varaus?">Poista</button>
hx-get,hx-post,hx-deletekertovat metodin ja osoitteenhx-targetkertoo, mihin elementtiin vastaus sijoitetaanhx-swapkertoo miten:innerHTML,outerHTML,append,delete,nonehx-triggerkertoo, mikä tapahtuma pyynnön laukaisee; oletus on lomakkeella lähetys ja painikkeella klikkaus
Mikä muuttuu palvelimella
Palvelin palauttaa HTML-palasia JSONin sijaan, joten tämän luennon säännöt pätevät sellaisinaan: tilakoodit, mediatyyppi ja tietojen tarkistaminen ovat samat. Kolme asiaa on kuitenkin htmx:lle ominaista.
htmx-pyynnön tunnistaminen. htmx lähettää jokaisessa pyynnössä
HTTP-otsakkeen HX-Request: true. Se korvaa aiemman
Accept-otsakkeen tarkistuksen, ja sama osoite voi edelleen palvella sekä
tavallista lomaketta että htmx:ää:
def htmx_pyynto():
return request.headers.get("HX-Request") == "true"
@app.post("/varaus")
def uusi_varaus():
virheet = tarkista_varaus(request.form)
...
if not htmx_pyynto(): # tavallinen lomake
if not virheet:
return redirect(url_for("etusivu"), code=303)
return render_template("etusivu.html", tiedot=request.form, virheet=virheet), 422
return render_template("lomakevastaus.html", tiedot=tiedot, virheet=virheet), koodi
Virhevastaukset sijoitetaan sivulle. htmx 4 sijoittaa kaikki vastaukset tilakoodista riippumatta; vain 204 ja 304 eivät aiheuta muutosta. Luennon 422-kuvio toimii siis suoraan: palvelin palauttaa lomakkeen virheilmoituksineen, ja htmx sijoittaa sen vanhan lomakkeen tilalle. htmx 2 jätti 4xx-vastaukset huomiotta, joten siinä sama vaatii erillisen asetuksen. Yksittäisen tilakoodin käsittelyn voi ohjata attribuutilla:
<button hx-delete="/varaus/7" hx-target="closest li" hx-swap="delete"
hx-status:403="target:#viesti swap:outerHTML">Poista</button>
Useampi kohta yhdellä vastauksella. Kun varaus tallentuu, sivulla
pitää päivittyä sekä lomake että varausluettelo. htmx 4:ssä vastaukseen lisätään
hx-partial-elementtejä, joilla on oma kohde ja oma sijoitustapa:
<div id="lomake-alue">…tyhjennetty lomake ja viesti…</div>
<hx-partial hx-target="#luettelo" hx-swap="innerHTML">
<ul id="varaukset">…päivitetty luettelo…</ul>
</hx-partial>
CSRF-tunniste ja istunto
htmx lähettää evästeet samoin kuin fetch, joten istunto toimii sellaisenaan.
CSRF-tunniste liitetään jokaiseen pyyntöön yhdellä attribuutilla. htmx 4:ssä
periytyminen on eksplisiittistä, joten attribuutin perään kirjoitetaan
:inherited:
<body hx-headers:inherited='{"X-CSRF-Token": "{{ csrf_tunniste() }}"}'>
Huomaa, että htmx 4:ssä hx-delete ei enää lähetä ympäröivän lomakkeen
kenttiä, joten piilokenttään tallennettu tunniste ei riitä poistopyynnössä.
htmx vertaa elementtien nimiä isoilla kirjaimilla
(elt.tagName === 'FORM'). HTML-dokumentissa tagName on
isoilla kirjaimilla, mutta mediatyypillä application/xhtml+xml
palvellussa dokumentissa se on sellaisena kuin se on kirjoitettu eli pienillä.
Silloin htmx ei tunnista lomaketta eikä estä sen tavallista lähetystä: lomake
lähtee kahdesti ja sivu latautuu uudelleen. Sama koskee sekä htmx 2:ta että
htmx 4:ää. Painikkeet ja linkit toimivat kyllä XHTML-sivullakin.
Jos käytät htmx:ää, tarjoile sivut tyypillä text/html. Merkkauksen
voi silti kirjoittaa polyglottina XHTML5:nä, jolloin se kelpaa myös
XHTML-validaattorille.
htmx XHTML-dokumentissa
Jos mediatyypin on pakko olla application/xhtml+xml, htmx:ää voi
silti käyttää, kunhan tietää, mikä toimii ja mikä ei. Kokeilin seuraavat htmx 4:llä
XHTML-sivulla:
| Tapa | Tulos |
|---|---|
Painike tai linkki, jossa hx-get |
toimii; palanen sijoittuu sivulle oikeaan XHTML-nimiavaruuteen |
Lomake, jossa hx-post |
lomake lähtee kahdesti: htmx tekee pyynnön ja selain lähettää lomakkeen tavalliseen tapaan |
| Lomake ja tavallisen lähetyksen esto (alla) | toimii; kentät menevät perille |
Painike, jossa hx-post ja hx-include |
pyyntö lähtee, mutta kentät jäävät tyhjiksi |
Lomakkeet saa toimimaan kolmella rivillä javascriptiä, joka estää tavallisen lähetyksen kaikilta htmx-lomakkeilta:
document.addEventListener("submit", function (e) {
// window.htmx varmistaa, ettei lähetystä estetä, jos htmx jäi lataamatta
if (window.htmx && e.target.matches("[hx-post], [hx-put], [hx-patch], [hx-delete]")) {
e.preventDefault(); // htmx ei tee tätä XHTML-dokumentissa
}
});
Progressiivinen parantaminen säilyy: ilman javascriptiä skriptiä ei suoriteta
lainkaan, joten lomake lähtee tavalliseen tapaan palvelimelle. Ehto
window.htmx kattaa myös tilanteen, jossa skripti latautuu mutta htmx ei:
ilman sitä lomakkeen lähetys estettäisiin eikä kukaan tekisi pyyntöä.
Versiot
htmx:n vakaa versio on 2.0, ja 4.0 julkaistiin
elokuussa 2026. Nelosessa moni asia muuttui: virhevastaukset sijoitetaan sivulle,
periytyminen on eksplisiittistä, hx-partial ja hx-status
ovat uusia, ja osa attribuuteista nimettiin uudelleen. Verkosta löytyvät ohjeet
koskevat useimmiten kakkosta, joten tarkista aina, kumpaa versiota ohje käsittelee.
Tämän luennon esimerkki käyttää nelosta.
Milloin htmx kannattaa? Se sopii sovellukseen, jonka näkymät
muodostetaan palvelimella ja jossa halutaan päivittää sivun osia ilman omaa
javascriptiä. JSON-rajapintaa tarvitaan silti, jos samaa dataa käyttävät myös
mobiilisovellus tai muut ohjelmat. htmx ei myöskään korvaa progressiivista
parantamista: sivu toimii ilman javascriptiä vain, jos lomakkeissa on
hx-post-attribuutin rinnalla tavallinen action.
Kirjautuminen ja evästeet
Fetch lähettää samaan lähteeseen tehtyjen pyyntöjen mukana evästeet oletuksena. Flaskin sessio toimii siksi fetch-pyynnöissä samalla tavalla kuin tavallisilla sivuilla, eikä selaimen koodin tarvitse tehdä mitään.
Kirjautumista vaativan rajapinnan on kuitenkin vastattava kirjautumattomalle eri
tavalla kuin tavallisen sivun. Tavallinen sivu ohjaa kirjautumissivulle, mutta
fetch seuraa uudelleenohjausta ja saa vastaukseksi kirjautumissivun HTML:n
tilakoodilla 200. Selaimen koodi luulee pyynnön onnistuneen, ja
vastaus.json() epäonnistuu. Rajapinta palauttaa siksi tilakoodin 401:
def vaatii_kirjautumisen_api(funktio):
@wraps(funktio)
def kaare(*args, **kwargs):
if "kayttaja" not in session:
return {"virhe": "Kirjaudu ensin"}, 401
return funktio(*args, **kwargs)
return kaare
@app.get("/api/omat")
@vaatii_kirjautumisen_api
def omat():
return {"kayttaja": session["kayttaja"]}
CSRF-hyökkäykset
Koska selain lähettää evästeet automaattisesti, pahantahtoinen sivusto voi yrittää saada käyttäjän selaimen lähettämään pyynnön sovellukseen käyttäjän tietämättä. Pyyntö kulkee käyttäjän istunnon evästeen kanssa, ja palvelin luulee sitä käyttäjän omaksi. Hyökkäyksen nimi on Cross-Site Request Forgery (CSRF). Se koskee sekä tavallisia lomakkeita että JSON-rajapintoja.
SameSite-eväste
Ensimmäinen suoja on evästeen SameSite-attribuutti. Arvolla
Lax selain ei lähetä evästettä toisen sivuston POST-lomakkeen tai
fetch-pyynnön mukana. Kaikki selaimet eivät käytä sitä oletuksena, joten se
kannattaa asettaa itse:
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
CSRF-tunniste
Varsinainen suoja on satunnainen tunniste, joka tallennetaan käyttäjän sessioon ja upotetaan sovelluksen omille sivuille. Palvelin hyväksyy tietoja muuttavan pyynnön vain, jos sen mukana tulee sama tunniste. Toinen sivusto ei voi lukea tunnistetta sovelluksen sivuilta, joten se ei voi lähettää sitä.
import secrets
def csrf_tunniste():
if "csrf" not in session:
session["csrf"] = secrets.token_urlsafe(32)
return session["csrf"]
app.jinja_env.globals["csrf_tunniste"] = csrf_tunniste
@app.before_request
def tarkista_csrf():
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
lahetetty = request.headers.get("X-CSRF-Token") or request.form.get("csrf", "")
odotettu = session.get("csrf", "")
if not lahetetty or not secrets.compare_digest(lahetetty.encode(), odotettu.encode()):
abort(403, "CSRF-tunniste puuttuu tai on väärä")
Tavallisella lomakkeella tunniste on piilokentässä, jolloin
new FormData(lomake) ottaa sen automaattisesti mukaan. JSON-pyynnöille
tunniste upotetaan sivun meta-elementtiin ja lähetetään HTTP-otsakkeessa:
<input type="hidden" name="csrf" value="{{ csrf_tunniste() }}" />
<meta name="csrf-token" content="{{ csrf_tunniste() }}" />
const tunniste = document.querySelector('meta[name="csrf-token"]').content;
const vastaus = await fetch("/api/tilaukset", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": tunniste },
body: JSON.stringify({ nimi: "Ada", postinumero: "40100" })
});
secrets.compare_digest vertaa merkkijonot ajassa, joka ei riipu siitä,
kuinka monta merkkiä täsmää, joten tunnistetta ei voi arvata vastausaikoja mittaamalla.
Valmis toteutus on Flask-WTF:n
CSRFProtect, joka tarkistaa sekä lomakkeiden piilokentät että HTTP-otsakkeen
X-CSRFToken.
Toinen sivusto ei voi lähettää lomakkeella application/json-tyyppistä
pyyntöä, mutta se voi lähettää JSON-muotoisen rungon tyypillä
text/plain. Jos palvelin lukee rungon mediatyypistä välittämättä,
esimerkiksi kutsulla request.get_json(force=True), suoja on poissa.
Käytä CSRF-tunnistetta äläkä luota pelkkään mediatyyppiin.
CORS palvelimen näkökulmasta
TIEA2120-luennolla todettiin, että palvelin päättää, kuka saa lukea sen vastauksia, ja selain valvoo päätöstä. Omassa sovelluksessa, jonka sivut ja rajapinta ovat samassa lähteessä, CORSia ei tarvita lainkaan. Sitä tarvitaan, kun rajapintaa käytetään toisesta lähteestä, esimerkiksi kun sivut ovat users.jyu.fi-palvelimella ja rajapinta Cloud Runissa.
HTTP-otsakkeet
Access-Control-Allow-Originkertoo, mistä lähteestä vastauksen saa lukea. Arvo on joko*tai yksi lähde, ei luettelo.Access-Control-Allow-Credentials: truesallii evästeiden käytön. Tämä ei ole mahdollista yhdessä arvon*kanssa, joten kirjautumista vaativa rajapinta joutuu ilmoittamaan lähteen nimeltä.- Evästeet lähtevät toiseen lähteeseen vain, jos fetch-kutsussa on
credentials: "include". Jos lähteet ovat lisäksi eri sivustoja, kuten users.jyu.fi ja run.app,SameSite=Lax-eväste ei lähde lainkaan: evästeen on oltavaSameSite=None; Secure. Silloin SameSite ei enää suojaa CSRF-hyökkäyksiltä, ja CSRF-tunniste on ainoa suoja. - Selaimet estävät kolmannen osapuolen evästeitä. Vaikka palvelin ja protokolla sallisivat sivustorajan ylittävän evästeen, selain voi silti jättää sen lähettämättä: useissa selaimissa kolmannen osapuolen evästeet ovat oletuksena estettyjä. Estosta tulee tyypillisesti ilmoitus konsoliin, ja pyyntö menee perille kirjautumattomana. Evästeisiin nojaavan rajapinnan kannattaa siksi olla samalla sivustolla kuin sivut; muuten tunnistautuminen on hoidettava tunnisteella, joka lähetetään HTTP-otsakkeessa.
- Esitarkistuspyyntöön (
OPTIONS) vastataan lisäksi HTTP-otsakkeillaAccess-Control-Allow-Methods,Access-Control-Allow-HeadersjaAccess-Control-Max-Age, joka kertoo, kuinka kauan selain saa muistaa luvan.
OPTIONS-pyynnöllä. Varsinaisen vastauksen javascript saa lukea vain, jos senkin HTTP-otsakkeet sallivat sen.Koska sallittuja lähteitä voi olla useita mutta HTTP-otsakkeeseen mahtuu vain yksi,
palvelin tarkistaa pyynnön Origin-otsakkeen sallittujen luettelosta ja
palauttaa sen, jos se on sallittu. Vary: Origin kertoo välimuisteille,
että vastaus riippuu pyytäjästä:
SALLITUT_LAHTEET = {"https://users.jyu.fi", "http://localhost:5173"}
@app.after_request
def cors(vastaus):
lahde = request.headers.get("Origin")
if request.path.startswith("/api/") and lahde in SALLITUT_LAHTEET:
vastaus.headers["Access-Control-Allow-Origin"] = lahde
vastaus.headers["Access-Control-Allow-Credentials"] = "true"
if request.method == "OPTIONS":
vastaus.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE"
vastaus.headers["Access-Control-Allow-Headers"] = "Content-Type, X-CSRF-Token"
vastaus.headers["Access-Control-Max-Age"] = "600"
vastaus.vary.add("Origin")
return vastaus
Flask vastaa OPTIONS-pyyntöihin automaattisesti, joten
esitarkistukselle ei tarvita omaa reittiä. Valmis toteutus samasta on
Flask-CORS:
from flask_cors import CORS
CORS(app, resources={r"/api/*": {"origins": ["https://users.jyu.fi"]}},
supports_credentials=True)
CORSin riskit
- Käytä arvoa
*vain julkiselle datalle ja nimettyjä lähteitä kaikelle muulle. Arvo*sopii rajapinnalle, jonka data on muutenkin julkista, kuten aikataulu- tai postinumerohaulle. - Älä palauta pyynnön
Origin-otsaketta tarkistamatta. Jos palvelin kopioi minkä tahansa lähteen vastaukseen ja sallii evästeet, mikä tahansa sivusto voi lukea kirjautuneen käyttäjän tiedot. Tämä on CORS-asetusten vaarallisin virhe. - CORS ei suojaa palvelinta. Selain estää vain vastauksen lukemisen; pyyntö on jo käsitelty palvelimella. CORS ei siis korvaa CSRF-tunnistetta.
Turvallinen julkaisu
- Debug-tila pois.
app.run(debug=True)näyttää virhetilanteessa Werkzeugin virheenjäljittimen, jonka kautta pahimmillaan voi ajaa koodia palvelimella. Julkaistussa sovelluksessa debug-tila ei saa koskaan olla päällä. - Virheet ilman sisäisiä tietoja. Rajapinnan virheilmoitus kertoo, mikä pyynnössä oli vialla, mutta ei tietokantakyselyjä, tiedostopolkuja tai poikkeusten jäljitystietoja.
- Pyynnön koon rajoitus.
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024hylkää liian suuret pyynnöt tilakoodilla 413 ennen kuin sovellus yrittää käsitellä niitä. - Ei salaisuuksia javascriptiin. Kaikki selaimelle lähetetty on käyttäjän luettavissa: javascript-tiedostot, JSON-vastaukset ja HTML-lähdekoodi. Salasanat, API-avaimet ja tietokantakyselyt kuuluvat palvelimelle.
Kts. myös OWASP AJAX Security Cheat Sheet.
Vianetsintä
Selaimen kehittäjätyökalujen Network-välilehti näyttää jokaisen pyynnön
ja vastauksen: tilakoodin, HTTP-otsakkeet ja rungon. Siitä näkee nopeasti, lähtikö pyyntö
oikeaan osoitteeseen, oliko mukana Accept- ja CSRF-otsake, ja palauttiko
palvelin JSONia vai HTML-virhesivun. Esitarkistuspyynnöt näkyvät omina
OPTIONS-riveinään.
Palvelinpuolen voi testata erikseen curl-komennolla, jolloin
selaimen koodi ei ole mukana lainkaan. Jos rajapinta toimii curlilla mutta ei
selaimessa, vika on selaimen koodissa tai CORS-asetuksissa.
Esimerkkisovellus
Kokoushuoneiden varaus on pieni Flask-sovellus, jossa luennon asiat näkyvät yhdellä sivulla. Sivulla on luettelo tulevista varauksista ja lomake uuden varauksen tekemiseen. Kirjautuminen on tarkoituksella yksinkertainen: käyttäjä valitaan luettelosta ilman salasanaa.
Kokeiltavat sovellukset:
- Varaussovellus (fetch) — lomake ja luettelo, liikenneloki sivun alalaidassa
- Varaussovellus (htmx) — sama toiminnallisuus ilman omaa javascriptiä
- Infonäyttö — hakee varaukset toisesta lähteestä toistuvalla pyynnöllä, eli CORS-esimerkki
Lähdekoodit, testit ja julkaisuohjeet:
- varaus-esimerkki.zip — varaussovellus, fetch-versio
- varaus-htmx.zip — varaussovellus, htmx-versio
- infonaytto.zip — infonäyttö, joka hakee varaukset toistuvalla pyynnöllä
Sivun alalaidassa on avattava liikenneloki, joka näyttää jokaisesta fetch-pyynnöstä saman tiedon kuin tämän luennon kaaviot: metodin, osoitteen, HTTP-otsakkeet ja rungon sekä vastauksen tilakoodin, HTTP-otsakkeet ja rungon. Kokeile esimerkiksi:
- Kirjaudu ja tee varaus. Lokista näkyy, miten
POST /varauslähtee HTTP-otsakkeellaAccept: application/jsonja palvelin vastaa tilakoodilla 201 jaLocation-otsakkeella. Sen jälkeen sivu hakee varausluettelon XHTML-palasena. - Tee sama varaus uudelleen: päällekkäisyydestä tulee 409 eikä 422, koska pyyntö on rakenteeltaan kunnossa mutta ristiriidassa tietokannan tilan kanssa. Tyhjennä otsikko ja lähetä: kenttäkohtaiset virheet tulevat 422-vastauksen mukana.
- Kokeile liikennelokin kahta painiketta. Ilman Accept-otsaketta palvelin
palauttaa HTML-sivun, jolloin
vastaus.json()epäonnistuu. Ilman CSRF-tunnistetta palvelin vastaa tilakoodilla 403. - Poista oma varauksesi. Javascript tekee sen
DELETE-pyynnöllä, ja vastaus on 204 ilman runkoa. - Ota javascript pois käytöstä ja tee sama uudelleen. Kaikki toimii, mutta sivu latautuu jokaisen toiminnon jälkeen uudelleen ja palvelin vastaa lomakkeelle HTML:llä tai uudelleenohjauksella.
htmx-versio toimii täsmälleen samoin, mutta sen selainpuolella ei ole riviäkään omaa javascriptiä: pyynnöt syntyvät htmx-attribuuteista ja palvelin vastaa HTML-palasilla. Vertaa sivujen lähdekoodeja keskenään: palvelin ja toiminnallisuus ovat samat, ja ero on siinä, kummassa päässä näkymä muodostetaan. Myös htmx-versio toimii ilman javascriptiä.
Infonäyttö
on erillinen sivu toisessa lähteessä, kuten neuvotteluhuoneen oven vieressä oleva
näyttö. Se lukee varaukset samasta rajapinnasta, joten kaikki sen liikenne on
lähteiden välistä. Lukeminen onnistuu, koska palvelin sallii tämän lähteen, mutta
sivun Yritä tehdä varaus tältä sivulta -painike epäonnistuu: palvelin sallii
toisesta lähteestä vain GET-metodin, joten selain keskeyttää pyynnön
esitarkistukseen. Sivulla näkyy vain TypeError: Failed to fetch, ja
varsinainen syy löytyy selaimen konsolista.
Käyttäjien kommentit