Tietokannat ja web-ohjelmointi

5. Oikeudet ja syötteet

Edellisessä osassa tehty esimerkkisovellus vaikuttaa päältä päin toimivalta, mutta sovelluksessa on vielä vakavia puutteita.

Ongelmana on, että sovellus olettaa, että sitä käytetään tarkoitetulla tavalla: käyttäjä seuraa sovelluksen linkkejä, käyttää siinä olevia lomakkeita ja syöttää lomakkeisiin järkevää tietoa. Sovellus ei kuitenkaan ota huomioon sitä, että kaikki käyttäjät eivät välttämättä toimi tällä tavalla.

Käymme seuraavaksi läpi sovelluksessa olevia puutteita sekä tapoja, joiden avulla ongelmat voidaan korjata.

Oikeuksien tarkastus

Sovelluksessa on tarkoitus, että käyttäjä voi muokata tai poistaa viestejä, jotka hän on lähettänyt itse. Tätä varten sivupohja tarkastaa seuraavasti, milloin linkit muokkaamiseen ja poistamiseen näytetään käyttäjälle:

thread.html

  {% if message.user_id == session.user_id %}
  <p>
    <a href="/edit/{{ message.id }}">Muokkaa</a> |
    <a href="/remove/{{ message.id }}">Poista</a>
  </p>
  {% endif %}

Tällainen tarkastus on kuitenkin riittämätön. Tarkastellaan tilannetta, jossa Maija on kirjautunut ja katsoo viestejä. Tässä näkyvät sinänsä oikein linkit viestin muokkaamiseen ja poistamiseen:

Muokkauslinkki vie seuraavalle sivulle:

Nyt kuitenkin ongelmana on, että Maija voi koettaa linkkien seuraamisen sijaan muuttaa itse sivun osoitetta. Tällä hetkellä Maija on sivulla /edit/3 ja hän voi kokeilla, mitä tapahtuu muuttamalla id-numeroa. Esimerkiksi muuttamalla osoitteeksi /edit/2 Maija päätyy muokkaamaan toista viestiä:

Tämä on vakava ongelma, koska Maija pääsee tällä tavalla muokkaamaan Uolevin lähettämää viestiä.

Korjaus ongelmaan on tarkastaa muokkaussivun käsittelijässä, että muokkaaja on tarkoitettu henkilö. Käsittelijä näyttää tällä hetkellä tältä:

app.py

@app.route("/edit/<int:message_id>", methods=["GET", "POST"])
def edit_message(message_id):
    message = forum.get_message(message_id)

    if request.method == "GET":
        return render_template("edit.html", message=message)

    if request.method == "POST":
        content = request.form["content"]
        forum.update_message(message["id"], content)

Lisätään käsittelijän alkuun tarkastus käyttäjän oikeuksista:

app.py

    message = forum.get_message(message_id)
    if message["user_id"] != session["user_id"]:
        abort(403)

Nyt käsittelijä vertaa viestin lähettäjän ja kirjautuneen käyttäjän id-numeroa. Jos id-numerot eivät ole samat, käsittelijä kutsuu Flask-kirjaston funktiota abort, joka katkaisee sivupyynnön ja palauttaa HTTP-koodin 403 (Forbidden). Tämä kertoo käyttäjälle, että pääsy sivulle on kielletty:

Huomaa, että käsittelijään lisätty tarkastus estää sekä muokkaussivun näyttämisen (GET-metodi) että muokkauksen tekemisen lomakkeella (POST-metodi) ilman oikeuksia. Myös jälkimmäinen esto on tärkeä, koska käyttäjä voisi kiertää ensimmäisen eston muuttamalla lomakkeen käsittelijän osoitetta esimerkiksi selaimen kehittäjän työkalujen kautta:

Myös viestin poistamisessa tulee tarkastaa vastaavalla tavalla, että käyttäjällä on oikeus poistaa viesti. Tarkastuksen voi toteuttaa samalla tavalla kuin viestin muokkaamisessa.

Parametrien käsittely

Yleisemmin sovelluksen tulee varautua siihen, että käyttäjä muuttaa minkä tahansa sivun osoitetta. Esimerkiksi käyttäjä voi kokeilla mennä katsomaan keskusteluketjua, jota ei ole olemassa:

Tässä keskustelualueella on kaksi ketjua (id-numerot 1 ja 2), mutta käyttäjä menee sivulle /thread/3, jossa id-numero on 3. Tämän seurauksena sivupyyntö aiheuttaa seuraavan virheen:

...
  File "/tmp/app.py", line 19, in show_thread
    thread = forum.get_thread(thread_id)
  File "/tmp/forum.py", line 13, in get_thread
    return db.query(sql, [thread_id])[0]
IndexError: list index out of range

Tämä virhe tulee funktiossa get_thread:

forum.py

def get_thread(thread_id):
    sql = "SELECT id, title FROM threads WHERE id = ?"
    return db.query(sql, [thread_id])[0]

Tässä koodi olettaa, että tietokannasta löytyy ketju annetulla id-numerolla. Tässä tilanteessa kuitenkin käyttäjä on antanut väärän id-numeron eikä ketjua ole olemassa. Tämän takia kyselyn tuloksena oleva lista on tyhjä ja yritys viitata listan indeksiin 0 aiheuttaa virheen.

Koodin voi korjata esimerkiksi seuraavasti:

forum.py

def get_thread(thread_id):
    sql = "SELECT id, title FROM threads WHERE id = ?"
    result = db.query(sql, [thread_id])
    return result[0] if result else None

Tämän korjauksen jälkeen funktio toimii muuten kuten ennenkin, mutta palauttaa None, jos haku tietokannasta antaa tyhjän tuloksen.

Katsotaan sitten sivupyynnön käsittelijää, joka kutsuu äskeistä funktiota:

app.py

@app.route("/thread/<int:thread_id>")
def show_thread(thread_id):
    thread = forum.get_thread(thread_id)
    messages = forum.get_messages(thread_id)
    return render_template("thread.html", thread=thread, messages=messages)

Voimme muuttaa tätä funktiota seuraavasti:

app.py

@app.route("/thread/<int:thread_id>")
def show_thread(thread_id):
    thread = forum.get_thread(thread_id)
    if not thread:
        abort(404)
    messages = forum.get_messages(thread_id)
    return render_template("thread.html", thread=thread, messages=messages)

Nyt jos ketjua ei ole olemassa, käsittelijä katkaisee sivupyynnön ja palauttaa HTTP-koodin 404 (Not Found). Tällöin käyttäjä saa tiedon, että sivua ei ole olemassa:

Vastaava korjaus tulee tehdä myös funktioon get_message sekä sitä kutsuviin käsittelijöihin viestin muokkauksen ja poiston yhteydessä.

Sovelluksessa on vielä toinenkin toinen ongelma liittyen ketjujen id-numeroihin. Käyttäjä voi kokeilla muuttaa ketjun id-numeron sisältävää piilokenttää lomakkeessa, jolla lähetetään vastaus ketjuun:

Tässä tapauksessa käyttäjä voi esimerkiksi muuttaa kentän arvoksi 3 ja yrittää lähettää viestin olemattomaan ketjuun. Tämä aiheuttaa seuraavan virheen koodissa:

...
  File "/tmp/app.py", line 40, in new_message
    forum.add_message(content, user_id, thread_id)
  File "/tmp/forum.py", line 37, in add_message
    db.execute(sql, [content, user_id, thread_id])
  File "/tmp/db.py", line 12, in execute
    result = db.execute(sql, params)
sqlite3.IntegrityError: FOREIGN KEY constraint failed

Tässä SQLite on havainnut, että tauluun messages yritetään lisätä rivi, jonka thread_id viittaa taulun threads olemattomaan riviin. Tietokanta valvoo viittausten eheyttä eikä rivin lisääminen onnistu.

Tämä estää sinänsä viestin lähetyksen, koska tulee “Internal Server Error”. Ei ole kuitenkaan hyvä, että sovellus kaatuu hallitsemattomasti. Voimme käsitellä virheen seuraavasti viestin lähetyksen yhteydessä:

app.py

@app.route("/new_message", methods=["POST"])
def new_message():
    ...
    try:
        forum.add_message(content, user_id, thread_id):
    except sqlite3.IntegrityError:
        abort(403)

    return redirect("/thread/" + str(thread_id))

Nyt jos viestin lisääminen ei onnistu, käsittelijä katkaisee sivupyynnön ja palauttaa HTTP-koodin 403 (Forbidden).

Kirjautumisen tarkastus

Käyttäjä voi aloittaa viestin kirjoittamisen kirjautuneena mutta kirjautua ulos esimerkiksi selaimen toisessa välilehdessä ennen viestin lähettämistä. Mitä tapahtuu, kun käyttäjä tämän jälkeen lähettää viestin lomakkeella?

Viestin lähetyksen tuloksena on “Internal Server Error” ja lokiin ilmestyy seuraava virheilmoitus:

...
  File "/tmp/app.py", line 37, in new_message
    user_id = session["user_id"]
...
KeyError: 'user_id'

Tämä virhe tulee koodissa rivillä, jossa luodaan muuttuja user_id:

app.py

@app.route("/new_message", methods=["POST"])
def new_message():
    content = request.form["content"]
    user_id = session["user_id"]
    ...

Koska käyttäjä on kirjautunut ulos sovelluksesta, ei ole enää olemassa istuntoon liittyvää arvoa session["user_id"].

Sovelluksessa olisi hyvä pystyä monessakin tilanteessa varmistamaan, että käyttäjä on kirjautuneena. Tähän tarkoitukseen soveltuu seuraava funktio, joka keskeyttää sivupyynnön, jos käyttäjä ei ole kirjautuneena:

app.py

def require_login():
    if "user_id" not in session:
        abort(403)

Voimme kutsua tätä funktiota uuden viestin lähetyksessä näin:

app.py

@app.route("/new_message", methods=["POST"])
def new_message():
    require_login()

    content = request.form["content"]
    user_id = session["user_id"]
    ...

Tämän jälkeen käyttäjä ei pysty enää lähettämään viestiä, jos hän ei ole kirjautuneena. Vastaava muutos tulee tehdä kaikkiin käsittelijöihin, joissa käyttäjän tulee olla kirjautuneena.

Dekoraattori

Kirjautumisen vaatimisen voisi toteuttaa myös määrittelemällä funktion eteen tulevan dekoraattorin, joka tarkastaa kirjautumisen. Tällöin funktion voisi kirjoittaa tähän tapaan:

@app.route("/new_message", methods=["POST"])
@require_login
def new_message():
    ...

Tässä @require_login on dekoraattori, joka tarkastaa kirjautumisen. Lisätietoa tällaisen dekoraattorin tekemisestä on Flaskin dokumentaatiossa.

Lomakkeiden käsittely

Tällä hetkellä käyttäjä voi kirjoittaa lomakkeisiin mitä tahansa ja käyttäjän lähettämät tiedot tallennetaan tietokantaan ja näytetään sovelluksessa. Tämä voi aiheuttaa ongelmia, jos tiedon määrä on poikkeavan suuri.

Käyttäjä voi esimerkiksi luoda uuden ketjun, jonka otsikossa on tuhat a-kirjainta:

Selkeästi ei ole järkevää, että ketjulla voi olla näin pitkä otsikko. Tämä sekoittaa myös koko sovelluksen ulkoasun:

Korjaus ongelmaan on rajoittaa syötteiden kokoa lomakkeissa. Voimme käyttää tähän maxlength-attribuuttia seuraavaan tapaan:

index.html

    <p>
      Otsikko:<br />
      <input type="text" name="title" maxlength="100" />
    </p>
    <p>
      Viesti:<br />
      <textarea name="content" rows="5" cols="40" maxlength="5000"></textarea>
    </p>

Tämän muutoksen jälkeen lomake pyrkii rajoittamaan syötteen pituutta niin, että otsikossa on enintään 100 merkkiä ja viestissä on enintään 5000 merkkiä.

Tämä ei ole kuitenkaan vielä yksinään riittävä korjaus, koska käyttäjä voi jälleen esimerkiksi muuttaa rajoja selaimen kehittäjän työkalujen avulla. Rajat tulee tarkastaa myös sovelluksen koodissa ennen tiedon lisäämistä tietokantaan:

app.py

@app.route("/new_thread", methods=["POST"])
def new_thread():
    ...
    title = request.form["title"]
    content = request.form["content"]
    if len(title) > 100 or len(content) > 5000:
        abort(403)
    ...

Joissain tilanteissa myös tyhjä tieto voi aiheuttaa ongelmia. Näin on esimerkiksi, jos ketjun otsikko on tyhjä:

Nyt listaan ilmestyy ketju, johon ei pääse linkistä tyhjän otsikon takia:

Voimme parantaa lomaketta seuraavasti:

index.html

    <p>
      Otsikko:<br />
      <input type="text" name="title" maxlength="100" required />
    </p>

Attribuutti required ilmaisee, että lomakkeen kenttä on pakollinen. Selain pyrkii estämään lomakkeen lähettämisen, jos kenttä on tyhjä.

Taas kerran meidän kuitenkin täytyy tarkastaa asia myös käsittelijässä:

app.py

    if not title or len(title) > 100 or len(content) > 5000:
        abort(403)

Tässä ehto not title on tosi silloin, kun title on tyhjä.

Vastaavat muutokset tulee tehdä kaikkiin sovelluksen lomakkeisiin, jossa tietokantaan tallennetaan käyttäjän antamaa tietoa.


Tämän osan muutosten jälkeen monta aukkoa sovelluksessa on tukittu ja sovellus toimii selvästi paremmin kuin ennen. Tässä on sovelluksen korjattu versio:

Palaamme kuitenkin sovelluksen tietoturvaan vielä materiaalin osassa 8.