umbral_security/lib.rs
1//! umbral-security — CSRF protection and a configurable security-header bundle.
2//!
3//! Django's `CsrfViewMiddleware` plus the `SecurityMiddleware` header bundle,
4//! widened to the modern header set. Plug it into the app and every non-safe
5//! request must carry a matching CSRF token; every response gets the hardening
6//! headers you've enabled.
7//!
8//! ```ignore
9//! App::builder()
10//! .plugin(AuthPlugin::new())
11//! .plugin(SecurityPlugin::new()) // secure-but-dev-safe defaults
12//! .build()
13//! .await?;
14//! ```
15//!
16//! ## Configuration is a struct, not a builder chain
17//!
18//! Construct a [`SecurityConfig`] (every field has a secure, dev-safe default)
19//! and flip exactly what you need — no long `.with_x().with_y()` chain:
20//!
21//! ```ignore
22//! SecurityPlugin::with_config(SecurityConfig {
23//! hsts: true,
24//! content_security_policy: Some("default-src 'self'".into()),
25//! server_header: Some("umbral".into()),
26//! request_body_limit: Some(2 * 1024 * 1024),
27//! ..Default::default()
28//! })
29//! ```
30//!
31//! `SecurityPlugin::new()` keeps the defaults; `SecurityPlugin::with_hsts(true)`
32//! stays as a one-flag convenience.
33//!
34//! ## CSRF
35//!
36//! Signed double-submit cookie pattern, fully automatic (the Django
37//! `CsrfViewMiddleware` + `{% csrf_token %}` split — see
38//! `docs/decisions/2026-06-10-automatic-csrf.md`):
39//!
40//! 1. **The middleware is the only mint.** On GET / HEAD / OPTIONS it mints a
41//! token *before* the handler runs (first visit covered) and appends the
42//! `umbral_csrf_token` cookie to the response. The cookie is NOT HttpOnly:
43//! the page's JS reads it and copies it into a header on later writes.
44//! 2. **Templates get the token for free.** The token is scoped into
45//! `umbral::templates::CURRENT_CSRF` around every non-exempt request, so
46//! any rendered template can write `{{ csrf_input }}` (the full hidden
47//! input) or `{{ csrf_token }}` (raw value, for `X-CSRF-Token` headers /
48//! htmx `hx-headers`). View code never touches CSRF.
49//! 3. Every POST / PUT / PATCH / DELETE must include the cookie AND a matching
50//! `X-CSRF-Token` header (JS path) or `csrf_token` / `__csrf` form field
51//! (HTML-form path). A mismatch returns 403. On success the token stays in
52//! scope so a validation-error re-render still carries it into the form.
53//!
54//! The token is a 32-byte CSPRNG value, hex-encoded. The CSRF cookie gains
55//! `Secure` automatically under `Environment::Prod` (or force it with
56//! [`SecurityConfig::csrf_cookie_secure`]).
57//!
58//! ### Signed / session-bound CSRF ([`SecurityConfig::signed_csrf`])
59//!
60//! Naive double-submit trusts the cookie: an attacker who can plant a cookie on
61//! a sibling subdomain can forge a matching token. `signed_csrf` (**default
62//! on**) makes the token `<random>.<HMAC-SHA256(secret_key, random[.session])>`
63//! — a forged cookie can't carry a valid signature without the app
64//! `secret_key`. Set [`SecurityConfig::session_bind_cookie`] to also fold the
65//! session cookie's value into the signature so a token minted under one
66//! session can't be replayed under another.
67//!
68//! The flip to default-on is deploy-safe because the middleware **rotates**
69//! any cookie token that can't pass signed-mode validation on the next safe
70//! request (browsers holding pre-upgrade unsigned cookies converge instead of
71//! 403ing), and because no other mint exists: the admin prefers the ambient
72//! middleware token and only self-mints when this plugin isn't mounted. With
73//! no resolvable `secret_key` (tests, pre-`App::build()` renders) minting and
74//! validation degrade to plain double-submit instead of locking writes out.
75//! Opt back into plain double-submit with `signed_csrf: false`.
76//!
77//! ## Headers
78//!
79//! Enabled by default: `X-Content-Type-Options: nosniff`, `X-Frame-Options:
80//! DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-XSS-Protection:
81//! 0` (modern guidance disables the legacy auditor), `Cross-Origin-Opener-Policy:
82//! same-origin` (matches Django 4.0+), and a `Server: umbral` header. Opt-in
83//! (default off, each a field on [`SecurityConfig`]): `Strict-Transport-Security`,
84//! `Content-Security-Policy`, `Permissions-Policy`, `Cross-Origin-Resource-Policy`,
85//! `Cross-Origin-Embedder-Policy`. CSP and HSTS are off by default because a wrong
86//! value breaks apps (HSTS bricks `http://` dev; a strict CSP breaks the CDN-using
87//! admin).
88//!
89//! ## Server identity & tower-http knobs
90//!
91//! [`SecurityConfig::server_header`] sets the `Server` header (prefer a bare
92//! product name — a version is an information-disclosure tradeoff);
93//! [`SecurityConfig::hide_server_header`] strips whatever the stack added.
94//! [`SecurityConfig::request_body_limit`] caps the request body via tower-http's
95//! `RequestBodyLimitLayer` (DoS hardening); [`SecurityConfig::redact_sensitive_headers`]
96//! (default on) marks `authorization` / `cookie` / `set-cookie` sensitive so
97//! they're redacted in tracing output.
98//!
99//! ## Why this lives in Plugin::wrap_router
100//!
101//! Layering middleware needs a `tower::Layer` value; the Plugin trait's
102//! `wrap_router(Router) -> Router` lets each plugin layer its middleware with
103//! the full axum / tower API. The app builder calls it in topological order so
104//! security wraps everything declared before it.
105
106use std::convert::Infallible;
107
108use axum::body::Body;
109use axum::extract::{Request, State};
110use axum::middleware::{self, Next};
111use axum::response::Response;
112use http::header::{AUTHORIZATION, COOKIE, HeaderName, HeaderValue, SERVER, SET_COOKIE};
113use http::{Method, StatusCode};
114use tower_http::limit::RequestBodyLimitLayer;
115use tower_http::sensitive_headers::SetSensitiveHeadersLayer;
116use tower_http::set_header::SetResponseHeaderLayer;
117use umbral::prelude::*;
118
119const CSRF_COOKIE: &str = "umbral_csrf_token";
120const CSRF_HEADER: &str = "x-csrf-token";
121/// Form field name that carries the CSRF token for HTML `<form>` submissions.
122/// Two shapes are accepted — `csrf_token` and `__csrf` — so existing form code
123/// on either convention works without migration. The header path stays the
124/// canonical one for JS clients.
125const CSRF_FORM_FIELDS: &[&str] = &["csrf_token", "__csrf"];
126/// Hard cap on the buffered body size when we peek at form data to extract the
127/// CSRF field. 1 MiB is well above any realistic urlencoded form.
128const MAX_FORM_BODY: usize = 1024 * 1024;
129
130/// Declarative security configuration. Build from [`Default`] (secure,
131/// dev-safe) and override the fields you need — see the crate docs for the
132/// rationale behind each default.
133#[derive(Debug, Clone)]
134pub struct SecurityConfig {
135 // ---- CSRF ----
136 /// Run the CSRF middleware. Default `true`.
137 pub csrf: bool,
138 /// Force the `Secure` flag on the CSRF cookie. Default `false`; `Secure` is
139 /// added automatically under `Environment::Prod` regardless, so this only
140 /// matters for forcing it on in a non-prod HTTPS setup.
141 pub csrf_cookie_secure: bool,
142 /// Sign the CSRF token with the app `secret_key` (HMAC-SHA256). Default
143 /// `true` — the middleware is the only mint, so every token carries a
144 /// signature; stale unsigned cookies rotate automatically on the next
145 /// safe request. Set `false` for plain double-submit.
146 pub signed_csrf: bool,
147 /// When `signed_csrf` is on, also bind the token to this cookie's value
148 /// (typically the session cookie). Default `None`.
149 pub session_bind_cookie: Option<String>,
150 /// Request-path prefixes exempt from CSRF (Django's `@csrf_exempt`).
151 /// A token-authenticated REST API carries no session cookie, so a
152 /// bearer-auth `POST /api/...` would otherwise 403; exempt `"/api"` to
153 /// keep it working. Matched as a path prefix. Default empty.
154 pub csrf_exempt_paths: Vec<String>,
155
156 // ---- Response headers (None / false = header omitted) ----
157 /// `X-Content-Type-Options: nosniff`. Default `true`.
158 pub content_type_options: bool,
159 /// `X-Frame-Options`. Default `Some("DENY")`.
160 pub frame_options: Option<String>,
161 /// `Referrer-Policy`. Default `Some("strict-origin-when-cross-origin")`.
162 pub referrer_policy: Option<String>,
163 /// `X-XSS-Protection`. Default `Some("0")` — disables the buggy legacy
164 /// filter rather than enabling it (current OWASP guidance).
165 pub xss_protection: Option<String>,
166 /// Emit `Strict-Transport-Security`. Default `false` (dev-safe). Value is
167 /// built from the `hsts_*` fields.
168 pub hsts: bool,
169 /// HSTS `max-age` in seconds. Default one year.
170 pub hsts_max_age: u64,
171 /// Add `; includeSubDomains` to HSTS. Default `true`.
172 pub hsts_include_subdomains: bool,
173 /// Add `; preload` to HSTS. Default `false`.
174 pub hsts_preload: bool,
175 /// `Content-Security-Policy`. Default `None` — a wrong CSP breaks apps, so
176 /// it's opt-in.
177 pub content_security_policy: Option<String>,
178 /// `Permissions-Policy`. Default `None`.
179 pub permissions_policy: Option<String>,
180 /// `Cross-Origin-Opener-Policy`. Default `Some("same-origin")` (matches
181 /// Django 4.0+). Set `None` to omit — e.g. apps relying on cross-origin
182 /// popups (some OAuth flows).
183 pub cross_origin_opener_policy: Option<String>,
184 /// `Cross-Origin-Resource-Policy` (e.g. `"same-origin"`). Default `None`.
185 pub cross_origin_resource_policy: Option<String>,
186 /// `Cross-Origin-Embedder-Policy` (e.g. `"require-corp"`). Default `None`.
187 pub cross_origin_embedder_policy: Option<String>,
188
189 // ---- Server identity ----
190 /// Set the `Server` response header. Default `Some("umbral")` — a bare
191 /// product name (no version, so no info disclosure), the way Django's
192 /// daphne/gunicorn advertise one. Set `None` to omit. Prefer no version.
193 pub server_header: Option<String>,
194 /// Strip any `Server` header the stack set. Default `false`. Ignored when
195 /// `server_header` is `Some` (the set wins) — to strip, also set
196 /// `server_header: None`.
197 pub hide_server_header: bool,
198
199 // ---- axum / tower-http knobs ----
200 /// Cap request body size in bytes (tower-http `RequestBodyLimitLayer`).
201 /// Default `None` (axum's own default applies).
202 pub request_body_limit: Option<usize>,
203 /// Mark `authorization` / `cookie` / `set-cookie` sensitive so tracing
204 /// redacts them. Default `true`.
205 pub redact_sensitive_headers: bool,
206}
207
208impl Default for SecurityConfig {
209 fn default() -> Self {
210 Self {
211 csrf: true,
212 csrf_cookie_secure: false,
213 signed_csrf: true,
214 session_bind_cookie: None,
215 csrf_exempt_paths: Vec::new(),
216 content_type_options: true,
217 frame_options: Some("DENY".to_string()),
218 referrer_policy: Some("strict-origin-when-cross-origin".to_string()),
219 xss_protection: Some("0".to_string()),
220 hsts: false,
221 hsts_max_age: 31_536_000,
222 hsts_include_subdomains: true,
223 hsts_preload: false,
224 content_security_policy: None,
225 permissions_policy: None,
226 // On by default, matching Django's `SECURE_CROSS_ORIGIN_OPENER_POLICY`
227 // (default `same-origin` since Django 4.0). Isolates the browsing
228 // context group; only affects apps that rely on cross-origin popups.
229 cross_origin_opener_policy: Some("same-origin".to_string()),
230 cross_origin_resource_policy: None,
231 cross_origin_embedder_policy: None,
232 // Advertise the framework (no version — no info disclosure), the way
233 // Django's daphne/gunicorn emit a `Server` header. Set `None` to omit
234 // or pair `None` + `hide_server_header` to strip an upstream one.
235 server_header: Some("umbral".to_string()),
236 hide_server_header: false,
237 request_body_limit: None,
238 redact_sensitive_headers: true,
239 }
240 }
241}
242
243impl SecurityConfig {
244 fn hsts_value(&self) -> String {
245 let mut v = format!("max-age={}", self.hsts_max_age);
246 if self.hsts_include_subdomains {
247 v.push_str("; includeSubDomains");
248 }
249 if self.hsts_preload {
250 v.push_str("; preload");
251 }
252 v
253 }
254}
255
256/// CSRF + security-headers plugin. Configure via [`SecurityConfig`].
257#[derive(Debug, Clone, Default)]
258pub struct SecurityPlugin {
259 config: SecurityConfig,
260}
261
262impl SecurityPlugin {
263 /// Secure, dev-safe defaults (see [`SecurityConfig`]).
264 pub fn new() -> Self {
265 Self::default()
266 }
267
268 /// Construct from an explicit config — the preferred entry point.
269 pub fn with_config(config: SecurityConfig) -> Self {
270 Self { config }
271 }
272
273 /// Borrow the active config.
274 pub fn config(&self) -> &SecurityConfig {
275 &self.config
276 }
277
278 /// One-flag convenience for `SecurityConfig::hsts`. Equivalent to
279 /// `with_config(SecurityConfig { hsts, ..Default::default() })`.
280 pub fn with_hsts(mut self, hsts: bool) -> Self {
281 self.config.hsts = hsts;
282 self
283 }
284}
285
286impl Plugin for SecurityPlugin {
287 fn name(&self) -> &'static str {
288 "security"
289 }
290
291 fn wrap_router(&self, router: Router) -> Router {
292 let cfg = &self.config;
293 let mut router = router;
294
295 // CSRF middleware (innermost of our additions).
296 if cfg.csrf {
297 let state = CsrfState::from_config(cfg);
298 router = router.layer(middleware::from_fn_with_state(state, csrf_middleware));
299 }
300
301 // Response-header setters. Order among them is irrelevant.
302 if cfg.content_type_options {
303 router = set_header(
304 router,
305 "x-content-type-options",
306 Some("nosniff".to_string()),
307 );
308 }
309 router = set_header(router, "x-frame-options", cfg.frame_options.clone());
310 router = set_header(router, "referrer-policy", cfg.referrer_policy.clone());
311 router = set_header(router, "x-xss-protection", cfg.xss_protection.clone());
312 if cfg.hsts {
313 router = set_header(router, "strict-transport-security", Some(cfg.hsts_value()));
314 }
315 router = set_header(
316 router,
317 "content-security-policy",
318 cfg.content_security_policy.clone(),
319 );
320 router = set_header(router, "permissions-policy", cfg.permissions_policy.clone());
321 router = set_header(
322 router,
323 "cross-origin-opener-policy",
324 cfg.cross_origin_opener_policy.clone(),
325 );
326 router = set_header(
327 router,
328 "cross-origin-resource-policy",
329 cfg.cross_origin_resource_policy.clone(),
330 );
331 router = set_header(
332 router,
333 "cross-origin-embedder-policy",
334 cfg.cross_origin_embedder_policy.clone(),
335 );
336
337 // Server identity: an explicit value overrides; otherwise optionally strip.
338 if let Some(v) = cfg.server_header.as_deref() {
339 if let Ok(hv) = HeaderValue::from_str(v) {
340 router = router.layer(SetResponseHeaderLayer::overriding(SERVER, hv));
341 }
342 } else if cfg.hide_server_header {
343 router = router.layer(middleware::from_fn(strip_server_header));
344 }
345
346 // tower-http knobs (outermost so they wrap everything above).
347 if cfg.redact_sensitive_headers {
348 router = router.layer(SetSensitiveHeadersLayer::new([
349 AUTHORIZATION,
350 COOKIE,
351 SET_COOKIE,
352 ]));
353 }
354 if let Some(limit) = cfg.request_body_limit {
355 router = router.layer(RequestBodyLimitLayer::new(limit));
356 }
357
358 router
359 }
360
361 fn on_ready(&self, _ctx: &umbral::plugin::AppContext) -> Result<(), umbral::plugin::PluginError> {
362 let settings = umbral::settings::get_opt();
363
364 // Boot nudge: HSTS and CSP are opt-in (safe defaults for dev), but
365 // a Prod deployment shipping neither is a real exposure — SSL
366 // stripping with no HSTS, XSS with no CSP backstop. Warn loudly so
367 // the gap is visible at startup rather than discovered in an audit.
368 let is_prod = settings
369 .map(|s| matches!(s.environment, Environment::Prod))
370 .unwrap_or(false);
371 if is_prod {
372 if !self.config.hsts {
373 tracing::warn!(
374 "SecurityPlugin: HSTS is disabled in Environment::Prod — responses ship \
375 no Strict-Transport-Security header, leaving clients open to SSL \
376 stripping. Enable with `.with_hsts(true)`."
377 );
378 }
379 if self.config.content_security_policy.is_none() {
380 tracing::warn!(
381 "SecurityPlugin: no Content-Security-Policy set in Environment::Prod — \
382 XSS has no CSP backstop. Set `content_security_policy` in SecurityConfig."
383 );
384 }
385 }
386
387 check_secret_key(settings, &self.config)?;
388
389 Ok(())
390 }
391}
392
393/// Add a `SetResponseHeaderLayer::if_not_present` for `name` when `value` is a
394/// valid header value; otherwise return the router untouched.
395fn set_header(router: Router, name: &'static str, value: Option<String>) -> Router {
396 match value.as_deref().and_then(|v| HeaderValue::from_str(v).ok()) {
397 Some(hv) => router.layer(SetResponseHeaderLayer::if_not_present(
398 HeaderName::from_static(name),
399 hv,
400 )),
401 None => router,
402 }
403}
404
405/// Per-request CSRF state captured at `wrap_router` time. The `secret` is read
406/// once from settings (absent in tests / before `App::build()` — signing then
407/// degrades to plain double-submit rather than panicking).
408#[derive(Clone)]
409struct CsrfState {
410 secure: bool,
411 signed: bool,
412 secret: Option<String>,
413 session_cookie: Option<String>,
414 exempt_paths: Vec<String>,
415}
416
417impl CsrfState {
418 fn from_config(cfg: &SecurityConfig) -> Self {
419 let settings = umbral::settings::get_opt();
420 let is_prod = settings
421 .map(|s| matches!(s.environment, Environment::Prod))
422 .unwrap_or(false);
423 let secret = if cfg.signed_csrf {
424 settings
425 .map(|s| s.secret_key.trim().to_string())
426 .filter(|s| !s.is_empty())
427 } else {
428 None
429 };
430 Self {
431 secure: cfg.csrf_cookie_secure || is_prod,
432 signed: cfg.signed_csrf,
433 secret,
434 session_cookie: cfg.session_bind_cookie.clone(),
435 exempt_paths: cfg.csrf_exempt_paths.clone(),
436 }
437 }
438
439 /// True when `path` falls under a configured CSRF-exempt prefix.
440 fn is_exempt(&self, path: &str) -> bool {
441 self.exempt_paths.iter().any(|prefix| {
442 let prefix = prefix.trim_end_matches('/');
443 path == prefix || path.starts_with(&format!("{prefix}/"))
444 })
445 }
446
447 /// The session value to fold into the signature, or `None` when session
448 /// binding isn't configured.
449 fn session_bind<'a>(&self, session_value: Option<&'a str>) -> Option<&'a str> {
450 if self.session_cookie.is_some() {
451 session_value
452 } else {
453 None
454 }
455 }
456
457 /// True when `token` may keep serving as this browser's CSRF cookie.
458 /// Plain mode accepts any non-empty token. Signed mode (with a
459 /// resolvable secret) requires a structurally valid `<raw>.<sig>` —
460 /// anything else (typically a cookie minted before `signed_csrf`
461 /// was enabled) triggers a rotation re-mint by the caller.
462 fn token_acceptable(&self, token: &str, session_value: Option<&str>) -> bool {
463 if token.is_empty() {
464 return false;
465 }
466 if !self.signed {
467 return true;
468 }
469 let Some(secret) = self.secret.as_deref() else {
470 return true; // signing requested but no secret resolved: degrade
471 };
472 let Some((raw, sig)) = token.rsplit_once('.') else {
473 return false;
474 };
475 tokens_match(sig, &sign(secret, raw, self.session_bind(session_value)))
476 }
477}
478
479/// Generate a fresh 32-byte token, hex-encoded. Public so tests and downstream
480/// code that mints tokens directly (e.g. server-rendered forms) share the same
481/// shape. Raw (unsigned) — the signed wrapper is applied by the middleware.
482pub fn generate_token() -> String {
483 let mut bytes = [0u8; 32];
484 getrandom::getrandom(&mut bytes).expect("getrandom failed");
485 hex::encode(bytes)
486}
487
488/// HMAC-SHA256 over `raw` (and the session value, when bound), keyed by the app
489/// secret, hex-encoded.
490///
491/// `secret` must never be empty in production. Boot (`on_ready`) already
492/// rejects an empty `SECRET_KEY` before this path is reachable in a real
493/// deployment; the assert below catches the bug in debug/test builds if
494/// that guard is somehow bypassed.
495fn sign(secret: &str, raw: &str, session: Option<&str>) -> String {
496 debug_assert!(
497 !secret.is_empty(),
498 "sign() called with an empty secret — CSRF tokens are trivially forgeable; \
499 on_ready should have rejected boot already"
500 );
501 use hmac::{Hmac, Mac};
502 use sha2::Sha256;
503 let mut mac =
504 <Hmac<Sha256>>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
505 mac.update(raw.as_bytes());
506 if let Some(s) = session {
507 mac.update(b".");
508 mac.update(s.as_bytes());
509 }
510 hex::encode(mac.finalize().into_bytes())
511}
512
513/// Mint a token for the response cookie — signed when configured and a secret
514/// is available, raw otherwise.
515fn mint_token(state: &CsrfState, session_value: Option<&str>) -> String {
516 let raw = generate_token();
517 if state.signed {
518 if let Some(secret) = state.secret.as_deref() {
519 let sig = sign(secret, &raw, state.session_bind(session_value));
520 return format!("{raw}.{sig}");
521 }
522 }
523 raw
524}
525
526/// Validate a submitted token against the cookie token. Always requires the
527/// double-submit equality; additionally verifies the HMAC signature when
528/// `signed` is on and a secret is available.
529fn csrf_valid(
530 state: &CsrfState,
531 cookie_token: &str,
532 submitted: &str,
533 session_value: Option<&str>,
534) -> bool {
535 if !tokens_match(cookie_token, submitted) {
536 return false;
537 }
538 if !state.signed {
539 return true;
540 }
541 let Some(secret) = state.secret.as_deref() else {
542 // Signing requested but no secret resolved (e.g. before App::build()):
543 // fall back to plain double-submit rather than locking writes out.
544 return true;
545 };
546 let Some((raw, sig)) = cookie_token.rsplit_once('.') else {
547 // Signed mode requires a signature; an unsigned token can't be trusted.
548 return false;
549 };
550 let expected = sign(secret, raw, state.session_bind(session_value));
551 tokens_match(sig, &expected)
552}
553
554/// Pull the value of a named cookie out of a `Cookie` header. v0 shape: linear
555/// scan, no quoting.
556fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
557 for part in header.split(';') {
558 let part = part.trim();
559 if let Some((k, v)) = part.split_once('=') {
560 if k == name {
561 return Some(v);
562 }
563 }
564 }
565 None
566}
567
568fn is_safe_method(method: &Method) -> bool {
569 matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
570}
571
572async fn csrf_middleware(
573 State(state): State<CsrfState>,
574 req: Request,
575 next: Next,
576) -> Result<Response, Infallible> {
577 let method = req.method().clone();
578
579 // Exempt paths (e.g. a token-authenticated `/api`) bypass CSRF entirely —
580 // they carry no session cookie, so the double-submit check doesn't apply.
581 if state.is_exempt(req.uri().path()) {
582 return Ok(next.run(req).await);
583 }
584
585 let cookie_header = req
586 .headers()
587 .get(COOKIE)
588 .and_then(|h| h.to_str().ok())
589 .map(str::to_string);
590 let cookie_token = cookie_header
591 .as_deref()
592 .and_then(|h| cookie_value(h, CSRF_COOKIE).map(str::to_string));
593 let session_value = state.session_cookie.as_deref().and_then(|name| {
594 cookie_header
595 .as_deref()
596 .and_then(|h| cookie_value(h, name).map(str::to_string))
597 });
598
599 if is_safe_method(&method) {
600 // The middleware is the only mint (docs/decisions/
601 // 2026-06-10-automatic-csrf.md): mint BEFORE the handler runs so
602 // first-visit renders already have a token in scope, and rotate a
603 // cookie token that can't pass signed-mode validation so flipping
604 // `signed_csrf` on doesn't 403 browsers holding old cookies.
605 let (token, minted) = match cookie_token {
606 Some(t) if state.token_acceptable(&t, session_value.as_deref()) => (t, false),
607 _ => (mint_token(&state, session_value.as_deref()), true),
608 };
609 let mut response =
610 umbral::templates::with_current_csrf(Some(token.clone()), next.run(req)).await;
611 if minted {
612 let mut cookie = format!("{CSRF_COOKIE}={token}; Path=/; SameSite=Lax");
613 if state.secure {
614 cookie.push_str("; Secure");
615 }
616 if let Ok(v) = HeaderValue::from_str(&cookie) {
617 // `append`, not `insert` — `insert` would wipe any cookie
618 // the handler set on this response (e.g. the session).
619 response.headers_mut().append(SET_COOKIE, v);
620 }
621 }
622 return Ok(response);
623 }
624
625 // Write methods: cookie and (header OR form field) must validate.
626 // On success the token is scoped around the handler so a
627 // validation-error re-render still carries it into the form.
628 let header_token = req
629 .headers()
630 .get(CSRF_HEADER)
631 .and_then(|h| h.to_str().ok())
632 .map(str::to_string);
633
634 if let Some(c) = cookie_token.as_ref() {
635 if let Some(h) = header_token.as_ref() {
636 if csrf_valid(&state, c, h, session_value.as_deref()) {
637 let token = c.clone();
638 return Ok(umbral::templates::with_current_csrf(Some(token), next.run(req)).await);
639 }
640 }
641 // Form-field path: peek the urlencoded body, then rebuild the request.
642 let content_type = req
643 .headers()
644 .get(http::header::CONTENT_TYPE)
645 .and_then(|v| v.to_str().ok())
646 .unwrap_or("")
647 .to_string();
648 if content_type.starts_with("application/x-www-form-urlencoded") {
649 let cookie_owned = c.clone();
650 let (parts, body) = req.into_parts();
651 let bytes = match axum::body::to_bytes(body, MAX_FORM_BODY).await {
652 Ok(b) => b,
653 Err(_) => return Ok(forbidden()),
654 };
655 if let Some(s) = form_field_token(&bytes) {
656 if csrf_valid(&state, &cookie_owned, &s, session_value.as_deref()) {
657 let req = Request::from_parts(parts, Body::from(bytes));
658 return Ok(umbral::templates::with_current_csrf(
659 Some(cookie_owned),
660 next.run(req),
661 )
662 .await);
663 }
664 }
665 }
666 }
667
668 Ok(forbidden())
669}
670
671/// Strip the `Server` response header (used when `hide_server_header` is set
672/// and no explicit value was given).
673async fn strip_server_header(req: Request, next: Next) -> Result<Response, Infallible> {
674 let mut response = next.run(req).await;
675 response.headers_mut().remove(SERVER);
676 Ok(response)
677}
678
679fn forbidden() -> Response {
680 let body = Body::from("CSRF verification failed");
681 Response::builder()
682 .status(StatusCode::FORBIDDEN)
683 .body(body)
684 .expect("static response")
685}
686
687/// Scan a urlencoded form body for any of the accepted CSRF field names.
688fn form_field_token(body: &[u8]) -> Option<String> {
689 let s = std::str::from_utf8(body).ok()?;
690 for part in s.split('&') {
691 let mut iter = part.splitn(2, '=');
692 let key = iter.next()?;
693 let val = iter.next().unwrap_or("");
694 if CSRF_FORM_FIELDS.contains(&key) {
695 // Tokens are hex (signed tokens add a `.` + hex sig — still no
696 // urlencoded-special chars), so `+`→space is the only decode
697 // needed for the common case.
698 return Some(val.replace('+', " "));
699 }
700 }
701 None
702}
703
704/// Read the current CSRF token from the request's cookie header. Public so
705/// handlers that render HTML forms can embed it as a hidden `csrf_token` input.
706pub fn current_csrf_token(headers: &http::HeaderMap) -> Option<String> {
707 headers
708 .get(COOKIE)
709 .and_then(|h| h.to_str().ok())
710 .and_then(|h| cookie_value(h, CSRF_COOKIE).map(str::to_string))
711}
712
713/// Constant-time string equality. Short-circuit `==` on `String` is a timing
714/// side-channel; `ct_eq` closes it. Per OWASP's "Use Constant-Time String
715/// Comparison" rule for security tokens. Public so other token consumers
716/// (e.g. the admin's SecurityPlugin-less login fallback) compare the same way.
717pub fn tokens_match(a: &str, b: &str) -> bool {
718 use subtle::ConstantTimeEq;
719 a.as_bytes().ct_eq(b.as_bytes()).into()
720}
721
722/// Validate that `secret_key` is non-empty when signed CSRF is enabled.
723///
724/// Called from [`SecurityPlugin::on_ready`]. Extracted as a free function so
725/// integration tests can exercise it with an explicit [`umbral::Settings`]
726/// without needing a live `App::build()` to populate the ambient
727/// `SETTINGS` OnceLock (which is `pub(crate)` and unreachable from plugin
728/// tests).
729///
730/// Behaviour when `settings` is `None` (i.e. `get_opt()` returned nothing,
731/// common in tests that bypass `App::build()`): treated as non-prod, no
732/// error.
733fn check_secret_key(
734 settings: Option<&umbral::Settings>,
735 config: &SecurityConfig,
736) -> Result<(), umbral::plugin::PluginError> {
737 // Only relevant when signed CSRF is active; plain double-submit doesn't
738 // use the secret at all.
739 if !config.csrf || !config.signed_csrf {
740 return Ok(());
741 }
742
743 let Some(s) = settings else {
744 // No settings available — running outside App::build() (e.g. tests).
745 // Can't determine environment or secret; skip.
746 return Ok(());
747 };
748
749 if s.secret_key.trim().is_empty() {
750 match s.environment {
751 Environment::Dev | Environment::Test => {
752 tracing::warn!(
753 "SecurityPlugin: SECRET_KEY is empty — CSRF tokens are signed with an \
754 empty HMAC key and are trivially forgeable. Set `secret_key` in \
755 umbral.toml or the UMBRAL_SECRET_KEY environment variable before \
756 deploying."
757 );
758 }
759 Environment::Prod => {
760 return Err(
761 "SecurityPlugin: SECRET_KEY must not be empty in production. \
762 An empty key makes CSRF tokens trivially forgeable. \
763 Set `secret_key` in umbral.toml or via UMBRAL_SECRET_KEY."
764 .into(),
765 );
766 }
767 }
768 }
769
770 Ok(())
771}
772
773/// Test-only constructors. `#[doc(hidden)]` — NOT a stable API; integration
774/// tests need a CSRF-wrapped router without `App::build()`-resolved settings.
775#[doc(hidden)]
776pub mod test_support {
777 use super::*;
778
779 /// Wrap `router` with the CSRF middleware using an explicit state,
780 /// bypassing settings resolution.
781 pub fn wrap_with_csrf(
782 router: axum::Router,
783 signed: bool,
784 secret: Option<String>,
785 ) -> axum::Router {
786 let state = CsrfState {
787 secure: false,
788 signed,
789 secret,
790 session_cookie: None,
791 exempt_paths: Vec::new(),
792 };
793 router.layer(middleware::from_fn_with_state(state, csrf_middleware))
794 }
795
796 /// Exercise [`check_secret_key`] directly with an explicit [`umbral::Settings`],
797 /// bypassing the ambient `SETTINGS` OnceLock (which is `pub(crate)` and
798 /// unreachable from plugin tests).
799 pub fn validate_secret_key(
800 settings: &umbral::Settings,
801 config: &SecurityConfig,
802 ) -> Result<(), umbral::plugin::PluginError> {
803 check_secret_key(Some(settings), config)
804 }
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 fn signed_state(secret: &str, session_cookie: Option<&str>) -> CsrfState {
812 CsrfState {
813 secure: false,
814 signed: true,
815 secret: Some(secret.to_string()),
816 session_cookie: session_cookie.map(str::to_string),
817 exempt_paths: Vec::new(),
818 }
819 }
820
821 #[test]
822 fn signing_is_deterministic_and_key_dependent() {
823 assert_eq!(sign("k", "abc", None), sign("k", "abc", None));
824 assert_ne!(sign("k1", "abc", None), sign("k2", "abc", None));
825 assert_ne!(sign("k", "abc", None), sign("k", "abc", Some("sess")));
826 }
827
828 #[test]
829 fn signed_token_round_trips_and_rejects_forgery() {
830 let st = signed_state("app-secret", None);
831 let token = mint_token(&st, None);
832 // Minted token is `<raw>.<sig>` and validates as a double-submit pair.
833 assert!(token.contains('.'));
834 assert!(csrf_valid(&st, &token, &token, None));
835 // An unsigned token (attacker-planted, no valid signature) is rejected
836 // even though it double-submits against itself.
837 let forged = generate_token();
838 assert!(!csrf_valid(&st, &forged, &forged, None));
839 // A token signed under a different key is rejected.
840 let other = signed_state("different-secret", None);
841 let other_token = mint_token(&other, None);
842 assert!(!csrf_valid(&st, &other_token, &other_token, None));
843 }
844
845 #[test]
846 fn session_binding_ties_token_to_session_value() {
847 let st = signed_state("app-secret", Some("umbral_session"));
848 let token = mint_token(&st, Some("sess-A"));
849 assert!(csrf_valid(&st, &token, &token, Some("sess-A")));
850 // Same token under a different session value no longer validates.
851 assert!(!csrf_valid(&st, &token, &token, Some("sess-B")));
852 }
853
854 #[test]
855 fn unsigned_mode_is_plain_double_submit() {
856 let st = CsrfState {
857 secure: false,
858 signed: false,
859 secret: None,
860 session_cookie: None,
861 exempt_paths: Vec::new(),
862 };
863 let tok = generate_token();
864 assert!(csrf_valid(&st, &tok, &tok, None));
865 assert!(!csrf_valid(&st, &tok, "different", None));
866 }
867
868 #[test]
869 fn exempt_path_matching_is_prefix_based() {
870 let st = CsrfState {
871 secure: false,
872 signed: false,
873 secret: None,
874 session_cookie: None,
875 exempt_paths: vec!["/api".to_string()],
876 };
877 assert!(st.is_exempt("/api"));
878 assert!(st.is_exempt("/api/customer/1"));
879 assert!(!st.is_exempt("/admin"));
880 assert!(!st.is_exempt("/contact"));
881 }
882
883 /// `/api` exempt must NOT bleed into `/api-internal`, `/apixyz`, etc.
884 /// The boundary check requires the prefix to be followed by `/` (sub-path)
885 /// or be an exact match — a bare `starts_with("/api")` would incorrectly
886 /// exempt those sibling routes.
887 #[test]
888 fn csrf_exempt_boundary_stops_at_path_segment() {
889 let st = CsrfState {
890 secure: false,
891 signed: false,
892 secret: None,
893 session_cookie: None,
894 exempt_paths: vec!["/api".to_string()],
895 };
896 // Exact match and sub-paths ARE exempt.
897 assert!(st.is_exempt("/api"), "/api exact must be exempt");
898 assert!(
899 st.is_exempt("/api/users"),
900 "/api/users sub-path must be exempt"
901 );
902 assert!(
903 st.is_exempt("/api/v2/resource"),
904 "/api/v2/resource must be exempt"
905 );
906 // Paths that merely START WITH the string but aren't segment-separated
907 // must NOT be exempt — that would be a CSRF-bypass on unintended routes.
908 assert!(
909 !st.is_exempt("/api-internal"),
910 "/api-internal must NOT be exempt when /api is configured"
911 );
912 assert!(
913 !st.is_exempt("/apixyz"),
914 "/apixyz must NOT be exempt when /api is configured"
915 );
916 assert!(
917 !st.is_exempt("/api2"),
918 "/api2 must NOT be exempt when /api is configured"
919 );
920 }
921
922 #[test]
923 fn hsts_value_reflects_flags() {
924 let cfg = SecurityConfig {
925 hsts_max_age: 100,
926 hsts_include_subdomains: true,
927 hsts_preload: true,
928 ..Default::default()
929 };
930 assert_eq!(cfg.hsts_value(), "max-age=100; includeSubDomains; preload");
931 let bare = SecurityConfig {
932 hsts_max_age: 100,
933 hsts_include_subdomains: false,
934 hsts_preload: false,
935 ..Default::default()
936 };
937 assert_eq!(bare.hsts_value(), "max-age=100");
938 }
939}