Security

CSRF tokens

CSRF protection for forms and fetch calls: tokens signed for one session that expire, the signed double-submit cookie check, and the Origin, Referer and Sec-Fetch-Site checks.

Version 1.0.0 · 5 functions · 5 crates

#forms#security#sessions#tokens

Functions

What use csrf_tokens.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use csrf_token, csrf_token_at from csrf_tokens (Quillo 0.6).

csrf_token(secret: str, session_id: str) → stror an error

A token for a form or a fetch call, signed with secret for session_id (an id kept in the session, never shown elsewhere): issued.nonce.signature, URL-safe. Each call gives a new one; any of them verifies while it is fresh.

csrf_token_at(secret: str, session_id: str, unix_seconds: int) → stror an error

A token as csrf_token makes it, issued at unix_seconds: for tests of expiry.

csrf_verify(secret: str, session_id: str, token: str, max_age_seconds: int) → boolor an error

Checks a token sent back with a form or a header: signed with secret for this session_id, and issued less than max_age_seconds ago (0: no limit). Gives true, or an error value saying why it is refused.

csrf_double_submit(secret: str, session_id: str, cookie_token: str, sent_token: str, max_age_seconds: int) → boolor an error

The signed double-submit check, for pages that cannot keep a token on the server: the token in a cookie (cookie_token) and the one sent in the form or header (sent_token) must be the same, and signed for this session.

csrf_origin_check(headers: map, allowed_origins: list) → boolor an error

Whether a request that changes something came from your own pages, by its headers (lower-case names, as request.headers has them): Sec-Fetch-Site same-origin (or none: typed or bookmarked), else Origin, else the Referer's origin, must be one of allowed_origins (https://shop.example.com). Gives true, or an error value saying why not.

Get it

$ quillo install csrf_tokens               # csrf_tokens.qwer into this folder$ quillo install csrf_tokens --example     # and its example, csrf_tokens.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/csrf_tokens.qwer$ curl -O https://quillolang.org/plugins/files/csrf_tokens.qwe

Then, in your program, all of it or only what you call:

main.qwe
use csrf_tokens.qweruse csrf_token, csrf_token_at from csrf_tokens      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
hmac = "0.12"sha2 = "0.10"base64 = "0.22"rand = "0.8"url = "2"

Example

csrf_tokens.qwe
// A money transfer form protected from cross-site requests: a token signed for the visitor's// session in the form, checked when it comes back, and the Origin checked too.////   CSRF_SECRET=$(openssl rand -hex 32) quillo run csrf_tokens.qwe      then open http://localhost:8080/transferuse "csrf_tokens.qwer"let SECRET = env("CSRF_SECRET", "dev-only-secret-change-me-0123456789")let ORIGINS = ["http://localhost:8080", "https://bank.example.com"]fn session_id() {    let s = session()    if s.sid != null { return s.sid }    let sid = crypto.token(16)    set_session({ ...s, sid })    sid}route GET /transfer {    let token = csrf_token(SECRET, session_id())?    <form method="post" action="/transfer">        <input type="hidden" name="csrf" value={token}/>        <label>To <input name="to"/></label>        <label>Amount <input name="amount"/></label>        <button>Send</button>    </form>}route POST /transfer { form: { csrf: str, to: str, amount: str } } {    let sid = session().sid ?? reply 403 "no session"    let ok = csrf_verify(SECRET, sid, csrf, 3600)    if is_err(ok) { reply 403 ok.error }    let same = csrf_origin_check(request.headers, ORIGINS)    if is_err(same) { reply 403 same.error }    `Sent ${amount} to ${to}`}// The checks at work, without a browser.route GET /selftest {    let token = csrf_token(SECRET, "session-a")?    let old = csrf_token_at(SECRET, "session-a", int(now() / 1000) - 7200)?    {        fresh: csrf_verify(SECRET, "session-a", token, 3600)?,        other_session: csrf_verify(SECRET, "session-b", token, 3600).error,        expired: csrf_verify(SECRET, "session-a", old, 3600).error,        double_submit: csrf_double_submit(SECRET, "session-a", token, token, 3600)?,        same_origin: csrf_origin_check({ "sec-fetch-site": "same-origin" }, ORIGINS)?,        evil_origin: csrf_origin_check({ origin: "https://evil.example" }, ORIGINS).error,        our_referer: csrf_origin_check({ referer: "https://bank.example.com/transfer?x=1" }, ORIGINS)?,    }}
$ quillo run csrf_tokens.qwe$ curl 'localhost:8080/selftest'$ curl 'localhost:8080/transfer'

Source

csrf_tokens.qwer
//! CSRF protection for forms and fetch calls: tokens signed for one session that expire, the signed double-submit cookie check, and the Origin, Referer and Sec-Fetch-Site checks browsers make possible.//! version: 1.0.0//!//! [dependencies]//! hmac = "0.12"//! sha2 = "0.10"//! base64 = "0.22"//! rand = "0.8"//! url = "2"use base64::engine::general_purpose::URL_SAFE_NO_PAD;use base64::Engine;use hmac::{Hmac, Mac};use rand::RngCore;use sha2::Sha256;fn now() -> i64 {    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)}fn signature(secret: &str, session_id: &str, issued: i64, nonce: &str) -> Hmac<Sha256> {    let mut m = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC takes any key length");    // Lengths first, so no two (session, nonce) pairs sign the same bytes.    m.update(format!("csrf.v1.{}.{}.{}.{}.{}", session_id.len(), session_id, issued, nonce.len(), nonce).as_bytes());    m}fn check_secret(secret: &str) -> Result<(), String> {    if secret.len() < 16 { Err("the secret should be at least 16 characters (32 random bytes is right)".into()) } else { Ok(()) }}/// A token for a form or a fetch call, signed with `secret` for `session_id` (an id kept in the session, never shown elsewhere):/// `issued.nonce.signature`, URL-safe. Each call gives a new one; any of them verifies while it is fresh.pub fn csrf_token(secret: &str, session_id: &str) -> Result<String, String> {    csrf_token_at(secret, session_id, now())}/// A token as `csrf_token` makes it, issued at `unix_seconds`: for tests of expiry.pub fn csrf_token_at(secret: &str, session_id: &str, unix_seconds: i64) -> Result<String, String> {    check_secret(secret)?;    if session_id.is_empty() {        return Err("a token needs a session id: give the visitor a session first".into());    }    let mut n = [0u8; 16];    rand::thread_rng().fill_bytes(&mut n);    let nonce = URL_SAFE_NO_PAD.encode(n);    let sig = URL_SAFE_NO_PAD.encode(signature(secret, session_id, unix_seconds, &nonce).finalize().into_bytes());    Ok(format!("{}.{}.{}", unix_seconds, nonce, sig))}/// Checks a token sent back with a form or a header: signed with `secret` for this `session_id`, and issued less than/// `max_age_seconds` ago (0: no limit). Gives true, or an error value saying why it is refused.pub fn csrf_verify(secret: &str, session_id: &str, token: &str, max_age_seconds: i64) -> Result<bool, String> {    check_secret(secret)?;    let parts: Vec<&str> = token.trim().split('.').collect();    let [issued, nonce, sig] = parts[..] else { return Err("no CSRF token, or not one of ours".into()) };    let issued: i64 = issued.parse().map_err(|_| "no CSRF token, or not one of ours".to_string())?;    let sig = URL_SAFE_NO_PAD.decode(sig).map_err(|_| "the CSRF token is damaged".to_string())?;    signature(secret, session_id, issued, nonce).verify_slice(&sig).map_err(|_| "the CSRF token was made for another session, or changed".to_string())?;    let age = now() - issued;    if age < -300 {        return Err("the CSRF token was issued in the future: a clock is off".into());    }    if max_age_seconds > 0 && age > max_age_seconds {        return Err(format!("the CSRF token is {} s old, more than the {} s allowed: reload the page", age, max_age_seconds));    }    Ok(true)}/// The signed double-submit check, for pages that cannot keep a token on the server: the token in a cookie/// (`cookie_token`) and the one sent in the form or header (`sent_token`) must be the same, and signed for this session.pub fn csrf_double_submit(secret: &str, session_id: &str, cookie_token: &str, sent_token: &str, max_age_seconds: i64) -> Result<bool, String> {    let (a, b) = (cookie_token.trim().as_bytes(), sent_token.trim().as_bytes());    if a.is_empty() || b.is_empty() {        return Err("the CSRF cookie or the token sent with the request is missing".into());    }    if a.len() != b.len() || a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) != 0 {        return Err("the token sent is not the one in the CSRF cookie".into());    }    csrf_verify(secret, session_id, sent_token, max_age_seconds)}fn origin_of(u: &str) -> Option<String> {    let u = url::Url::parse(u).ok()?;    match u.origin() {        url::Origin::Tuple(..) => Some(u.origin().ascii_serialization()),        _ => None,    }}/// Whether a request that changes something came from your own pages, by its headers (lower-case names, as/// `request.headers` has them): `Sec-Fetch-Site` same-origin (or none: typed or bookmarked), else `Origin`, else the/// `Referer`'s origin, must be one of `allowed_origins` (`https://shop.example.com`). Gives true, or an error value saying why not.pub fn csrf_origin_check(headers: std::collections::HashMap<String, String>, allowed_origins: Vec<String>) -> Result<bool, String> {    let h = |k: &str| headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(k)).map(|(_, v)| v.trim().to_string());    let allowed: Vec<String> = allowed_origins.iter().filter_map(|o| origin_of(o)).collect();    if let Some(site) = h("sec-fetch-site") {        match site.as_str() {            "same-origin" | "none" => return Ok(true),            "same-site" | "cross-site" => {}            other => return Err(format!("Sec-Fetch-Site `{}` is not one browsers send", other)),        }    }    let (from, what) = match (h("origin").filter(|o| o != "null"), h("referer")) {        (Some(o), _) => (origin_of(&o), "Origin"),        (None, Some(r)) => (origin_of(&r), "Referer"),        (None, None) => return Err("no Origin, Referer or Sec-Fetch-Site header: refused, as a page on another site could send it".into()),    };    match from {        Some(o) if allowed.contains(&o) => Ok(true),        Some(o) => Err(format!("the request came from {} ({}), not from {}", o, what, allowed.join(", "))),        None => Err(format!("the {} header is not a URL", what)),    }}
Thread
77Useful? Like it, or add to its thread below.

Thread 0 posts

On CSRF tokens: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.