use std::convert::Infallible;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::middleware::{self, Next};
use axum::response::Response;
use http::header::{
AUTHORIZATION, COOKIE, HeaderName, HeaderValue, PROXY_AUTHORIZATION, SERVER, SET_COOKIE,
};
use http::{Method, StatusCode};
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::sensitive_headers::SetSensitiveHeadersLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use umbral::prelude::*;
const CSRF_COOKIE: &str = "umbral_csrf_token";
const SESSION_COOKIE: &str = "umbral_session";
const CSRF_HEADER: &str = "x-csrf-token";
const CSRF_FORM_FIELDS: &[&str] = &["csrf_token", "__csrf"];
const MAX_FORM_BODY: usize = 1024 * 1024;
#[derive(Debug, Clone)]
pub struct SecurityConfig {
pub csrf: bool,
pub csrf_cookie_secure: bool,
pub signed_csrf: bool,
pub session_bind_cookie: Option<String>,
pub csrf_exempt_paths: Vec<String>,
pub content_type_options: bool,
pub frame_options: Option<String>,
pub referrer_policy: Option<String>,
pub xss_protection: Option<String>,
pub hsts: bool,
pub hsts_max_age: u64,
pub hsts_include_subdomains: bool,
pub hsts_preload: bool,
pub content_security_policy: Option<String>,
pub permissions_policy: Option<String>,
pub cross_origin_opener_policy: Option<String>,
pub cross_origin_resource_policy: Option<String>,
pub cross_origin_embedder_policy: Option<String>,
pub server_header: Option<String>,
pub hide_server_header: bool,
pub request_body_limit: Option<usize>,
pub redact_sensitive_headers: bool,
pub private_cache: bool,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
csrf: true,
csrf_cookie_secure: false,
signed_csrf: true,
session_bind_cookie: None,
csrf_exempt_paths: Vec::new(),
content_type_options: true,
frame_options: Some("DENY".to_string()),
referrer_policy: Some("strict-origin-when-cross-origin".to_string()),
xss_protection: Some("0".to_string()),
hsts: false,
hsts_max_age: 31_536_000,
hsts_include_subdomains: true,
hsts_preload: false,
content_security_policy: None,
permissions_policy: None,
cross_origin_opener_policy: Some("same-origin".to_string()),
cross_origin_resource_policy: None,
cross_origin_embedder_policy: None,
server_header: Some("umbral".to_string()),
hide_server_header: false,
request_body_limit: None,
redact_sensitive_headers: true,
private_cache: true,
}
}
}
impl SecurityConfig {
pub fn production_hardened() -> Self {
Self {
hsts: true,
hsts_preload: true,
content_security_policy: Some(
"default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
.to_string(),
),
cross_origin_resource_policy: Some("same-origin".to_string()),
csrf_cookie_secure: true,
..Self::default()
}
}
fn hsts_value(&self) -> String {
let mut v = format!("max-age={}", self.hsts_max_age);
if self.hsts_include_subdomains {
v.push_str("; includeSubDomains");
}
if self.hsts_preload {
v.push_str("; preload");
}
v
}
}
#[derive(Debug, Clone, Default)]
pub struct SecurityPlugin {
config: SecurityConfig,
}
impl SecurityPlugin {
pub fn new() -> Self {
Self::default()
}
pub fn with_config(config: SecurityConfig) -> Self {
Self { config }
}
pub fn production_hardened() -> Self {
Self::with_config(SecurityConfig::production_hardened())
}
pub fn config(&self) -> &SecurityConfig {
&self.config
}
pub fn with_hsts(mut self, hsts: bool) -> Self {
self.config.hsts = hsts;
self
}
pub fn csrf_exempt<I, S>(mut self, paths: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.config
.csrf_exempt_paths
.extend(paths.into_iter().map(Into::into));
self
}
}
impl Plugin for SecurityPlugin {
fn name(&self) -> &'static str {
"security"
}
fn wrap_router(&self, router: Router) -> Router {
let cfg = &self.config;
let mut router = router;
if cfg.csrf {
let state = CsrfState::from_config(cfg);
router = router.layer(middleware::from_fn_with_state(state, csrf_middleware));
}
if cfg.private_cache {
router = router.layer(middleware::from_fn(private_cache_middleware));
}
if cfg.content_type_options {
router = set_header(
router,
"x-content-type-options",
Some("nosniff".to_string()),
);
}
router = set_header(router, "x-frame-options", cfg.frame_options.clone());
router = set_header(router, "referrer-policy", cfg.referrer_policy.clone());
router = set_header(router, "x-xss-protection", cfg.xss_protection.clone());
if cfg.hsts {
router = set_header(router, "strict-transport-security", Some(cfg.hsts_value()));
}
router = set_header(
router,
"content-security-policy",
cfg.content_security_policy.clone(),
);
router = set_header(router, "permissions-policy", cfg.permissions_policy.clone());
router = set_header(
router,
"cross-origin-opener-policy",
cfg.cross_origin_opener_policy.clone(),
);
router = set_header(
router,
"cross-origin-resource-policy",
cfg.cross_origin_resource_policy.clone(),
);
router = set_header(
router,
"cross-origin-embedder-policy",
cfg.cross_origin_embedder_policy.clone(),
);
if let Some(v) = cfg.server_header.as_deref() {
if let Ok(hv) = HeaderValue::from_str(v) {
router = router.layer(SetResponseHeaderLayer::overriding(SERVER, hv));
}
} else if cfg.hide_server_header {
router = router.layer(middleware::from_fn(strip_server_header));
}
if cfg.redact_sensitive_headers {
router = router.layer(SetSensitiveHeadersLayer::new([
AUTHORIZATION,
COOKIE,
SET_COOKIE,
]));
}
if let Some(limit) = cfg.request_body_limit {
router = router.layer(RequestBodyLimitLayer::new(limit));
}
router
}
fn on_ready(
&self,
_ctx: &umbral::plugin::AppContext,
) -> Result<(), umbral::plugin::PluginError> {
let settings = umbral::settings::get_opt();
let is_prod = settings
.map(|s| matches!(s.environment, Environment::Prod))
.unwrap_or(false);
if is_prod {
if !self.config.hsts {
tracing::warn!(
"SecurityPlugin: HSTS is disabled in Environment::Prod — responses ship \
no Strict-Transport-Security header, leaving clients open to SSL \
stripping. Enable with `.with_hsts(true)`."
);
}
if self.config.content_security_policy.is_none() {
tracing::warn!(
"SecurityPlugin: no Content-Security-Policy set in Environment::Prod — \
XSS has no CSP backstop. Set `content_security_policy` in SecurityConfig."
);
}
}
check_secret_key(settings, &self.config)?;
Ok(())
}
}
fn set_header(router: Router, name: &'static str, value: Option<String>) -> Router {
match value.as_deref().and_then(|v| HeaderValue::from_str(v).ok()) {
Some(hv) => router.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static(name),
hv,
)),
None => router,
}
}
#[derive(Clone)]
struct CsrfState {
secure: bool,
signed: bool,
secret: Option<String>,
session_cookie: Option<String>,
exempt_paths: Vec<String>,
}
impl CsrfState {
fn from_config(cfg: &SecurityConfig) -> Self {
let is_prod = umbral::settings::get_opt()
.map(|s| matches!(s.environment, Environment::Prod))
.unwrap_or(false);
Self {
secure: cfg.csrf_cookie_secure || is_prod,
signed: cfg.signed_csrf,
secret: None,
session_cookie: cfg.session_bind_cookie.clone(),
exempt_paths: cfg.csrf_exempt_paths.clone(),
}
}
fn resolve_secret(&self) -> Option<String> {
if !self.signed {
return None;
}
self.secret.clone().or_else(|| {
umbral::settings::get_opt()
.map(|s| s.secret_key.trim().to_string())
.filter(|s| !s.is_empty())
})
}
fn is_exempt(&self, path: &str) -> bool {
self.exempt_paths.iter().any(|prefix| {
let prefix = prefix.trim_end_matches('/');
path == prefix || path.starts_with(&format!("{prefix}/"))
})
}
fn session_bind<'a>(&self, session_value: Option<&'a str>) -> Option<&'a str> {
if self.session_cookie.is_some() {
session_value
} else {
None
}
}
fn token_acceptable(&self, token: &str, session_value: Option<&str>) -> bool {
if token.is_empty() {
return false;
}
if !self.signed {
return true;
}
let Some(secret) = self.resolve_secret() else {
return true; };
let Some((raw, sig)) = token.rsplit_once('.') else {
return false;
};
tokens_match(sig, &sign(&secret, raw, self.session_bind(session_value)))
}
}
pub fn generate_token() -> String {
let mut bytes = [0u8; 32];
getrandom::getrandom(&mut bytes).expect("getrandom failed");
hex::encode(bytes)
}
fn sign(secret: &str, raw: &str, session: Option<&str>) -> String {
debug_assert!(
!secret.is_empty(),
"sign() called with an empty secret — CSRF tokens are trivially forgeable; \
on_ready should have rejected boot already"
);
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac =
<Hmac<Sha256>>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
mac.update(raw.as_bytes());
if let Some(s) = session {
mac.update(b".");
mac.update(s.as_bytes());
}
hex::encode(mac.finalize().into_bytes())
}
fn mint_token(state: &CsrfState, session_value: Option<&str>) -> String {
let raw = generate_token();
if state.signed {
if let Some(secret) = state.resolve_secret() {
let sig = sign(&secret, &raw, state.session_bind(session_value));
return format!("{raw}.{sig}");
}
}
raw
}
fn csrf_valid(
state: &CsrfState,
cookie_token: &str,
submitted: &str,
session_value: Option<&str>,
) -> bool {
if !tokens_match(cookie_token, submitted) {
return false;
}
if !state.signed {
return true;
}
let Some(secret) = state.resolve_secret() else {
return true;
};
let Some((raw, sig)) = cookie_token.rsplit_once('.') else {
return false;
};
let expected = sign(&secret, raw, state.session_bind(session_value));
tokens_match(sig, &expected)
}
fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
for part in header.split(';') {
let part = part.trim();
if let Some((k, v)) = part.split_once('=') {
if k == name {
return Some(v);
}
}
}
None
}
fn is_safe_method(method: &Method) -> bool {
matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
}
const PRIVATE_CACHE_CONTROL: &str = "no-store, private";
fn request_is_personalised(headers: &http::HeaderMap) -> bool {
if headers.contains_key(AUTHORIZATION) || headers.contains_key(PROXY_AUTHORIZATION) {
return true;
}
headers
.get_all(COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.any(|h| cookie_value(h, SESSION_COOKIE).is_some())
}
async fn private_cache_middleware(req: Request, next: Next) -> Response {
let personalised = request_is_personalised(req.headers());
let mut resp = next.run(req).await;
if personalised {
resp.headers_mut()
.entry(http::header::CACHE_CONTROL)
.or_insert_with(|| HeaderValue::from_static(PRIVATE_CACHE_CONTROL));
}
resp
}
async fn csrf_middleware(
State(state): State<CsrfState>,
req: Request,
next: Next,
) -> Result<Response, Infallible> {
let method = req.method().clone();
if state.is_exempt(req.uri().path()) {
return Ok(next.run(req).await);
}
let cookie_header = req
.headers()
.get(COOKIE)
.and_then(|h| h.to_str().ok())
.map(str::to_string);
let cookie_token = cookie_header
.as_deref()
.and_then(|h| cookie_value(h, CSRF_COOKIE).map(str::to_string));
let session_value = state.session_cookie.as_deref().and_then(|name| {
cookie_header
.as_deref()
.and_then(|h| cookie_value(h, name).map(str::to_string))
});
if is_safe_method(&method) {
let (token, minted) = match cookie_token {
Some(t) if state.token_acceptable(&t, session_value.as_deref()) => (t, false),
_ => (mint_token(&state, session_value.as_deref()), true),
};
let mut response =
umbral::templates::with_current_csrf(Some(token.clone()), next.run(req)).await;
if minted {
let mut cookie = format!("{CSRF_COOKIE}={token}; Path=/; SameSite=Lax");
if state.secure {
cookie.push_str("; Secure");
}
if let Ok(v) = HeaderValue::from_str(&cookie) {
response.headers_mut().append(SET_COOKIE, v);
}
}
return Ok(response);
}
let header_token = req
.headers()
.get(CSRF_HEADER)
.and_then(|h| h.to_str().ok())
.map(str::to_string);
if let Some(c) = cookie_token.as_ref() {
if let Some(h) = header_token.as_ref() {
if csrf_valid(&state, c, h, session_value.as_deref()) {
let token = c.clone();
return Ok(umbral::templates::with_current_csrf(Some(token), next.run(req)).await);
}
}
let content_type = req
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if content_type.starts_with("application/x-www-form-urlencoded") {
let cookie_owned = c.clone();
let (parts, body) = req.into_parts();
let bytes = match axum::body::to_bytes(body, MAX_FORM_BODY).await {
Ok(b) => b,
Err(_) => return Ok(forbidden()),
};
if let Some(s) = form_field_token(&bytes) {
if csrf_valid(&state, &cookie_owned, &s, session_value.as_deref()) {
let req = Request::from_parts(parts, Body::from(bytes));
return Ok(umbral::templates::with_current_csrf(
Some(cookie_owned),
next.run(req),
)
.await);
}
}
}
}
Ok(forbidden())
}
async fn strip_server_header(req: Request, next: Next) -> Result<Response, Infallible> {
let mut response = next.run(req).await;
response.headers_mut().remove(SERVER);
Ok(response)
}
fn forbidden() -> Response {
let body = Body::from("CSRF verification failed");
Response::builder()
.status(StatusCode::FORBIDDEN)
.body(body)
.expect("static response")
}
fn form_field_token(body: &[u8]) -> Option<String> {
let s = std::str::from_utf8(body).ok()?;
for part in s.split('&') {
let mut iter = part.splitn(2, '=');
let key = iter.next()?;
let val = iter.next().unwrap_or("");
if CSRF_FORM_FIELDS.contains(&key) {
return Some(val.replace('+', " "));
}
}
None
}
pub fn current_csrf_token(headers: &http::HeaderMap) -> Option<String> {
headers
.get(COOKIE)
.and_then(|h| h.to_str().ok())
.and_then(|h| cookie_value(h, CSRF_COOKIE).map(str::to_string))
}
pub fn tokens_match(a: &str, b: &str) -> bool {
use subtle::ConstantTimeEq;
a.as_bytes().ct_eq(b.as_bytes()).into()
}
fn check_secret_key(
settings: Option<&umbral::Settings>,
config: &SecurityConfig,
) -> Result<(), umbral::plugin::PluginError> {
if !config.csrf || !config.signed_csrf {
return Ok(());
}
let Some(s) = settings else {
return Ok(());
};
if s.secret_key.trim().is_empty() {
match s.environment {
Environment::Dev | Environment::Test => {
tracing::warn!(
"SecurityPlugin: SECRET_KEY is empty — CSRF tokens are signed with an \
empty HMAC key and are trivially forgeable. Set `secret_key` in \
umbral.toml or the UMBRAL_SECRET_KEY environment variable before \
deploying."
);
}
Environment::Prod => {
return Err(
"SecurityPlugin: SECRET_KEY must not be empty in production. \
An empty key makes CSRF tokens trivially forgeable. \
Set `secret_key` in umbral.toml or via UMBRAL_SECRET_KEY."
.into(),
);
}
}
}
Ok(())
}
#[doc(hidden)]
pub mod test_support {
use super::*;
pub fn wrap_with_csrf(
router: axum::Router,
signed: bool,
secret: Option<String>,
) -> axum::Router {
let state = CsrfState {
secure: false,
signed,
secret,
session_cookie: None,
exempt_paths: Vec::new(),
};
router.layer(middleware::from_fn_with_state(state, csrf_middleware))
}
pub fn validate_secret_key(
settings: &umbral::Settings,
config: &SecurityConfig,
) -> Result<(), umbral::plugin::PluginError> {
check_secret_key(Some(settings), config)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn signed_state(secret: &str, session_cookie: Option<&str>) -> CsrfState {
CsrfState {
secure: false,
signed: true,
secret: Some(secret.to_string()),
session_cookie: session_cookie.map(str::to_string),
exempt_paths: Vec::new(),
}
}
#[test]
fn signing_is_deterministic_and_key_dependent() {
assert_eq!(sign("k", "abc", None), sign("k", "abc", None));
assert_ne!(sign("k1", "abc", None), sign("k2", "abc", None));
assert_ne!(sign("k", "abc", None), sign("k", "abc", Some("sess")));
}
#[test]
fn from_config_does_not_capture_the_secret_at_build_time() {
let cfg = SecurityConfig {
csrf: true,
signed_csrf: true,
..Default::default()
};
let state = CsrfState::from_config(&cfg);
assert!(state.signed, "signed mode still requested");
assert!(
state.secret.is_none(),
"the secret must not be captured at build time — it's resolved per request"
);
}
#[test]
fn resolve_secret_precedence() {
let injected = signed_state("captured", None);
assert_eq!(injected.resolve_secret().as_deref(), Some("captured"));
let unsigned = CsrfState {
secure: false,
signed: false,
secret: Some("ignored".to_string()),
session_cookie: None,
exempt_paths: Vec::new(),
};
assert_eq!(
unsigned.resolve_secret(),
None,
"unsigned mode never resolves a secret"
);
}
#[test]
fn signed_token_round_trips_and_rejects_forgery() {
let st = signed_state("app-secret", None);
let token = mint_token(&st, None);
assert!(token.contains('.'));
assert!(csrf_valid(&st, &token, &token, None));
let forged = generate_token();
assert!(!csrf_valid(&st, &forged, &forged, None));
let other = signed_state("different-secret", None);
let other_token = mint_token(&other, None);
assert!(!csrf_valid(&st, &other_token, &other_token, None));
}
#[test]
fn session_binding_ties_token_to_session_value() {
let st = signed_state("app-secret", Some("umbral_session"));
let token = mint_token(&st, Some("sess-A"));
assert!(csrf_valid(&st, &token, &token, Some("sess-A")));
assert!(!csrf_valid(&st, &token, &token, Some("sess-B")));
}
#[test]
fn unsigned_mode_is_plain_double_submit() {
let st = CsrfState {
secure: false,
signed: false,
secret: None,
session_cookie: None,
exempt_paths: Vec::new(),
};
let tok = generate_token();
assert!(csrf_valid(&st, &tok, &tok, None));
assert!(!csrf_valid(&st, &tok, "different", None));
}
#[test]
fn exempt_path_matching_is_prefix_based() {
let st = CsrfState {
secure: false,
signed: false,
secret: None,
session_cookie: None,
exempt_paths: vec!["/api".to_string()],
};
assert!(st.is_exempt("/api"));
assert!(st.is_exempt("/api/customer/1"));
assert!(!st.is_exempt("/admin"));
assert!(!st.is_exempt("/contact"));
}
#[test]
fn csrf_exempt_boundary_stops_at_path_segment() {
let st = CsrfState {
secure: false,
signed: false,
secret: None,
session_cookie: None,
exempt_paths: vec!["/api".to_string()],
};
assert!(st.is_exempt("/api"), "/api exact must be exempt");
assert!(
st.is_exempt("/api/users"),
"/api/users sub-path must be exempt"
);
assert!(
st.is_exempt("/api/v2/resource"),
"/api/v2/resource must be exempt"
);
assert!(
!st.is_exempt("/api-internal"),
"/api-internal must NOT be exempt when /api is configured"
);
assert!(
!st.is_exempt("/apixyz"),
"/apixyz must NOT be exempt when /api is configured"
);
assert!(
!st.is_exempt("/api2"),
"/api2 must NOT be exempt when /api is configured"
);
}
#[test]
fn hsts_value_reflects_flags() {
let cfg = SecurityConfig {
hsts_max_age: 100,
hsts_include_subdomains: true,
hsts_preload: true,
..Default::default()
};
assert_eq!(cfg.hsts_value(), "max-age=100; includeSubDomains; preload");
let bare = SecurityConfig {
hsts_max_age: 100,
hsts_include_subdomains: false,
hsts_preload: false,
..Default::default()
};
assert_eq!(bare.hsts_value(), "max-age=100");
}
}