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