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