From 86888b3877a0384a811f1b9cb80934162e084300 Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 21 Jun 2026 17:40:21 +0200 Subject: [PATCH] csrf implemented --- Cargo.lock | 6 + Cargo.toml | 7 + assets/views/admin/base.html | 30 ++++ assets/views/base.html | 30 ++++ src/app.rs | 6 + src/shared/csrf.rs | 279 +++++++++++++++++++++++++++++++++++ src/shared/mod.rs | 1 + 7 files changed, 359 insertions(+) create mode 100644 src/shared/csrf.rs diff --git a/Cargo.lock b/Cargo.lock index 73e291e..09f9267 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2650,11 +2650,15 @@ dependencies = [ "chrono", "dotenvy", "fluent-templates", + "form_urlencoded", + "futures-util", + "hmac", "include_dir", "insta", "loco-oauth2", "loco-rs", "migration", + "multer", "passwords", "regex", "reqwest", @@ -2663,6 +2667,8 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", + "subtle", "time", "tokio", "totp-rs", diff --git a/Cargo.toml b/Cargo.toml index 06a468f..e242543 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,13 @@ passwords = "3.1.16" tower-sessions = "0.14" # TOTP (Google Authenticator) for optional two-factor auth totp-rs = { version = "5", features = ["qr", "gen_secret"] } +# CSRF: HMAC-signed double-submit token + body inspection for the `_csrf` field +hmac = { version = "0.12" } +sha2 = { version = "0.10" } +subtle = { version = "2.6" } +form_urlencoded = { version = "1" } +multer = { version = "3" } +futures-util = { version = "0.3" } [[bin]] name = "kompress-eshop-cli" diff --git a/assets/views/admin/base.html b/assets/views/admin/base.html index a2734d2..53f85a0 100644 --- a/assets/views/admin/base.html +++ b/assets/views/admin/base.html @@ -43,6 +43,36 @@ {% block head %}{% endblock head %} + + + + .` — the HMAC is keyed by the app's JWT secret, so the +//! server can recognise its own tokens without storing any per-session state +//! (no session table, survives restarts and multiple instances). On every unsafe +//! request ([`protect`]) the same token must be echoed back, either as the +//! `X-CSRF-Token` header (htmx requests) or a `_csrf` form field (native +//! `
` submits, which cannot set a custom header). Because a cross-origin +//! attacker can neither read the cookie nor forge a valid signature, they cannot +//! produce a matching echo. +//! +//! The browser side lives in the two base templates (`base.html`, +//! `admin/base.html`): an `htmx:configRequest` hook adds the header and a +//! `submit` hook injects the hidden `_csrf` field. +//! +//! `/api/*` is exempt: that subtree is the token-authenticated JSON API and the +//! OAuth2 callback, neither of which is driven by the browser session cookie. + +use axum::{ + body::{to_bytes, Body}, + extract::{Request, State}, + http::{header, Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use bytes::Bytes; +use hmac::{Hmac, Mac}; +use loco_rs::prelude::*; +use sha2::Sha256; +use subtle::ConstantTimeEq; +use time::Duration as TimeDuration; +use uuid::Uuid; + +/// Cookie that holds the signed token. Deliberately *not* `HttpOnly`: the page +/// JS has to read it to echo it back in the header / hidden field. +pub const CSRF_COOKIE: &str = "csrf_token"; +/// Header carrying the echoed token on htmx (and any scripted) requests. +pub const CSRF_HEADER: &str = "x-csrf-token"; +/// Hidden form field carrying the echoed token on native `` submits. +pub const CSRF_FIELD: &str = "_csrf"; + +/// Cookie lifetime. Long enough to outlast a normal browsing session; the token +/// only needs to be stable, not short-lived (it is not a credential on its own). +const COOKIE_MAX_AGE_SECS: i64 = 60 * 60 * 24 * 14; +/// Upper bound on a body we will buffer to find the `_csrf` field. Covers the +/// largest native multipart submit (a 10 MiB image upload plus the other +/// fields); anything bigger is rejected rather than buffered. +const MAX_BODY_BYTES: usize = 16 * 1024 * 1024; + +type HmacSha256 = Hmac; + +fn to_hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +/// HMAC-SHA256 of the random part, keyed by the app secret. +fn sign(secret: &str, random: &str) -> String { + let mut mac = + HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts a key of any length"); + mac.update(random.as_bytes()); + to_hex(&mac.finalize().into_bytes()) +} + +/// Mint a fresh `.` token. +pub fn make_token(secret: &str) -> String { + let random = Uuid::new_v4().simple().to_string(); + let sig = sign(secret, &random); + format!("{random}.{sig}") +} + +/// True when `token` is well-formed and its signature is the one this server +/// would produce — i.e. it is one of *our* tokens, not an attacker-injected one. +fn signature_valid(secret: &str, token: &str) -> bool { + let Some((random, sig)) = token.split_once('.') else { + return false; + }; + let expected = sign(secret, random); + expected.as_bytes().ct_eq(sig.as_bytes()).into() +} + +/// Constant-time equality of two full tokens (the double-submit comparison). +fn tokens_match(a: &str, b: &str) -> bool { + a.as_bytes().ct_eq(b.as_bytes()).into() +} + +fn issue_cookie(token: String) -> Cookie<'static> { + Cookie::build((CSRF_COOKIE, token)) + .path("/") + .http_only(false) + .same_site(SameSite::Lax) + .max_age(TimeDuration::seconds(COOKIE_MAX_AGE_SECS)) + .build() +} + +fn forbidden(reason: &str) -> Response { + tracing::debug!(reason, "CSRF check rejected request"); + (StatusCode::FORBIDDEN, "CSRF validation failed").into_response() +} + +/// Attach a freshly minted token cookie to an outgoing response. +fn set_fresh_cookie(res: &mut Response, secret: &str) { + let cookie = issue_cookie(make_token(secret)); + if let Ok(value) = cookie.encoded().to_string().parse() { + res.headers_mut().append(header::SET_COOKIE, value); + } +} + +/// Pull the `_csrf` value out of an `application/x-www-form-urlencoded` body. +fn field_from_urlencoded(bytes: &[u8]) -> Option { + form_urlencoded::parse(bytes) + .find(|(k, _)| k == CSRF_FIELD) + .map(|(_, v)| v.into_owned()) +} + +/// Pull the `_csrf` value out of a `multipart/form-data` body. Parses a *copy* +/// of the buffered bytes purely to read the field; the original bytes are still +/// forwarded to the handler unchanged. +async fn field_from_multipart(content_type: &str, bytes: Bytes) -> Option { + let boundary = multer::parse_boundary(content_type).ok()?; + let stream = futures_util::stream::once(async move { Ok::<_, std::io::Error>(bytes) }); + let mut multipart = multer::Multipart::new(stream, boundary); + while let Ok(Some(field)) = multipart.next_field().await { + if field.name() == Some(CSRF_FIELD) { + return field.text().await.ok(); + } + } + None +} + +/// CSRF enforcement middleware. Safe methods get a token cookie (minted if +/// missing); unsafe methods must echo a valid, matching token. See the module +/// docs for the full scheme. +pub async fn protect( + State(ctx): State, + jar: CookieJar, + req: Request, + next: Next, +) -> Response { + // The token is keyed by the JWT secret. If no secret is configured we cannot + // validate, so fail open rather than 403 the whole site — the same secret is + // already required for auth to work at all. + let Ok(jwt) = ctx.config.get_jwt_config() else { + return next.run(req).await; + }; + let secret = jwt.secret.clone(); + + // The token the browser currently holds, accepted only if untampered. + let cookie_token = jar + .get(CSRF_COOKIE) + .map(|c| c.value().to_string()) + .filter(|t| signature_valid(&secret, t)); + + let is_safe = matches!( + *req.method(), + Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE + ); + // Token-auth JSON API + OAuth2 callback: not browser-cookie-driven. + let exempt = req.uri().path().starts_with("/api/"); + + if is_safe || exempt { + let mut res = next.run(req).await; + if cookie_token.is_none() { + set_fresh_cookie(&mut res, &secret); + } + return res; + } + + // ---- unsafe, non-exempt: require a valid double-submit ---- + let Some(expected) = cookie_token else { + return forbidden("missing or tampered CSRF cookie"); + }; + + // htmx and other scripted requests send the token as a header; prefer it so + // we never have to touch the body. + if let Some(header_token) = req.headers().get(CSRF_HEADER).and_then(|v| v.to_str().ok()) { + if tokens_match(header_token, &expected) { + return next.run(req).await; + } + return forbidden("CSRF header did not match cookie"); + } + + // Native submit: the token is a `_csrf` body field. Buffer the body, + // read the field from a copy, then forward the original bytes untouched. + let content_type = req + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let (parts, body) = req.into_parts(); + let Ok(bytes) = to_bytes(body, MAX_BODY_BYTES).await else { + return forbidden("request body too large to validate CSRF"); + }; + + let submitted = if content_type.starts_with("application/x-www-form-urlencoded") { + field_from_urlencoded(&bytes) + } else if content_type.starts_with("multipart/form-data") { + field_from_multipart(&content_type, bytes.clone()).await + } else { + None + }; + + let valid = submitted + .as_deref() + .is_some_and(|t| tokens_match(t, &expected)); + if !valid { + return forbidden("missing or non-matching CSRF form field"); + } + + next.run(Request::from_parts(parts, Body::from(bytes))).await +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "test-secret"; + + #[test] + fn fresh_token_validates() { + let token = make_token(SECRET); + assert!(signature_valid(SECRET, &token)); + } + + #[test] + fn tampered_or_foreign_token_rejected() { + let token = make_token(SECRET); + // Flip the random part but keep the old signature. + let (_, sig) = token.split_once('.').unwrap(); + let forged = format!("{}.{sig}", Uuid::new_v4().simple()); + assert!(!signature_valid(SECRET, &forged)); + // A token minted under a different secret is not ours. + assert!(!signature_valid(SECRET, &make_token("other-secret"))); + // Malformed input never validates. + assert!(!signature_valid(SECRET, "no-dot-here")); + } + + #[test] + fn double_submit_compare() { + let token = make_token(SECRET); + assert!(tokens_match(&token, &token.clone())); + assert!(!tokens_match(&token, &make_token(SECRET))); + } + + #[test] + fn extract_field_from_urlencoded() { + let body = b"email=a%40b.com&_csrf=abc.def&password=x"; + assert_eq!(field_from_urlencoded(body), Some("abc.def".to_string())); + assert_eq!(field_from_urlencoded(b"email=x"), None); + } + + #[tokio::test] + async fn extract_field_from_multipart() { + let boundary = "X-BOUNDARY"; + let content_type = format!("multipart/form-data; boundary={boundary}"); + let body = format!( + "--{b}\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nWidget\r\n\ + --{b}\r\nContent-Disposition: form-data; name=\"_csrf\"\r\n\r\nabc.def\r\n\ + --{b}--\r\n", + b = boundary + ); + let got = field_from_multipart(&content_type, Bytes::from(body)).await; + assert_eq!(got, Some("abc.def".to_string())); + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 33b70aa..0060742 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -1,5 +1,6 @@ //! Cross-cutting helpers used across feature slices. +pub mod csrf; pub mod guard; pub mod money; pub mod rbac;