Skip to main content

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}