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:

HTTP-pyyntö ja -vastaus Selain lähettää GET-pyynnön osoitteeseen /api/postinumero/40100 HTTP-otsakkeella Accept: application/json. Palvelin vastaa tilakoodilla 200, mediatyypillä application/json ja JSON-rungolla, jossa on postinumero ja toimipaikka. Selainjavascript tai lomakeFlask-sovelluspalvelin GET /api/postinumero/40100 HTTP/1.1Host: localhost:5000Accept: application/json1Palvelin päättää tilakoodin, mediatyypin ja rungonHTTP/1.1 200 OKContent-Type: application/json{"postinumero": "40100", "toimipaikka": "Jyväskylä"}2
Pyynnössä selain kertoo, mitä se haluaa (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.

Yleisimpiä mediatyyppejä
MediatyyppiSisältö
application/jsonJSON-data
text/htmlHTML-dokumentti tai -palanen
application/xhtml+xmlXHTML-dokumentti tai -palanen
application/xmlmuu XML-data (vanhempi muoto text/xml)
text/plainpelkkä 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:

Mistä Flask löytää selaimen lähettämät tiedot
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:

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.

JSON-rajapinnan tärkeimmät tilakoodit
KoodiKäyttö
200 OKpyyntö onnistui, vastauksessa on dataa
201 Createduusi kohde luotiin; sen osoite Location-otsakkeessa
204 No Contentonnistui, mutta vastauksessa ei ole runkoa (esimerkiksi poisto)
400 Bad Requestpyyntö on rakenteeltaan virheellinen
401 Unauthorizedkäyttäjä ei ole kirjautunut
403 Forbiddenkäyttäjä on kirjautunut, mutta hänellä ei ole oikeutta
404 Not Foundkohdetta ei ole
405 Method Not Allowedosoite ei hyväksy metodia
409 Conflictpyyntö on ristiriidassa nykytilan kanssa, esimerkiksi nimi on jo käytössä
413 Content Too Largepyyntö on liian suuri
415 Unsupported Media Typeväärä Content-Type
422 Unprocessable Contentpyyntö on rakenteeltaan kunnossa, mutta tiedot eivät kelpaa (kenttäkohtaiset virheet)
500 Internal Server Errorvirhe 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.

Sama lomake ilman javascriptiä ja javascriptin kanssa Ilman javascriptiä selain lähettää lomakkeen tavallisena POST-pyyntönä, palvelin vastaa uudelleenohjauksella 303 osoitteeseen /kiitos, ja selain hakee uuden sivun. Javascriptin kanssa fetch lähettää saman lomakkeen samaan osoitteeseen HTTP-otsakkeella Accept: application/json, ja palvelin vastaa virhetilanteessa tilakoodilla 422 ja JSON-rungolla, jossa on kenttäkohtaiset virheet. Sivu ei lataudu uudelleen. Selainjavascript tai lomakeFlask-sovelluspalvelin Ilman javascriptiä: tavallinen lomakePOST /tilaus HTTP/1.1Content-Type: application/x-www-form-urlencodedAccept: text/html,application/xhtml+xml,…Cookie: session=…csrf=…&nimi=Ada&postinumero=401001HTTP/1.1 303 See OtherLocation: /kiitos2Selain seuraa uudelleenohjausta ja lataa uuden sivunGET /kiitos HTTP/1.13HTTP/1.1 200 OKContent-Type: application/xhtml+xml; charset=utf-8<!DOCTYPE html> … kiitossivu …4Javascriptin kanssa: fetch samaan osoitteeseenPOST /tilaus HTTP/1.1Content-Type: multipart/form-data; boundary=…Accept: application/jsonCookie: session=…csrf=… nimi= postinumero=4010 (FormData)1HTTP/1.1 422 Unprocessable ContentContent-Type: application/json{"virhe": "Tarkista tiedot", "kentat": {"nimi": "Nimi puuttuu.", …}}2Javascript näyttää virheet kenttien vieressä; sivu ei lataudu uudelleen
Sama lomake ja sama osoite kahdella tavalla. Ilman javascriptiä palvelin vastaa uudelleenohjauksella ja selain lataa uuden sivun; fetch pyytää JSONia ja saa kenttäkohtaiset virheet ilman sivun latautumista.

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)
Fetch ei pyydä JSONia itsestään

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.

JSON ja HTML-palanen vastauksena
OminaisuusJSONHTML-palanen
Näkymän rakennejavascriptissä Jinja-templatessa
Escapausselaimen koodissa (textContent) Jinja hoitaa sen palvelimella
Muut käyttäjättoimii myös mobiilisovelluksille ja muille ohjelmillesopii vain tälle sivulle
Sopiidatalle, 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);
Ilman nimiavaruutta elementit eivät ole HTML:ää

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>

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 ja XHTML-mediatyyppi

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:

htmx 4 XHTML-mediatyypillä tarjoillulla sivulla
TapaTulos
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" })
});
Istunto ja CSRF-tunniste JSON-pyynnössä Ensin selain hakee sivun. Palvelin asettaa istuntoevästeen Set-Cookie-otsakkeella ja upottaa CSRF-tunnisteen sivun meta-elementtiin. Sitten javascript lähettää JSON-pyynnön, jonka mukana selain lähettää evästeen automaattisesti ja javascript tunnisteen X-CSRF-Token-otsakkeessa. Palvelin vastaa tilakoodilla 201 ja Location-otsakkeella. Ilman tunnistetta palvelin vastaa tilakoodilla 403. Selainjavascript tai lomakeFlask-sovelluspalvelin GET /tilaus HTTP/1.11HTTP/1.1 200 OKContent-Type: application/xhtml+xml; charset=utf-8Set-Cookie: session=…; HttpOnly; Path=/; SameSite=Lax… <meta name="csrf-token" content="Qx7…" /> …2Selain tallentaa evästeen; javascript lukee tunnisteen sivultaPOST /api/tilaukset HTTP/1.1Content-Type: application/jsonCookie: session=… (selain lisää itse)X-CSRF-Token: Qx7… (javascript lisää){"nimi": "Ada", "postinumero": "40100"}3HTTP/1.1 201 CreatedContent-Type: application/jsonLocation: /api/tilaukset/7{"id": 7, "nimi": "Ada", "postinumero": "40100"}4Sama pyyntö ilman CSRF-tunnistettaPOST /api/tilaukset HTTP/1.1Content-Type: application/json{"nimi": "Ada", "postinumero": "40100"}5HTTP/1.1 403 ForbiddenContent-Type: application/json{"virhe": "CSRF-tunniste puuttuu tai on väärä"}6
Selain lisää istuntoevästeen jokaiseen pyyntöön itse, mutta CSRF-tunnisteen lähettää vain sovelluksen oma javascript, joka on lukenut sen sivulta. Pyyntö ilman tunnistetta hylätään.

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.

JSON-muoto ei yksin suojaa

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

CORS-esitarkistus ja varsinainen pyyntö Sivu on ladattu osoitteesta https://users.jyu.fi ja rajapinta on toisessa lähteessä. Koska pyynnössä on JSON-mediatyyppi ja oma HTTP-otsake, selain lähettää ensin automaattisesti OPTIONS-esitarkistuspyynnön Origin-otsakkeella. Palvelin vastaa sallivilla Access-Control-otsakkeilla. Sen jälkeen selain lähettää varsinaisen POST-pyynnön, ja palvelin toistaa vastauksessa Access-Control-Allow-Origin- ja Access-Control-Allow-Credentials-otsakkeet, jolloin javascript saa lukea vastauksen. Istuntoeväste lähtee toiseen lähteeseen vain, jos fetch-kutsussa on credentials: include. Selainsivu: https://users.jyu.fiRajapintahttps://…run.app Selain lähettää esitarkistuksen itse; javascript ei näe sitäOPTIONS /api/tilaukset HTTP/1.1Origin: https://users.jyu.fiAccess-Control-Request-Method: POSTAccess-Control-Request-Headers: content-type, x-csrf-token1HTTP/1.1 200 OKAccess-Control-Allow-Origin: https://users.jyu.fiAccess-Control-Allow-Credentials: trueAccess-Control-Allow-Methods: GET, POST, PUT, DELETEAccess-Control-Allow-Headers: Content-Type, X-CSRF-TokenAccess-Control-Max-Age: 600Vary: Origin2Lupa saatiin: selain lähettää varsinaisen pyynnönEväste lähtee mukaan vain, jos fetchissä on credentials: "include"POST /api/tilaukset HTTP/1.1Origin: https://users.jyu.fiContent-Type: application/jsonCookie: session=…X-CSRF-Token: Qx7…{"nimi": "Ada", "postinumero": "40100"}3HTTP/1.1 201 CreatedAccess-Control-Allow-Origin: https://users.jyu.fiAccess-Control-Allow-Credentials: trueContent-Type: application/jsonVary: Origin{"id": 7, "nimi": "Ada", "postinumero": "40100"}4Otsakkeet täsmäävät: selain antaa javascriptin lukea vastauksen
Kun sivu ja rajapinta ovat eri lähteissä, selain kysyy ensin 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

Turvallinen julkaisu

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:

Lähdekoodit, testit ja julkaisuohjeet:

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:

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.

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti