Skip to main content

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    fn hsts_value(&self) -> String {
243        let mut v = format!("max-age={}", self.hsts_max_age);
244        if self.hsts_include_subdomains {
245            v.push_str("; includeSubDomains");
246        }
247        if self.hsts_preload {
248            v.push_str("; preload");
249        }
250        v
251    }
252}
253
254/// CSRF + security-headers plugin. Configure via [`SecurityConfig`].
255#[derive(Debug, Clone, Default)]
256pub struct SecurityPlugin {
257    config: SecurityConfig,
258}
259
260impl SecurityPlugin {
261    /// Secure, dev-safe defaults (see [`SecurityConfig`]).
262    pub fn new() -> Self {
263        Self::default()
264    }
265
266    /// Construct from an explicit config — the preferred entry point.
267    pub fn with_config(config: SecurityConfig) -> Self {
268        Self { config }
269    }
270
271    /// Borrow the active config.
272    pub fn config(&self) -> &SecurityConfig {
273        &self.config
274    }
275
276    /// One-flag convenience for `SecurityConfig::hsts`. Equivalent to
277    /// `with_config(SecurityConfig { hsts, ..Default::default() })`.
278    pub fn with_hsts(mut self, hsts: bool) -> Self {
279        self.config.hsts = hsts;
280        self
281    }
282}
283
284impl Plugin for SecurityPlugin {
285    fn name(&self) -> &'static str {
286        "security"
287    }
288
289    fn wrap_router(&self, router: Router) -> Router {
290        let cfg = &self.config;
291        let mut router = router;
292
293        // CSRF middleware (innermost of our additions).
294        if cfg.csrf {
295            let state = CsrfState::from_config(cfg);
296            router = router.layer(middleware::from_fn_with_state(state, csrf_middleware));
297        }
298
299        // Response-header setters. Order among them is irrelevant.
300        if cfg.content_type_options {
301            router = set_header(
302                router,
303                "x-content-type-options",
304                Some("nosniff".to_string()),
305            );
306        }
307        router = set_header(router, "x-frame-options", cfg.frame_options.clone());
308        router = set_header(router, "referrer-policy", cfg.referrer_policy.clone());
309        router = set_header(router, "x-xss-protection", cfg.xss_protection.clone());
310        if cfg.hsts {
311            router = set_header(router, "strict-transport-security", Some(cfg.hsts_value()));
312        }
313        router = set_header(
314            router,
315            "content-security-policy",
316            cfg.content_security_policy.clone(),
317        );
318        router = set_header(router, "permissions-policy", cfg.permissions_policy.clone());
319        router = set_header(
320            router,
321            "cross-origin-opener-policy",
322            cfg.cross_origin_opener_policy.clone(),
323        );
324        router = set_header(
325            router,
326            "cross-origin-resource-policy",
327            cfg.cross_origin_resource_policy.clone(),
328        );
329        router = set_header(
330            router,
331            "cross-origin-embedder-policy",
332            cfg.cross_origin_embedder_policy.clone(),
333        );
334
335        // Server identity: an explicit value overrides; otherwise optionally strip.
336        if let Some(v) = cfg.server_header.as_deref() {
337            if let Ok(hv) = HeaderValue::from_str(v) {
338                router = router.layer(SetResponseHeaderLayer::overriding(SERVER, hv));
339            }
340        } else if cfg.hide_server_header {
341            router = router.layer(middleware::from_fn(strip_server_header));
342        }
343
344        // tower-http knobs (outermost so they wrap everything above).
345        if cfg.redact_sensitive_headers {
346            router = router.layer(SetSensitiveHeadersLayer::new([
347                AUTHORIZATION,
348                COOKIE,
349                SET_COOKIE,
350            ]));
351        }
352        if let Some(limit) = cfg.request_body_limit {
353            router = router.layer(RequestBodyLimitLayer::new(limit));
354        }
355
356        router
357    }
358
359    fn on_ready(
360        &self,
361        _ctx: &umbral::plugin::AppContext,
362    ) -> Result<(), umbral::plugin::PluginError> {
363        let settings = umbral::settings::get_opt();
364
365        // Boot nudge: HSTS and CSP are opt-in (safe defaults for dev), but
366        // a Prod deployment shipping neither is a real exposure — SSL
367        // stripping with no HSTS, XSS with no CSP backstop. Warn loudly so
368        // the gap is visible at startup rather than discovered in an audit.
369        let is_prod = settings
370            .map(|s| matches!(s.environment, Environment::Prod))
371            .unwrap_or(false);
372        if is_prod {
373            if !self.config.hsts {
374                tracing::warn!(
375                    "SecurityPlugin: HSTS is disabled in Environment::Prod — responses ship \
376                     no Strict-Transport-Security header, leaving clients open to SSL \
377                     stripping. Enable with `.with_hsts(true)`."
378                );
379            }
380            if self.config.content_security_policy.is_none() {
381                tracing::warn!(
382                    "SecurityPlugin: no Content-Security-Policy set in Environment::Prod — \
383                     XSS has no CSP backstop. Set `content_security_policy` in SecurityConfig."
384                );
385            }
386        }
387
388        check_secret_key(settings, &self.config)?;
389
390        Ok(())
391    }
392}
393
394/// Add a `SetResponseHeaderLayer::if_not_present` for `name` when `value` is a
395/// valid header value; otherwise return the router untouched.
396fn set_header(router: Router, name: &'static str, value: Option<String>) -> Router {
397    match value.as_deref().and_then(|v| HeaderValue::from_str(v).ok()) {
398        Some(hv) => router.layer(SetResponseHeaderLayer::if_not_present(
399            HeaderName::from_static(name),
400            hv,
401        )),
402        None => router,
403    }
404}
405
406/// Per-request CSRF state captured at `wrap_router` time. The `secret` is read
407/// once from settings (absent in tests / before `App::build()` — signing then
408/// degrades to plain double-submit rather than panicking).
409#[derive(Clone)]
410struct CsrfState {
411    secure: bool,
412    signed: bool,
413    secret: Option<String>,
414    session_cookie: Option<String>,
415    exempt_paths: Vec<String>,
416}
417
418impl CsrfState {
419    fn from_config(cfg: &SecurityConfig) -> Self {
420        let settings = umbral::settings::get_opt();
421        let is_prod = settings
422            .map(|s| matches!(s.environment, Environment::Prod))
423            .unwrap_or(false);
424        let secret = if cfg.signed_csrf {
425            settings
426                .map(|s| s.secret_key.trim().to_string())
427                .filter(|s| !s.is_empty())
428        } else {
429            None
430        };
431        Self {
432            secure: cfg.csrf_cookie_secure || is_prod,
433            signed: cfg.signed_csrf,
434            secret,
435            session_cookie: cfg.session_bind_cookie.clone(),
436            exempt_paths: cfg.csrf_exempt_paths.clone(),
437        }
438    }
439
440    /// True when `path` falls under a configured CSRF-exempt prefix.
441    fn is_exempt(&self, path: &str) -> bool {
442        self.exempt_paths.iter().any(|prefix| {
443            let prefix = prefix.trim_end_matches('/');
444            path == prefix || path.starts_with(&format!("{prefix}/"))
445        })
446    }
447
448    /// The session value to fold into the signature, or `None` when session
449    /// binding isn't configured.
450    fn session_bind<'a>(&self, session_value: Option<&'a str>) -> Option<&'a str> {
451        if self.session_cookie.is_some() {
452            session_value
453        } else {
454            None
455        }
456    }
457
458    /// True when `token` may keep serving as this browser's CSRF cookie.
459    /// Plain mode accepts any non-empty token. Signed mode (with a
460    /// resolvable secret) requires a structurally valid `<raw>.<sig>` —
461    /// anything else (typically a cookie minted before `signed_csrf`
462    /// was enabled) triggers a rotation re-mint by the caller.
463    fn token_acceptable(&self, token: &str, session_value: Option<&str>) -> bool {
464        if token.is_empty() {
465            return false;
466        }
467        if !self.signed {
468            return true;
469        }
470        let Some(secret) = self.secret.as_deref() else {
471            return true; // signing requested but no secret resolved: degrade
472        };
473        let Some((raw, sig)) = token.rsplit_once('.') else {
474            return false;
475        };
476        tokens_match(sig, &sign(secret, raw, self.session_bind(session_value)))
477    }
478}
479
480/// Generate a fresh 32-byte token, hex-encoded. Public so tests and downstream
481/// code that mints tokens directly (e.g. server-rendered forms) share the same
482/// shape. Raw (unsigned) — the signed wrapper is applied by the middleware.
483pub fn generate_token() -> String {
484    let mut bytes = [0u8; 32];
485    getrandom::getrandom(&mut bytes).expect("getrandom failed");
486    hex::encode(bytes)
487}
488
489/// HMAC-SHA256 over `raw` (and the session value, when bound), keyed by the app
490/// secret, hex-encoded.
491///
492/// `secret` must never be empty in production. Boot (`on_ready`) already
493/// rejects an empty `SECRET_KEY` before this path is reachable in a real
494/// deployment; the assert below catches the bug in debug/test builds if
495/// that guard is somehow bypassed.
496fn sign(secret: &str, raw: &str, session: Option<&str>) -> String {
497    debug_assert!(
498        !secret.is_empty(),
499        "sign() called with an empty secret — CSRF tokens are trivially forgeable; \
500         on_ready should have rejected boot already"
501    );
502    use hmac::{Hmac, Mac};
503    use sha2::Sha256;
504    let mut mac =
505        <Hmac<Sha256>>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
506    mac.update(raw.as_bytes());
507    if let Some(s) = session {
508        mac.update(b".");
509        mac.update(s.as_bytes());
510    }
511    hex::encode(mac.finalize().into_bytes())
512}
513
514/// Mint a token for the response cookie — signed when configured and a secret
515/// is available, raw otherwise.
516fn mint_token(state: &CsrfState, session_value: Option<&str>) -> String {
517    let raw = generate_token();
518    if state.signed {
519        if let Some(secret) = state.secret.as_deref() {
520            let sig = sign(secret, &raw, state.session_bind(session_value));
521            return format!("{raw}.{sig}");
522        }
523    }
524    raw
525}
526
527/// Validate a submitted token against the cookie token. Always requires the
528/// double-submit equality; additionally verifies the HMAC signature when
529/// `signed` is on and a secret is available.
530fn csrf_valid(
531    state: &CsrfState,
532    cookie_token: &str,
533    submitted: &str,
534    session_value: Option<&str>,
535) -> bool {
536    if !tokens_match(cookie_token, submitted) {
537        return false;
538    }
539    if !state.signed {
540        return true;
541    }
542    let Some(secret) = state.secret.as_deref() else {
543        // Signing requested but no secret resolved (e.g. before App::build()):
544        // fall back to plain double-submit rather than locking writes out.
545        return true;
546    };
547    let Some((raw, sig)) = cookie_token.rsplit_once('.') else {
548        // Signed mode requires a signature; an unsigned token can't be trusted.
549        return false;
550    };
551    let expected = sign(secret, raw, state.session_bind(session_value));
552    tokens_match(sig, &expected)
553}
554
555/// Pull the value of a named cookie out of a `Cookie` header. v0 shape: linear
556/// scan, no quoting.
557fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
558    for part in header.split(';') {
559        let part = part.trim();
560        if let Some((k, v)) = part.split_once('=') {
561            if k == name {
562                return Some(v);
563            }
564        }
565    }
566    None
567}
568
569fn is_safe_method(method: &Method) -> bool {
570    matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
571}
572
573async fn csrf_middleware(
574    State(state): State<CsrfState>,
575    req: Request,
576    next: Next,
577) -> Result<Response, Infallible> {
578    let method = req.method().clone();
579
580    // Exempt paths (e.g. a token-authenticated `/api`) bypass CSRF entirely —
581    // they carry no session cookie, so the double-submit check doesn't apply.
582    if state.is_exempt(req.uri().path()) {
583        return Ok(next.run(req).await);
584    }
585
586    let cookie_header = req
587        .headers()
588        .get(COOKIE)
589        .and_then(|h| h.to_str().ok())
590        .map(str::to_string);
591    let cookie_token = cookie_header
592        .as_deref()
593        .and_then(|h| cookie_value(h, CSRF_COOKIE).map(str::to_string));
594    let session_value = state.session_cookie.as_deref().and_then(|name| {
595        cookie_header
596            .as_deref()
597            .and_then(|h| cookie_value(h, name).map(str::to_string))
598    });
599
600    if is_safe_method(&method) {
601        // The middleware is the only mint (docs/decisions/
602        // 2026-06-10-automatic-csrf.md): mint BEFORE the handler runs so
603        // first-visit renders already have a token in scope, and rotate a
604        // cookie token that can't pass signed-mode validation so flipping
605        // `signed_csrf` on doesn't 403 browsers holding old cookies.
606        let (token, minted) = match cookie_token {
607            Some(t) if state.token_acceptable(&t, session_value.as_deref()) => (t, false),
608            _ => (mint_token(&state, session_value.as_deref()), true),
609        };
610        let mut response =
611            umbral::templates::with_current_csrf(Some(token.clone()), next.run(req)).await;
612        if minted {
613            let mut cookie = format!("{CSRF_COOKIE}={token}; Path=/; SameSite=Lax");
614            if state.secure {
615                cookie.push_str("; Secure");
616            }
617            if let Ok(v) = HeaderValue::from_str(&cookie) {
618                // `append`, not `insert` — `insert` would wipe any cookie
619                // the handler set on this response (e.g. the session).
620                response.headers_mut().append(SET_COOKIE, v);
621            }
622        }
623        return Ok(response);
624    }
625
626    // Write methods: cookie and (header OR form field) must validate.
627    // On success the token is scoped around the handler so a
628    // validation-error re-render still carries it into the form.
629    let header_token = req
630        .headers()
631        .get(CSRF_HEADER)
632        .and_then(|h| h.to_str().ok())
633        .map(str::to_string);
634
635    if let Some(c) = cookie_token.as_ref() {
636        if let Some(h) = header_token.as_ref() {
637            if csrf_valid(&state, c, h, session_value.as_deref()) {
638                let token = c.clone();
639                return Ok(umbral::templates::with_current_csrf(Some(token), next.run(req)).await);
640            }
641        }
642        // Form-field path: peek the urlencoded body, then rebuild the request.
643        let content_type = req
644            .headers()
645            .get(http::header::CONTENT_TYPE)
646            .and_then(|v| v.to_str().ok())
647            .unwrap_or("")
648            .to_string();
649        if content_type.starts_with("application/x-www-form-urlencoded") {
650            let cookie_owned = c.clone();
651            let (parts, body) = req.into_parts();
652            let bytes = match axum::body::to_bytes(body, MAX_FORM_BODY).await {
653                Ok(b) => b,
654                Err(_) => return Ok(forbidden()),
655            };
656            if let Some(s) = form_field_token(&bytes) {
657                if csrf_valid(&state, &cookie_owned, &s, session_value.as_deref()) {
658                    let req = Request::from_parts(parts, Body::from(bytes));
659                    return Ok(umbral::templates::with_current_csrf(
660                        Some(cookie_owned),
661                        next.run(req),
662                    )
663                    .await);
664                }
665            }
666        }
667    }
668
669    Ok(forbidden())
670}
671
672/// Strip the `Server` response header (used when `hide_server_header` is set
673/// and no explicit value was given).
674async fn strip_server_header(req: Request, next: Next) -> Result<Response, Infallible> {
675    let mut response = next.run(req).await;
676    response.headers_mut().remove(SERVER);
677    Ok(response)
678}
679
680fn forbidden() -> Response {
681    let body = Body::from("CSRF verification failed");
682    Response::builder()
683        .status(StatusCode::FORBIDDEN)
684        .body(body)
685        .expect("static response")
686}
687
688/// Scan a urlencoded form body for any of the accepted CSRF field names.
689fn form_field_token(body: &[u8]) -> Option<String> {
690    let s = std::str::from_utf8(body).ok()?;
691    for part in s.split('&') {
692        let mut iter = part.splitn(2, '=');
693        let key = iter.next()?;
694        let val = iter.next().unwrap_or("");
695        if CSRF_FORM_FIELDS.contains(&key) {
696            // Tokens are hex (signed tokens add a `.` + hex sig — still no
697            // urlencoded-special chars), so `+`→space is the only decode
698            // needed for the common case.
699            return Some(val.replace('+', " "));
700        }
701    }
702    None
703}
704
705/// Read the current CSRF token from the request's cookie header. Public so
706/// handlers that render HTML forms can embed it as a hidden `csrf_token` input.
707pub fn current_csrf_token(headers: &http::HeaderMap) -> Option<String> {
708    headers
709        .get(COOKIE)
710        .and_then(|h| h.to_str().ok())
711        .and_then(|h| cookie_value(h, CSRF_COOKIE).map(str::to_string))
712}
713
714/// Constant-time string equality. Short-circuit `==` on `String` is a timing
715/// side-channel; `ct_eq` closes it. Per OWASP's "Use Constant-Time String
716/// Comparison" rule for security tokens. Public so other token consumers
717/// (e.g. the admin's SecurityPlugin-less login fallback) compare the same way.
718pub fn tokens_match(a: &str, b: &str) -> bool {
719    use subtle::ConstantTimeEq;
720    a.as_bytes().ct_eq(b.as_bytes()).into()
721}
722
723/// Validate that `secret_key` is non-empty when signed CSRF is enabled.
724///
725/// Called from [`SecurityPlugin::on_ready`]. Extracted as a free function so
726/// integration tests can exercise it with an explicit [`umbral::Settings`]
727/// without needing a live `App::build()` to populate the ambient
728/// `SETTINGS` OnceLock (which is `pub(crate)` and unreachable from plugin
729/// tests).
730///
731/// Behaviour when `settings` is `None` (i.e. `get_opt()` returned nothing,
732/// common in tests that bypass `App::build()`): treated as non-prod, no
733/// error.
734fn check_secret_key(
735    settings: Option<&umbral::Settings>,
736    config: &SecurityConfig,
737) -> Result<(), umbral::plugin::PluginError> {
738    // Only relevant when signed CSRF is active; plain double-submit doesn't
739    // use the secret at all.
740    if !config.csrf || !config.signed_csrf {
741        return Ok(());
742    }
743
744    let Some(s) = settings else {
745        // No settings available — running outside App::build() (e.g. tests).
746        // Can't determine environment or secret; skip.
747        return Ok(());
748    };
749
750    if s.secret_key.trim().is_empty() {
751        match s.environment {
752            Environment::Dev | Environment::Test => {
753                tracing::warn!(
754                    "SecurityPlugin: SECRET_KEY is empty — CSRF tokens are signed with an \
755                     empty HMAC key and are trivially forgeable. Set `secret_key` in \
756                     umbral.toml or the UMBRAL_SECRET_KEY environment variable before \
757                     deploying."
758                );
759            }
760            Environment::Prod => {
761                return Err(
762                    "SecurityPlugin: SECRET_KEY must not be empty in production. \
763                     An empty key makes CSRF tokens trivially forgeable. \
764                     Set `secret_key` in umbral.toml or via UMBRAL_SECRET_KEY."
765                        .into(),
766                );
767            }
768        }
769    }
770
771    Ok(())
772}
773
774/// Test-only constructors. `#[doc(hidden)]` — NOT a stable API; integration
775/// tests need a CSRF-wrapped router without `App::build()`-resolved settings.
776#[doc(hidden)]
777pub mod test_support {
778    use super::*;
779
780    /// Wrap `router` with the CSRF middleware using an explicit state,
781    /// bypassing settings resolution.
782    pub fn wrap_with_csrf(
783        router: axum::Router,
784        signed: bool,
785        secret: Option<String>,
786    ) -> axum::Router {
787        let state = CsrfState {
788            secure: false,
789            signed,
790            secret,
791            session_cookie: None,
792            exempt_paths: Vec::new(),
793        };
794        router.layer(middleware::from_fn_with_state(state, csrf_middleware))
795    }
796
797    /// Exercise [`check_secret_key`] directly with an explicit [`umbral::Settings`],
798    /// bypassing the ambient `SETTINGS` OnceLock (which is `pub(crate)` and
799    /// unreachable from plugin tests).
800    pub fn validate_secret_key(
801        settings: &umbral::Settings,
802        config: &SecurityConfig,
803    ) -> Result<(), umbral::plugin::PluginError> {
804        check_secret_key(Some(settings), config)
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    fn signed_state(secret: &str, session_cookie: Option<&str>) -> CsrfState {
813        CsrfState {
814            secure: false,
815            signed: true,
816            secret: Some(secret.to_string()),
817            session_cookie: session_cookie.map(str::to_string),
818            exempt_paths: Vec::new(),
819        }
820    }
821
822    #[test]
823    fn signing_is_deterministic_and_key_dependent() {
824        assert_eq!(sign("k", "abc", None), sign("k", "abc", None));
825        assert_ne!(sign("k1", "abc", None), sign("k2", "abc", None));
826        assert_ne!(sign("k", "abc", None), sign("k", "abc", Some("sess")));
827    }
828
829    #[test]
830    fn signed_token_round_trips_and_rejects_forgery() {
831        let st = signed_state("app-secret", None);
832        let token = mint_token(&st, None);
833        // Minted token is `<raw>.<sig>` and validates as a double-submit pair.
834        assert!(token.contains('.'));
835        assert!(csrf_valid(&st, &token, &token, None));
836        // An unsigned token (attacker-planted, no valid signature) is rejected
837        // even though it double-submits against itself.
838        let forged = generate_token();
839        assert!(!csrf_valid(&st, &forged, &forged, None));
840        // A token signed under a different key is rejected.
841        let other = signed_state("different-secret", None);
842        let other_token = mint_token(&other, None);
843        assert!(!csrf_valid(&st, &other_token, &other_token, None));
844    }
845
846    #[test]
847    fn session_binding_ties_token_to_session_value() {
848        let st = signed_state("app-secret", Some("umbral_session"));
849        let token = mint_token(&st, Some("sess-A"));
850        assert!(csrf_valid(&st, &token, &token, Some("sess-A")));
851        // Same token under a different session value no longer validates.
852        assert!(!csrf_valid(&st, &token, &token, Some("sess-B")));
853    }
854
855    #[test]
856    fn unsigned_mode_is_plain_double_submit() {
857        let st = CsrfState {
858            secure: false,
859            signed: false,
860            secret: None,
861            session_cookie: None,
862            exempt_paths: Vec::new(),
863        };
864        let tok = generate_token();
865        assert!(csrf_valid(&st, &tok, &tok, None));
866        assert!(!csrf_valid(&st, &tok, "different", None));
867    }
868
869    #[test]
870    fn exempt_path_matching_is_prefix_based() {
871        let st = CsrfState {
872            secure: false,
873            signed: false,
874            secret: None,
875            session_cookie: None,
876            exempt_paths: vec!["/api".to_string()],
877        };
878        assert!(st.is_exempt("/api"));
879        assert!(st.is_exempt("/api/customer/1"));
880        assert!(!st.is_exempt("/admin"));
881        assert!(!st.is_exempt("/contact"));
882    }
883
884    /// `/api` exempt must NOT bleed into `/api-internal`, `/apixyz`, etc.
885    /// The boundary check requires the prefix to be followed by `/` (sub-path)
886    /// or be an exact match — a bare `starts_with("/api")` would incorrectly
887    /// exempt those sibling routes.
888    #[test]
889    fn csrf_exempt_boundary_stops_at_path_segment() {
890        let st = CsrfState {
891            secure: false,
892            signed: false,
893            secret: None,
894            session_cookie: None,
895            exempt_paths: vec!["/api".to_string()],
896        };
897        // Exact match and sub-paths ARE exempt.
898        assert!(st.is_exempt("/api"), "/api exact must be exempt");
899        assert!(
900            st.is_exempt("/api/users"),
901            "/api/users sub-path must be exempt"
902        );
903        assert!(
904            st.is_exempt("/api/v2/resource"),
905            "/api/v2/resource must be exempt"
906        );
907        // Paths that merely START WITH the string but aren't segment-separated
908        // must NOT be exempt — that would be a CSRF-bypass on unintended routes.
909        assert!(
910            !st.is_exempt("/api-internal"),
911            "/api-internal must NOT be exempt when /api is configured"
912        );
913        assert!(
914            !st.is_exempt("/apixyz"),
915            "/apixyz must NOT be exempt when /api is configured"
916        );
917        assert!(
918            !st.is_exempt("/api2"),
919            "/api2 must NOT be exempt when /api is configured"
920        );
921    }
922
923    #[test]
924    fn hsts_value_reflects_flags() {
925        let cfg = SecurityConfig {
926            hsts_max_age: 100,
927            hsts_include_subdomains: true,
928            hsts_preload: true,
929            ..Default::default()
930        };
931        assert_eq!(cfg.hsts_value(), "max-age=100; includeSubDomains; preload");
932        let bare = SecurityConfig {
933            hsts_max_age: 100,
934            hsts_include_subdomains: false,
935            hsts_preload: false,
936            ..Default::default()
937        };
938        assert_eq!(bare.hsts_value(), "max-age=100");
939    }
940}