use std::sync::Arc;
use axum::extract::{Request, State};
use axum::http::{HeaderMap, Method, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use uuid::Uuid;
pub const DEFAULT_HEADER: &str = "x-csrf-token";
#[derive(Clone)]
pub struct CsrfConfig {
pub cookie_name: Arc<str>,
pub header: Arc<str>,
}
impl CsrfConfig {
pub fn new(cookie_name: impl Into<Arc<str>>) -> Self {
Self {
cookie_name: cookie_name.into(),
header: Arc::from(DEFAULT_HEADER),
}
}
}
pub fn token() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
pub fn build_cookie(name: &str, value: &str, max_age: u64, secure: bool) -> String {
let secure = secure || name.starts_with("__Host-") || name.starts_with("__Secure-");
let mut c = format!("{name}={value}; Path=/; SameSite=Lax; Max-Age={max_age}");
if secure {
c.push_str("; Secure");
}
c
}
pub async fn csrf_protect(State(cfg): State<CsrfConfig>, req: Request, next: Next) -> Response {
let safe = is_safe(req.method());
let bearer = req.headers().contains_key(header::AUTHORIZATION);
let ok = {
let cookie_tok = parse_cookie(req.headers(), &cfg.cookie_name);
let header_tok = req
.headers()
.get(&*cfg.header)
.and_then(|v| v.to_str().ok());
allowed(safe, bearer, cookie_tok, header_tok)
};
if ok {
next.run(req).await
} else {
tracing::debug!("csrf token missing or invalid on state-changing request");
(StatusCode::FORBIDDEN, "csrf token missing or invalid").into_response()
}
}
fn is_safe(method: &Method) -> bool {
matches!(
*method,
Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
)
}
fn parse_cookie<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
raw.split(';').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k.trim() == name).then(|| v.trim())
})
}
fn allowed(safe: bool, bearer: bool, cookie_tok: Option<&str>, header_tok: Option<&str>) -> bool {
if safe || bearer {
return true;
}
matches!((cookie_tok, header_tok), (Some(c), Some(h)) if !c.is_empty() && c == h)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_methods_and_bearer_bypass() {
assert!(
allowed(true, false, None, None),
"safe method allowed without token"
);
assert!(
allowed(false, true, None, None),
"bearer request allowed without token"
);
}
#[test]
fn mismatch_or_missing_is_rejected() {
assert!(
!allowed(false, false, Some("abc"), Some("xyz")),
"mismatch rejected"
);
assert!(
!allowed(false, false, Some("abc"), None),
"missing header rejected"
);
assert!(
!allowed(false, false, None, Some("abc")),
"missing cookie rejected"
);
assert!(
!allowed(false, false, Some(""), Some("")),
"empty token rejected"
);
}
#[test]
fn matching_double_submit_is_allowed() {
assert!(allowed(false, false, Some("tok"), Some("tok")), "match ok");
}
#[test]
fn readable_cookie_has_no_httponly() {
let c = build_cookie("zerg_csrf", "tok", 60, false);
assert!(
!c.contains("HttpOnly"),
"csrf cookie must be readable by the SPA"
);
assert!(c.contains("SameSite=Lax"));
}
}