use axum::{
extract::Request,
http::{StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use rand::distr::{Alphanumeric, SampleString};
use subtle::ConstantTimeEq;
pub fn generate_csrf_token() -> String {
Alphanumeric.sample_string(&mut rand::rng(), 32)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CsrfToken(String);
impl CsrfToken {
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug)]
struct CsrfMiddlewareApplied;
#[derive(serde::Deserialize)]
struct CsrfForm {
_token: Option<String>,
}
pub(crate) fn extract_token_from_body(bytes: &[u8]) -> Option<String> {
serde_urlencoded::from_bytes::<CsrfForm>(bytes)
.ok()
.and_then(|form| form._token)
}
pub async fn csrf_middleware(mut req: Request, next: Next) -> Response {
if let Some(policy) = req
.extensions()
.get::<super::MachineEndpointPolicy>()
.cloned()
&& policy.matches(&req)
{
return match policy.authenticate(req).await {
Ok(request) => next.run(request).await,
Err(status) => (status, "Machine request rejected").into_response(),
};
}
if req.extensions().get::<CsrfMiddlewareApplied>().is_some() {
return next.run(req).await;
}
if is_signed_webhook_exemption(&req) {
return next.run(req).await;
}
req.extensions_mut().insert(CsrfMiddlewareApplied);
let method = req.method();
if method == axum::http::Method::GET || method == axum::http::Method::HEAD {
handle_csrf_get(req, next).await
} else if method == axum::http::Method::OPTIONS || method == axum::http::Method::TRACE {
next.run(req).await
} else {
handle_csrf_state_modifying(req, next).await
}
}
fn is_signed_webhook_exemption(req: &Request) -> bool {
if req.method() != axum::http::Method::POST {
return false;
}
let config = req
.extensions()
.get::<crate::config::SecurityConfig>()
.unwrap_or(&crate::config::RullstConfig::global().security);
config
.csrf_signed_webhook_paths
.iter()
.any(|path| path == req.uri().path())
}
pub(crate) fn is_csrf_exempt_path(path: &str) -> bool {
path == "/robots.txt"
|| path == "/sitemap.xml"
|| path == "/favicon.ico"
|| path.starts_with("/static/")
|| path.ends_with(".txt")
|| path.ends_with(".xml")
|| path.ends_with(".ico")
|| path.ends_with(".json")
|| path.ends_with(".css")
|| path.ends_with(".js")
|| path.ends_with(".png")
|| path.ends_with(".jpg")
|| path.ends_with(".jpeg")
|| path.ends_with(".svg")
|| path.ends_with(".webp")
|| path.ends_with(".wasm")
}
async fn handle_csrf_get(mut req: Request, next: Next) -> Response {
if is_csrf_exempt_path(req.uri().path()) {
return next.run(req).await;
}
let cookie_token = csrf_token_from_cookies(req.headers());
if let Some(token) = cookie_token {
req.extensions_mut().insert(CsrfToken(token));
return next.run(req).await;
}
let token = generate_csrf_token();
req.extensions_mut().insert(CsrfToken(token.clone()));
{
let same_site = req
.extensions()
.get::<crate::config::SecurityConfig>()
.map(|cfg| cfg.csrf_same_site.clone())
.unwrap_or_else(|| "Lax".to_string());
let secure_cookie = req
.extensions()
.get::<crate::config::Environment>()
.copied()
.map(crate::config::Environment::requires_secure_defaults)
.unwrap_or_else(|| {
crate::config::Environment::detect(None)
.map(crate::config::Environment::requires_secure_defaults)
.unwrap_or(true)
});
let mut response = next.run(req).await;
let secure_attr = if secure_cookie { "; Secure" } else { "" };
if let Ok(cookie_val) = header::HeaderValue::from_str(&format!(
"rullst_csrf={}; Path=/; SameSite={}{}",
token, same_site, secure_attr
)) {
response
.headers_mut()
.append(header::SET_COOKIE, cookie_val);
}
response
}
}
fn csrf_token_from_cookies(headers: &header::HeaderMap) -> Option<String> {
let mut found = None;
for value in headers.get_all(header::COOKIE) {
for cookie in value.as_bytes().split(|byte| *byte == b';') {
let Some(token) = cookie.trim_ascii().strip_prefix(b"rullst_csrf=") else {
continue;
};
if found.is_some()
|| !(1..=128).contains(&token.len())
|| !token
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return None;
}
let Ok(token) = std::str::from_utf8(token) else {
return None;
};
found = Some(token.to_owned());
}
}
found
}
#[cfg_attr(mutants, mutants::skip)]
async fn handle_csrf_state_modifying(mut req: Request, next: Next) -> Response {
let csrf_cookie = csrf_token_from_cookies(req.headers());
let Some(cookie_token) = csrf_cookie else {
return (StatusCode::FORBIDDEN, "CSRF token cookie missing").into_response();
};
let mut header_values = req.headers().get_all("X-CSRF-Token").iter();
let header_token = header_values.next();
if header_values.next().is_some() {
return (StatusCode::FORBIDDEN, "Ambiguous CSRF token").into_response();
}
if let Some(token) = header_token {
if token.as_bytes().len() == cookie_token.len()
&& token.as_bytes().ct_eq(cookie_token.as_bytes()).into()
{
req.extensions_mut().insert(CsrfToken(cookie_token.clone()));
return next.run(req).await;
}
return (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response();
}
let content_type = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if content_type.split(';').next().is_some_and(|media_type| {
media_type
.trim()
.eq_ignore_ascii_case("application/x-www-form-urlencoded")
}) {
let (mut parts, body) = req.into_parts();
let bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
Ok(b) => b,
Err(_) => {
return (StatusCode::BAD_REQUEST, "Failed to read request body").into_response();
}
};
let body_token = extract_token_from_body(&bytes);
if let Some(token) = body_token {
if token.len() == cookie_token.len()
&& token.as_bytes().ct_eq(cookie_token.as_bytes()).into()
{
parts.extensions.insert(CsrfToken(cookie_token));
let reconstructed_req = Request::from_parts(parts, axum::body::Body::from(bytes));
return next.run(reconstructed_req).await;
}
}
}
(StatusCode::FORBIDDEN, "Invalid or missing CSRF token").into_response()
}
#[cfg(test)]
#[path = "csrf_tests.rs"]
mod tests;