use axess_rng::SecureRng;
use axum::{
body::Body,
http::{HeaderValue, Request, Response, StatusCode, header},
response::IntoResponse,
};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use subtle::ConstantTimeEq;
use tower::{Layer, Service};
use url::Url;
use zeroize::Zeroizing;
use crate::session::id::SessionId;
use crate::session::layer::SessionHandle;
pub const DEFAULT_CSRF_COOKIE: &str = "axess.csrf";
pub const DEFAULT_CSRF_HEADER: &str = "x-csrf-token";
pub const DEFAULT_CSRF_FORM_FIELD: &str = "_csrf";
pub const DEFAULT_CSRF_FORM_BODY_LIMIT: usize = 64 * 1024;
const TOKEN_NONCE_BYTES: usize = 32;
use crate::cookies::MAX_COOKIE_VALUE_BYTES;
#[derive(Clone)]
pub struct CsrfConfig {
signing_key: Arc<Zeroizing<[u8; 32]>>,
rng: Arc<dyn SecureRng>,
cookie_name: Arc<str>,
header_name: Arc<str>,
form_field_name: Arc<str>,
form_body_limit: usize,
secure: bool,
same_site: tower_cookies::cookie::SameSite,
path: Arc<str>,
allowed_origins: Arc<[Arc<str>]>,
}
fn should_warn_dropped_origins(kept: usize, rejected: usize) -> bool {
kept > 0 && rejected > 0
}
impl CsrfConfig {
pub fn new(signing_key: [u8; 32]) -> Self {
Self {
signing_key: Arc::new(Zeroizing::new(signing_key)),
rng: Arc::new(axess_rng::SystemRng),
cookie_name: DEFAULT_CSRF_COOKIE.into(),
header_name: DEFAULT_CSRF_HEADER.into(),
form_field_name: DEFAULT_CSRF_FORM_FIELD.into(),
form_body_limit: DEFAULT_CSRF_FORM_BODY_LIMIT,
secure: true,
same_site: tower_cookies::cookie::SameSite::Lax,
path: "/".into(),
allowed_origins: Arc::from(Vec::new()),
}
}
pub fn rng(mut self, rng: impl SecureRng) -> Self {
self.rng = Arc::new(rng);
self
}
pub fn cookie_name(mut self, name: impl Into<Arc<str>>) -> Self {
self.cookie_name = name.into();
self
}
pub fn header_name(mut self, name: impl Into<Arc<str>>) -> Self {
self.header_name = name.into();
self
}
pub fn form_field_name(mut self, name: impl Into<Arc<str>>) -> Self {
self.form_field_name = name.into();
self
}
pub fn form_body_limit(mut self, limit: usize) -> Self {
self.form_body_limit = limit;
self
}
pub fn secure(mut self, secure: bool) -> Self {
self.secure = secure;
self
}
pub fn same_site(mut self, same_site: tower_cookies::cookie::SameSite) -> Self {
self.same_site = same_site;
self
}
pub fn require_origin(mut self, origins: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
let mut normalized: Vec<Arc<str>> = Vec::new();
let mut supplied_any = false;
let mut rejected: Vec<String> = Vec::new();
for raw in origins {
supplied_any = true;
let raw = raw.as_ref();
match normalize_origin(raw) {
Some(n) => normalized.push(Arc::from(n)),
None => rejected.push(raw.to_owned()),
}
}
assert!(
!(supplied_any && normalized.is_empty()),
"CsrfConfig::require_origin: all {} supplied origin(s) failed to canonicalize \
(missing scheme, opaque-origin scheme, or unparseable URL): {:?}. \
Leaving the list empty would silently disable the Origin/Referer gate.",
rejected.len(),
rejected,
);
if should_warn_dropped_origins(normalized.len(), rejected.len()) {
tracing::warn!(
rejected = ?rejected,
kept = normalized.len(),
"CsrfConfig::require_origin: {} supplied origin(s) failed to \
canonicalize and were dropped. Requests from them will be \
rejected as if they were never allow-listed.",
rejected.len(),
);
}
self.allowed_origins = normalized.into();
self
}
}
#[derive(Clone, Debug)]
pub struct CsrfToken(pub String);
impl CsrfToken {
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone)]
pub struct CsrfIssuer {
state: Arc<IssuerState>,
}
struct IssuerState {
signing_key: Arc<Zeroizing<[u8; 32]>>,
rng: Arc<dyn SecureRng>,
handle: Option<SessionHandle>,
entry: Option<(SessionId, String)>,
rotated: std::sync::Mutex<Option<(SessionId, String)>>,
}
impl CsrfIssuer {
fn new(
signing_key: Arc<Zeroizing<[u8; 32]>>,
rng: Arc<dyn SecureRng>,
handle: Option<SessionHandle>,
entry: Option<(SessionId, String)>,
) -> Self {
Self {
state: Arc::new(IssuerState {
signing_key,
rng,
handle,
entry,
rotated: std::sync::Mutex::new(None),
}),
}
}
pub async fn token(&self) -> CsrfToken {
let Some(handle) = &self.state.handle else {
return CsrfToken(String::new());
};
let current = handle.0.read().await.id;
CsrfToken(self.token_for(current))
}
fn token_for(&self, current: SessionId) -> String {
if let Some((entry_id, token)) = &self.state.entry
&& *entry_id == current
{
return token.clone();
}
let mut rotated = self
.state
.rotated
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match rotated.as_ref() {
Some((id, token)) if *id == current => token.clone(),
_ => {
let token = mint_token(
current.as_bytes(),
&self.state.signing_key,
&*self.state.rng,
);
*rotated = Some((current, token.clone()));
token
}
}
}
}
#[derive(Clone)]
pub struct CsrfLayer {
config: CsrfConfig,
}
impl CsrfLayer {
pub fn new(config: CsrfConfig) -> Self {
Self { config }
}
}
impl<S> Layer<S> for CsrfLayer {
type Service = CsrfService<S>;
fn layer(&self, inner: S) -> Self::Service {
CsrfService {
inner,
config: self.config.clone(),
}
}
}
#[derive(Clone)]
pub struct CsrfService<S> {
inner: S,
config: CsrfConfig,
}
impl<S> Service<Request<Body>> for CsrfService<S>
where
S: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
S::Future: Send + 'static,
S::Error: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: Request<Body>) -> Self::Future {
let config = self.config.clone();
let mut inner = self.inner.clone();
std::mem::swap(&mut inner, &mut self.inner);
Box::pin(async move {
let cookie_token = extract_cookie_token(&req, &config.cookie_name);
let session_handle = req.extensions().get::<SessionHandle>().cloned();
let session_id = match &session_handle {
Some(handle) => Some(handle.0.read().await.id),
None => None,
};
if is_state_changing(req.method()) {
req = match validate_state_changing(
req,
&config,
session_id,
cookie_token.as_deref(),
)
.await
{
Ok(req) => req,
Err(Refused) => return Ok(forbidden()),
};
}
let EntryToken {
provisional_mint,
extension_token,
} = entry_token(&config, cookie_token.as_deref(), session_id);
let issuer = CsrfIssuer::new(
config.signing_key.clone(),
config.rng.clone(),
session_handle.clone(),
session_id.map(|sid| (sid, extension_token.clone())),
);
req.extensions_mut().insert(CsrfToken(extension_token));
req.extensions_mut().insert(issuer.clone());
let mut response = inner.call(req).await?;
let session_id_after = match &session_handle {
Some(handle) => Some(handle.0.read().await.id),
None => None,
};
let token_to_set = replacement_token(
&config,
&issuer,
provisional_mint,
cookie_token.as_deref(),
session_id_after,
);
if token_to_set.is_some()
&& let Some(handle) = &session_handle
{
handle.mark_needs_persist().await;
}
if let Some(new_token) = token_to_set {
let cookie = build_cookie(&config, &new_token);
if let Ok(hv) = HeaderValue::from_str(&cookie) {
response.headers_mut().append(header::SET_COOKIE, hv);
}
}
Ok(response)
})
}
}
struct Refused;
fn forbidden() -> Response<Body> {
(StatusCode::FORBIDDEN, "CSRF validation failed").into_response()
}
async fn validate_state_changing(
mut req: Request<Body>,
config: &CsrfConfig,
session_id: Option<SessionId>,
cookie_token: Option<&str>,
) -> Result<Request<Body>, Refused> {
let method = req.method().clone();
let Some(session_id) = session_id else {
tracing::warn!(
method = %method,
path = %req.uri().path(),
"csrf: no session id on state-changing request \
(CsrfLayer must be layered inside the session layer)"
);
return Err(Refused);
};
if !config.allowed_origins.is_empty() && !origin_matches_allowed(&req, &config.allowed_origins)
{
tracing::warn!(
method = %method,
path = %req.uri().path(),
"csrf: Origin/Referer validation failed"
);
return Err(Refused);
}
let (extracted, req_after) = match extract_presented_token(req, config).await {
Ok(pair) => pair,
Err(reason) => {
tracing::warn!(
method = %method,
reason = %reason,
"csrf: request rejected during token extraction"
);
return Err(Refused);
}
};
req = req_after;
let presented = extracted;
let cookie_present = cookie_token;
if !validate_pair(
cookie_present,
presented.as_deref(),
session_id.as_bytes(),
&config.signing_key,
) {
tracing::warn!(
method = %method,
path = %req.uri().path(),
cookie_present = cookie_present.is_some(),
header_or_form_present = presented.is_some(),
"csrf: token validation failed"
);
return Err(Refused);
}
Ok(req)
}
struct EntryToken {
provisional_mint: Option<String>,
extension_token: String,
}
fn entry_token(
config: &CsrfConfig,
cookie_token: Option<&str>,
session_id: Option<SessionId>,
) -> EntryToken {
let usable_cookie = cookie_token.filter(|c| {
!c.is_empty()
&& session_id
.as_ref()
.is_none_or(|sid| validate_token(c, sid.as_bytes(), &config.signing_key))
});
let provisional_mint = match (usable_cookie, session_id.as_ref()) {
(Some(_), _) => None,
(None, Some(sid_at_entry)) => Some(mint_token(
sid_at_entry.as_bytes(),
&config.signing_key,
&*config.rng,
)),
(None, None) => None,
};
let extension_token = provisional_mint
.clone()
.or_else(|| usable_cookie.map(str::to_owned))
.or_else(|| cookie_token.map(str::to_owned))
.unwrap_or_default();
EntryToken {
provisional_mint,
extension_token,
}
}
fn replacement_token(
config: &CsrfConfig,
issuer: &CsrfIssuer,
provisional_mint: Option<String>,
cookie_token: Option<&str>,
session_id_after: Option<SessionId>,
) -> Option<String> {
let effective_cookie = provisional_mint
.as_deref()
.or(cookie_token)
.filter(|t| !t.is_empty());
match (effective_cookie, session_id_after.as_ref()) {
(Some(existing), Some(sid_after))
if validate_token(existing, sid_after.as_bytes(), &config.signing_key) =>
{
provisional_mint
}
(_, Some(sid_after)) => {
Some(issuer.token_for(*sid_after))
}
(_, None) => None,
}
}
fn is_state_changing(method: &axum::http::Method) -> bool {
!matches!(
*method,
axum::http::Method::GET
| axum::http::Method::HEAD
| axum::http::Method::OPTIONS
| axum::http::Method::TRACE
)
}
fn extract_cookie_token(req: &Request<Body>, cookie_name: &str) -> Option<String> {
crate::cookies::extract_named_cookie(req.headers(), cookie_name, MAX_COOKIE_VALUE_BYTES)
}
async fn extract_presented_token(
req: Request<Body>,
config: &CsrfConfig,
) -> Result<(Option<String>, Request<Body>), &'static str> {
if let Some(value) = req.headers().get(config.header_name.as_ref())
&& let Ok(s) = value.to_str()
&& !s.is_empty()
{
return Ok((Some(s.to_string()), req));
}
let is_urlencoded = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| {
let base = s.split(';').next().unwrap_or("").trim();
base.eq_ignore_ascii_case("application/x-www-form-urlencoded")
})
.unwrap_or(false);
if !is_urlencoded {
return Ok((None, req));
}
if let Some(declared) = req
.headers()
.get(header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<usize>().ok())
&& declared > config.form_body_limit
{
return Err("form body exceeds csrf buffer cap");
}
let (parts, body) = req.into_parts();
let bytes = match axum::body::to_bytes(body, config.form_body_limit).await {
Ok(b) => b,
Err(_) => return Err("form body exceeds csrf buffer cap"),
};
let field = form_urlencoded::parse(&bytes)
.find(|(k, _)| k.as_ref() == config.form_field_name.as_ref())
.map(|(_, v)| v.into_owned())
.filter(|s| !s.is_empty());
let restored = Request::from_parts(parts, Body::from(bytes));
Ok((field, restored))
}
fn normalize_origin(raw: &str) -> Option<String> {
let parsed = Url::parse(raw).ok()?;
match parsed.origin() {
url::Origin::Tuple(..) => Some(parsed.origin().ascii_serialization()),
url::Origin::Opaque(_) => None,
}
}
fn origin_matches_allowed(req: &Request<Body>, allowed: &[Arc<str>]) -> bool {
if let Some(header) = req.headers().get(header::ORIGIN) {
let Ok(s) = header.to_str() else { return false };
let Some(origin) = normalize_origin(s) else {
return false;
};
return allowed.iter().any(|a| a.as_ref() == origin);
}
let Some(referer) = req.headers().get(header::REFERER) else {
return false;
};
let Ok(referer_str) = referer.to_str() else {
return false;
};
let Some(origin) = normalize_origin(referer_str) else {
return false;
};
allowed.iter().any(|a| a.as_ref() == origin)
}
fn validate_pair(
cookie_token: Option<&str>,
presented: Option<&str>,
session_id: &[u8],
signing_key: &[u8; 32],
) -> bool {
let (Some(c), Some(p)) = (cookie_token, presented) else {
return false;
};
if c.is_empty() || p.is_empty() {
return false;
}
bool::from(c.as_bytes().ct_eq(p.as_bytes())) && validate_token(c, session_id, signing_key)
}
fn build_cookie(config: &CsrfConfig, token: &str) -> String {
use tower_cookies::Cookie;
let mut cookie = Cookie::new(config.cookie_name.as_ref().to_string(), token.to_string());
cookie.set_http_only(false);
cookie.set_secure(config.secure);
cookie.set_same_site(config.same_site);
cookie.set_path(config.path.as_ref().to_string());
cookie.to_string()
}
mod token;
use token::{mint_token, validate_token};
#[cfg(test)]
mod tests;
#[cfg(test)]
mod rotation_tests;