Lezione 4 di 7 · 25 min di lettura

Middleware, pagine e moduli

Funzioni che avvolgono il gestore. Scrivere un middleware con use, quelli già pronti di wisp per i log e gli errori, costruire pagine HTML senza aprire la porta agli attacchi, e ricevere i dati di un modulo.

Avvolgere il gestore

Ci sono cose che un server deve fare per ogni richiesta, qualunque sia la strada: scrivere una riga di log, controllare una chiave di accesso, aggiungere un’intestazione, trasformare un crash in una risposta 500. Ripeterle in ogni strada sarebbe noioso, e prima o poi ne dimenticheresti una.

La soluzione si chiama middleware: una funzione che sta “in mezzo” tra la richiesta e il gestore. Riceve come ultimo argomento il resto del lavoro, una funzione next che produce la risposta, e decide lei cosa farne: chiamarla o no, e cosa fare prima e dopo. È la forma perfetta per use (lezione 5.3).

Ecco due middleware scritti a mano, uno che agisce dopo e uno che agisce prima:

src/middleware_demo.gleam
import gleam/http.{Get}
import gleam/int
import gleam/io
import gleam/list
import gleam/string
import wisp.{type Request, type Response}
import wisp/simulate

pub fn handle_request(request: Request) -> Response {
  use <- add_server_header
  use <- require_key(request)
  wisp.ok() |> wisp.string_body("The secret recipe")
}

fn add_server_header(next: fn() -> Response) -> Response {
  next()
  |> wisp.set_header("server", "gleam")
}

fn require_key(request: Request, next: fn() -> Response) -> Response {
  case list.key_find(request.headers, "x-key") {
    Ok("open-sesame") -> next()
    _ -> wisp.response(401) |> wisp.string_body("Who are you?")
  }
}

pub fn main() -> Nil {
  let without_key = simulate.request(Get, "/")
  let with_key = simulate.header(without_key, "x-key", "open-sesame")
  print(handle_request(without_key))
  print(handle_request(with_key))
}

fn print(response: Response) -> Nil {
  io.println(
    int.to_string(response.status)
    <> " "
    <> simulate.read_body(response)
    <> " "
    <> string.inspect(response.headers),
  )
}
output
401 Who are you? [#("server", "gleam")]
200 The secret recipe [#("content-type", "text/plain"), #("server", "gleam")]
  • add_server_header chiama subito next(), e poi aggiunge un’intestazione alla risposta che ne esce. Agisce dopo.
  • require_key guarda le intestazioni della richiesta (una lista di coppie, quindi list.key_find): se la chiave è giusta chiama next(), altrimenti risponde lei, con 401 (Unauthorized, “non autorizzato”), e il gestore non viene mai eseguito. Agisce prima. wisp.response(401) crea una risposta vuota con quel codice.
  • simulate.header aggiunge un’intestazione a una richiesta finta.

L’ordine dei use conta: sono scatole una dentro l’altra, e la prima è la più esterna. add_server_header avvolge tutto, quindi anche il 401 riceve l’intestazione server. Se scambi le due righe, il 401 esce da require_key prima di passare da add_server_header, e l’intestazione manca.

I middleware di wisp

Wisp ne ha alcuni già pronti. I due che userai sempre sono il log e la rete di sicurezza per i crash:

src/logged_server.gleam
import gleam/erlang/process
import mist
import wisp.{type Request, type Response}
import wisp/wisp_mist

pub fn main() -> Nil {
  wisp.configure_logger()
  let assert Ok(_) =
    wisp_mist.handler(handle_request, wisp.random_string(64))
    |> mist.new
    |> mist.port(8000)
    |> mist.start
  process.sleep_forever()
}

fn handle_request(request: Request) -> Response {
  use <- wisp.log_request(request)
  use <- wisp.rescue_crashes
  case wisp.path_segments(request) {
    [] -> wisp.html_response("<h1>All good</h1>", 200)
    ["crash"] -> panic as "the kitchen is on fire"
    _ -> wisp.not_found()
  }
}

Avvialo, e chiedi con curl localhost:8000/, poi localhost:8000/crash, poi localhost:8000/nope. Il secondo risponde Internal server error; nel terminale del server compare il diario:

output
Listening on http://127.0.0.1:8000
INFO 200 GET /
EROR function="handle_request" line=21 message="the kitchen is on fire" module="logged_server" file="src/logged_server.gleam" gleam_error=Panic class=Errored
INFO 500 GET /crash
INFO 404 GET /nope
  • wisp.configure_logger(), chiamato una volta in main, prepara il sistema di log di Erlang: senza, i messaggi INFO non si vedono.
  • wisp.log_request(request) scrive una riga per ogni richiesta, con codice di stato, metodo e percorso. È il primo use, quindi il più esterno: vede la risposta finale, anche il 500.
  • wisp.rescue_crashes esegue il resto dentro una rete: se qualcosa si schianta (un panic, un let assert fallito), scrive l’errore nel log, con modulo, riga e messaggio, e risponde 500 invece di lasciar cadere la richiesta.

Nel terminale le etichette INFO ed EROR sono colorate. I log vanno sempre letti dove gira il server, non nel browser: l’utente vede solo Internal server error, e non deve sapere niente della tua cucina in fiamme.

Gli esempi ufficiali di wisp raccolgono i middleware in una funzione sola, chiamata di solito middleware, che ogni gestore usa come primo use. Oltre ai due visti qui, di solito ci mettono wisp.method_override, wisp.handle_head e wisp.csrf_known_header_protection: protezioni e comodità di HTTP che puoi scoprire nella documentazione di wisp, quando ti serviranno.

Pagine HTML

Una pagina HTML è una stringa, e si costruisce come qualsiasi altra stringa: con <>. C’è però un’insidia enorme, che conviene conoscere dal primo giorno. Immagina una pagina che saluta con il nome letto dalla query, e qualcuno che come nome scrive un pezzo di HTML:

src/escape_demo.gleam
import gleam/io
import wisp

pub fn main() -> Nil {
  let name = "<script>alert('hi')</script>"
  io.println("<p>Hello, " <> name <> "</p>")
  io.println("<p>Hello, " <> wisp.escape_html(name) <> "</p>")
  io.println(wisp.escape_html("Tom & Jerry \"forever\""))
}
output
<p>Hello, <script>alert('hi')</script></p>
<p>Hello, &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;</p>
Tom &amp; Jerry &quot;forever&quot;

Nella prima riga, il “nome” è diventato codice: il browser vedrebbe un tag <script> ed eseguirebbe il JavaScript dentro. wisp.escape_html sostituisce i caratteri speciali dell’HTML con le loro entità: < diventa <, & diventa &, le virgolette " e '. Il browser le mostra come i caratteri originali, ma non le interpreta più come HTML.

La regola è semplice: tutto quello che arriva da fuori (query, moduli, JSON, file caricati, anche dati salvati da altri utenti) passa da wisp.escape_html prima di finire in una pagina.

Dettagli nerd Cos'è un attacco XSS?

XSS sta per cross-site scripting. Succede quando un sito mette nelle sue pagine del testo scritto da un utente senza proteggerlo: l’attaccante scrive del JavaScript al posto del suo nome o di un commento, e quel codice viene eseguito nel browser di chiunque apra la pagina, come se l’avesse scritto il sito.

Da lì, lo script può fare tutto quello che il sito può fare per conto dell’utente: leggere i dati della pagina, mandare richieste con il suo account, mostrare un finto modulo di accesso. È uno degli attacchi più comuni del web, e si previene sempre allo stesso modo: il testo degli utenti non diventa mai HTML senza essere “disinnescato” con l’escape.

Ricevere un modulo

I moduli HTML (form) sono il modo più antico per mandare dati a un server: caselle di testo e un pulsante. Con method='post', il browser manda una richiesta POST con i valori nel corpo, codificati come una query (name=Ada&city=London) e l’intestazione content-type: application/x-www-form-urlencoded.

Wisp li legge con il middleware wisp.require_form: se la richiesta è un modulo, passa al resto un valore FormData, il cui campo values è una lista di coppie #(nome, valore); se non lo è, risponde da solo con un errore.

src/form_demo.gleam
import gleam/http.{Get, Post}
import gleam/int
import gleam/io
import gleam/list
import gleam/result
import gleam/string
import wisp.{type Request, type Response}
import wisp/simulate

pub fn handle_request(request: Request) -> Response {
  case request.method {
    Get -> show_form()
    Post -> greet(request)
    _ -> wisp.method_not_allowed([Get, Post])
  }
}

fn show_form() -> Response {
  let html =
    "<form method='post'>
  <input name='name'>
  <input name='city'>
  <button>Send</button>
</form>"
  wisp.html_response(html, 200)
}

fn greet(request: Request) -> Response {
  use form <- wisp.require_form(request)
  io.println(string.inspect(form.values))
  let result = {
    use name <- result.try(list.key_find(form.values, "name"))
    use city <- result.try(list.key_find(form.values, "city"))
    Ok(wisp.escape_html(name) <> " from " <> wisp.escape_html(city))
  }
  case result {
    Ok(text) -> wisp.html_response("<p>Welcome, " <> text <> "!</p>", 200)
    Error(_) -> wisp.bad_request("name and city are required")
  }
}

pub fn main() -> Nil {
  let response =
    simulate.request(Post, "/")
    |> simulate.form_body([#("name", "Ada"), #("city", "London")])
    |> handle_request
  io.println(simulate.read_body(response))
  let response =
    simulate.request(Post, "/")
    |> simulate.form_body([#("name", "Ada")])
    |> handle_request
  io.println(simulate.read_body(response))
  let response =
    simulate.request(Post, "/")
    |> simulate.string_body("name=Ada")
    |> handle_request
  io.println(
    int.to_string(response.status) <> " " <> simulate.read_body(response),
  )
}
output
[#("city", "London"), #("name", "Ada")]
<p>Welcome, Ada from London!</p>
[#("name", "Ada")]
Bad request: name and city are required
415 Unsupported media type
  • simulate.form_body crea una richiesta finta con un modulo, esattamente come la manderebbe il browser.
  • La prima riga stampata mostra un dettaglio utile: wisp ordina i campi per nome, city prima di name, qualunque fosse l’ordine nel modulo.
  • Ogni campo può mancare, quindi list.key_find dà un Result, e i use ... <- result.try si fermano al primo che manca (lezione 5.3). Se ne manca uno, la risposta è 400.
  • La terza richiesta ha un corpo di testo semplice, non un modulo: require_form risponde 415 (Unsupported Media Type, “tipo di contenuto non supportato”) senza nemmeno chiamare il resto.
  • I valori finiscono nella pagina, quindi passano da escape_html.

Per vederlo con il browser, crea un modulo form_server che lo avvia come library_server nella lezione precedente, e apri http://localhost:8000: compili le caselle, premi Send, e il browser fa la richiesta POST. Da curl, -d manda proprio un modulo:

terminale
curl -d 'name=Ada&city=Paris' localhost:8000
output
<p>Welcome, Ada from Paris!</p>

Intanto, nel terminale del server, compare la riga [#("city", "Paris"), #("name", "Ada")]: la stampa greet con io.println, e un server scrive dove gira lui, non nel terminale di chi fa la richiesta.

Quiz

In un gestore scrivi prima use <- wisp.rescue_crashes e poi use <- wisp.log_request(request). Cosa succede alla riga di log quando una richiesta si schianta?

Esercizio · sul tuo computer

L'ordinazione

Crea src/order_form.gleam, con un gestore per le ordinazioni di un ristorante:

  • accetta solo POST (con require_method), e legge un modulo con i campi dish e quantity;
  • se quantity è un numero intero maggiore di zero risponde con la pagina <p>2 x pizza</p> (quantità, x, piatto protetto con escape_html); altrimenti 400 con invalid order;
  • un middleware scritto da te, no_cache, aggiunge a ogni risposta, anche agli errori, l’intestazione cache-control: no-store.

Nel main, manda quattro ordinazioni: pizza 2, <i>soup</i> 1, pizza lots, pizza 0, e stampa codice e corpo di ognuna. Poi manda un GET e stampa solo le intestazioni della risposta con string.inspect:

output
200 <p>2 x pizza</p>
200 <p>1 x &lt;i&gt;soup&lt;/i&gt;</p>
400 Bad request: invalid order
400 Bad request: invalid order
[#("allow", "POST"), #("cache-control", "no-store")]

Suggerimento: int.parse restituisce un Result, e può stare nella stessa catena di result.try dei campi. Per “maggiore di zero”, una guardia nel case (lezione 2.5).

Mostra una soluzione (prima prova da solo!)
src/order_form.gleam
import gleam/http.{Get, Post}
import gleam/int
import gleam/io
import gleam/list
import gleam/result
import gleam/string
import wisp.{type Request, type Response}
import wisp/simulate

pub fn handle_request(request: Request) -> Response {
  use <- no_cache
  use <- wisp.require_method(request, Post)
  use form <- wisp.require_form(request)
  let order = {
    use dish <- result.try(list.key_find(form.values, "dish"))
    use quantity <- result.try(list.key_find(form.values, "quantity"))
    use quantity <- result.try(int.parse(quantity))
    Ok(#(dish, quantity))
  }
  case order {
    Ok(#(dish, quantity)) if quantity > 0 ->
      wisp.html_response(
        "<p>"
          <> int.to_string(quantity)
          <> " x "
          <> wisp.escape_html(dish)
          <> "</p>",
        200,
      )
    _ -> wisp.bad_request("invalid order")
  }
}

fn no_cache(next: fn() -> Response) -> Response {
  next()
  |> wisp.set_header("cache-control", "no-store")
}

pub fn main() -> Nil {
  [
    [#("dish", "pizza"), #("quantity", "2")],
    [#("dish", "<i>soup</i>"), #("quantity", "1")],
    [#("dish", "pizza"), #("quantity", "lots")],
    [#("dish", "pizza"), #("quantity", "0")],
  ]
  |> list.each(fn(values) {
    let response =
      simulate.request(Post, "/order")
      |> simulate.form_body(values)
      |> handle_request
    io.println(
      int.to_string(response.status) <> " " <> simulate.read_body(response),
    )
  })
  let response = handle_request(simulate.request(Get, "/order"))
  io.println(string.inspect(response.headers))
}

no_cache è il use più esterno, quindi avvolge anche il 405 di require_method: l’ultima riga lo dimostra. L’intestazione cache-control: no-store chiede al browser di non conservare la risposta: per un’ordinazione, non ha senso rivederne una vecchia.

Ricapitolando

  • Un middleware è una funzione che riceve il resto del lavoro (next) come ultimo argomento: si usa con use, può agire prima (e magari rispondere lei) o dopo (e cambiare la risposta).
  • L’ordine dei use è l’ordine delle scatole: il primo è il più esterno.
  • wisp.configure_logger() in main, poi use <- wisp.log_request(request) per una riga di log a richiesta, e use <- wisp.rescue_crashes per trasformare un crash in un 500.
  • Le pagine HTML sono stringhe; tutto ciò che arriva da fuori passa da wisp.escape_html, contro gli attacchi XSS.
  • use form <- wisp.require_form(request) legge un modulo: form.values è una lista di coppie, ordinata per nome. Se il corpo non è un modulo, risponde 415.
  • Per le prove: simulate.form_body e simulate.header; da curl, -d 'a=1&b=2'.

Nella prossima lezione il server parlerà con altri programmi invece che con le persone: un’API JSON.