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
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 errorA 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 errorA 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 errorChecks 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 errorThe 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 errorWhether 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.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/csrf_tokens.qwer$ curl -O https://quillolang.org/plugins/files/csrf_tokens.qweThen, in your program, all of it or only what you call:
use csrf_tokens.qweruse csrf_token, csrf_token_at from csrf_tokens // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
hmac = "0.12"sha2 = "0.10"base64 = "0.22"rand = "0.8"url = "2"Example
// 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 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)), }}Related
paseto.qwer · 1.0.0PASETO v4 tokens, the safer JWT: v4.local encrypted with a shared key and v4.public signed with Ed25519, expiry checked.7 views · 9 downloadsFile type file_type.qwer · 1.0.0What a file really is from its first bytes, not its name: MIME type, extension and kind, for upload checks.7 views · 9 downloadsArgon2 passwords argon2.qwer · 1.0.0Argon2id password hashes with the memory and passes you choose, any Argon2 hash verified, its parameters read, old ones marked for a rehash, and the cost timed on this server.7 views · 6 downloadsBiscuit tokens biscuit.qwer · 1.0.0Biscuit authorization tokens: issued with Datalog facts and checks signed by your key, narrowed by their holder without asking the issuer, and authorized offline against each request.7 views · 8 downloadsCaptcha captcha.qwer · 1.0.0Picture captchas made on your own server: distorted letters as a PNG data URL and the answer to keep in the session.7 views · 6 downloadsContent-Security-Policy csp.qwer · 1.0.0Content-Security-Policy headers built from a map and read back, a nonce per page, the policy graded for the holes that let scripts in, and whether a URL would load under it.7 views · 7 downloadsThread 0 posts
On CSRF tokens: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.