Skip to main content

autumn_web/security/
config.rs

1//! Security configuration for Autumn applications.
2//!
3//! Controls security headers and CSRF protection. All settings have
4//! sensible defaults and are profile-aware:
5//!
6//! - **`dev`**: Relaxed -- CSRF disabled, HSTS off, permissive headers.
7//! - **`prod`**: Strict -- CSRF enabled, HSTS on, all protective headers active.
8//!
9//! Session and authentication configuration live in their own modules
10//! ([`crate::session::SessionConfig`], [`crate::auth::AuthConfig`]).
11//!
12//! # `autumn.toml` example
13//!
14//! ```toml
15//! [security.headers]
16//! x_frame_options = "DENY"
17//! content_security_policy = "default-src 'self'"
18//!
19//! # Enable per-request CSP nonces — removes 'unsafe-inline' from the default
20//! # style-src and makes the nonce available via the CspNonce extractor.
21//! [security.headers.csp_nonce]
22//! enabled = true
23//!
24//! [security.csrf]
25//! enabled = true
26//!
27//! [security.rate_limit]
28//! enabled = true
29//! requests_per_second = 10.0
30//! burst = 20
31//! ```
32//!
33//! # Environment variable reference
34//!
35//! | Variable | Config field | Type |
36//! |----------|-------------|------|
37//! | `AUTUMN_SECURITY__HEADERS__X_FRAME_OPTIONS` | `security.headers.x_frame_options` | `String` |
38//! | `AUTUMN_SECURITY__HEADERS__HSTS_MAX_AGE_SECS` | `security.headers.hsts_max_age_secs` | `u64` |
39//! | `AUTUMN_SECURITY__HEADERS__CONTENT_SECURITY_POLICY` | `security.headers.content_security_policy` | `String` |
40//! | `AUTUMN_SECURITY__HEADERS__CSP_NONCE__ENABLED` | `security.headers.csp_nonce.enabled` | `bool` |
41//! | `AUTUMN_SECURITY__CSRF__ENABLED` | `security.csrf.enabled` | `bool` |
42//! | `AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES` | `security.csrf.token_scan_bytes` | `usize` |
43//! | `AUTUMN_SECURITY__RATE_LIMIT__ENABLED` | `security.rate_limit.enabled` | `bool` |
44//! | `AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND` | `security.rate_limit.requests_per_second` | `f64` |
45//! | `AUTUMN_SECURITY__RATE_LIMIT__BURST` | `security.rate_limit.burst` | `u32` |
46//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS` | `security.rate_limit.trust_forwarded_headers` | `bool` |
47//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES` | `security.rate_limit.trusted_proxies` | comma-separated `String` |
48//! | `AUTUMN_SECURITY__RATE_LIMIT__BACKEND` | `security.rate_limit.backend` | `memory` / `redis` |
49//! | `AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE` | `security.rate_limit.on_backend_failure` | `fail_open` / `fail_closed` |
50//! | `AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL` | `security.rate_limit.redis.url` | `String` |
51//! | `AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX` | `security.rate_limit.redis.key_prefix` | `String` |
52//! | `AUTUMN_SECURITY__TRUSTED_PROXIES__RANGES` | `security.trusted_proxies.ranges` | comma-separated `String` |
53//! | `AUTUMN_SECURITY__TRUSTED_PROXIES__TRUST_FORWARDED_HEADERS` | `security.trusted_proxies.trust_forwarded_headers` | `bool` |
54//! | `AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS` | `security.trusted_proxies.trusted_hops` | `u32` |
55//! | `AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES` | `security.upload.max_request_size_bytes` | `usize` |
56//! | `AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES` | `security.upload.max_file_size_bytes` | `usize` |
57//! | `AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES` | `security.upload.allowed_mime_types` | comma-separated `String` |
58//! | `AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH` | `security.upload.reject_on_content_type_mismatch` | `bool` |
59//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND` | `security.webhooks.replay.backend` | `memory` / `redis` |
60//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL` | `security.webhooks.replay.redis.url` | `String` |
61//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX` | `security.webhooks.replay.redis.key_prefix` | `String` |
62//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION` | `security.webhooks.replay.allow_memory_in_production` | `bool` |
63//! | per-endpoint `secret_env` | `security.webhooks.endpoints[*].secret` | environment variable name |
64//!
65//! Setting any header value to an empty string disables it (the header is
66//! not emitted). This is the escape hatch for opting out of a default.
67
68use std::collections::HashMap;
69use std::sync::Arc;
70
71use serde::Deserialize;
72
73// ── Signing secret contract ────────────────────────────────────────────────
74
75/// Minimum byte length for a valid production signing secret (32 bytes / 256 bits).
76///
77/// A hex-encoded 32-byte value is 64 characters. Anything shorter is rejected
78/// at production startup.
79pub const MIN_SECRET_LEN: usize = 32;
80
81/// Known demo / template / placeholder values that must never reach production.
82const DEMO_VALUES: &[&str] = &[
83    "changeme",
84    "change_me",
85    "change-me",
86    "secret",
87    "supersecret",
88    "super-secret",
89    "super_secret",
90    "your-secret-here",
91    "your_secret_here",
92    "insert-secret-here",
93    "replace-this",
94    "replace_me",
95    "todo",
96    "fixme",
97    "example",
98    "placeholder",
99    "dev_only",
100    "dev-only",
101    "test_secret",
102    "test-secret",
103    "test",
104    "password",
105];
106
107/// Signing-secret configuration for HMAC-signed framework surfaces.
108///
109/// The signing secret is the shared key used to sign sessions, CSRF tokens,
110/// flash/signed-cookie state, and local-storage signed URLs.
111///
112/// # Development and test
113///
114/// Leave `secret` unset. An ephemeral per-process key is generated automatically.
115/// This means sessions and signed URLs do **not** survive process restarts and
116/// replicas cannot share state — acceptable in dev, unacceptable in production.
117///
118/// # Production
119///
120/// Set `secret` via the `AUTUMN_SECURITY__SIGNING_SECRET` environment variable
121/// (or `[security.signing_secret] secret` in `autumn.toml`). The secret must be:
122/// - At least `MIN_SECRET_LEN` bytes long.
123/// - Not a known template/demo value.
124/// - Stable across restarts and identical on every replica.
125///
126/// Generate a secret: `openssl rand -hex 32`
127///
128/// # Rotation
129///
130/// When rotating, move the current secret to `previous_secrets` and set the
131/// new value in `secret`. New signatures use `secret`; tokens signed with any
132/// entry in `previous_secrets` continue to validate during the grace window.
133/// Remove expired entries from `previous_secrets` after the maximum relevant
134/// cookie/token lifetime has elapsed.
135///
136/// # `autumn.toml` example
137///
138/// ```toml
139/// [security.signing_secret]
140/// # secret set via AUTUMN_SECURITY__SIGNING_SECRET env var (never commit this)
141///
142/// # rotation grace window — leave populated until all existing tokens expire:
143/// previous_secrets = ["oldsecretvalue..."]
144/// ```
145#[derive(Debug, Clone, Default, Deserialize)]
146pub struct SigningSecretConfig {
147    /// The current signing secret. In production, must come from an environment
148    /// variable or external secrets manager — never a committed literal.
149    pub secret: Option<String>,
150
151    /// Previous signing secrets accepted during a rotation grace window.
152    ///
153    /// New signatures always use `secret`. Tokens signed with an entry here
154    /// remain valid until removed. Remove entries after the maximum relevant
155    /// cookie/token lifetime has elapsed (e.g. `session.max_age_secs`).
156    #[serde(default)]
157    pub previous_secrets: Vec<String>,
158}
159
160/// Error returned when a signing secret fails production validation.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum SigningSecretError {
163    /// No secret is configured but the production profile requires one.
164    MissingInProduction,
165    /// The secret is too short to meet the minimum entropy requirement.
166    TooShort {
167        /// Actual byte length of the supplied secret.
168        actual: usize,
169        /// Minimum required byte length (`MIN_SECRET_LEN`).
170        required: usize,
171    },
172    /// The secret matches a known insecure demo or template value.
173    KnownWeakValue(String),
174}
175
176impl std::fmt::Display for SigningSecretError {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        match self {
179            Self::MissingInProduction => write!(
180                f,
181                "signing secret is required in production; set \
182                 AUTUMN_SECURITY__SIGNING_SECRET (generate with `openssl rand -hex 32`)"
183            ),
184            Self::TooShort { actual, required } => write!(
185                f,
186                "signing secret is too short ({actual} bytes, minimum {required}); \
187                 generate one with `openssl rand -hex 32`"
188            ),
189            Self::KnownWeakValue(v) => write!(
190                f,
191                "signing secret looks like a template/demo value ({v:?}); \
192                 generate one with `openssl rand -hex 32`"
193            ),
194        }
195    }
196}
197
198/// Validate a signing secret for production use.
199///
200/// In development and test the check is skipped — any value (including `None`)
201/// is accepted so zero-config local development continues to work.
202///
203/// In production:
204/// - `None` → [`SigningSecretError::MissingInProduction`]
205/// - Shorter than `MIN_SECRET_LEN` bytes → [`SigningSecretError::TooShort`]
206/// - Matches a known demo/template string → [`SigningSecretError::KnownWeakValue`]
207///
208/// # Errors
209///
210/// Returns [`SigningSecretError`] when production validation fails.
211pub fn validate_signing_secret(
212    secret: Option<&str>,
213    is_production: bool,
214) -> Result<(), SigningSecretError> {
215    if !is_production {
216        return Ok(());
217    }
218    let secret = secret.ok_or(SigningSecretError::MissingInProduction)?;
219    // Demo-value check first: "changeme" is more informative than "too short".
220    let lower = secret.to_ascii_lowercase();
221    for &demo in DEMO_VALUES {
222        if lower == demo {
223            return Err(SigningSecretError::KnownWeakValue(secret.to_owned()));
224        }
225    }
226    let byte_len = secret.len();
227    if byte_len < MIN_SECRET_LEN {
228        return Err(SigningSecretError::TooShort {
229            actual: byte_len,
230            required: MIN_SECRET_LEN,
231        });
232    }
233    Ok(())
234}
235
236// ── Resolved signing key material ─────────────────────────────────────────
237
238/// HMAC-SHA256 of `message` under `key`, returned as lowercase hex.
239///
240/// # Panics
241///
242/// This should not panic because HMAC accepts keys of any length. A panic would
243/// indicate a broken crypto crate invariant.
244#[must_use]
245pub fn hmac_sha256_hex(key: &[u8], message: &[u8]) -> String {
246    use hmac::{Hmac, Mac};
247    use sha2::Sha256;
248    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
249    mac.update(message);
250    let bytes = mac.finalize().into_bytes();
251    bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
252        use std::fmt::Write as _;
253        let _ = write!(acc, "{b:02x}");
254        acc
255    })
256}
257
258/// Constant-time string comparison for HMAC verification.
259fn ct_eq_str(a: &str, b: &str) -> bool {
260    use subtle::ConstantTimeEq;
261    a.as_bytes().ct_eq(b.as_bytes()).into()
262}
263
264/// Generate a random 32-byte ephemeral key from two UUID v4 values.
265fn generate_ephemeral_key() -> Vec<u8> {
266    let a = uuid::Uuid::new_v4();
267    let b = uuid::Uuid::new_v4();
268    let mut bytes = vec![0u8; 32];
269    bytes[..16].copy_from_slice(a.as_bytes());
270    bytes[16..].copy_from_slice(b.as_bytes());
271    bytes
272}
273
274/// Resolved signing keys for a running Autumn instance.
275///
276/// Created once at startup from [`SigningSecretConfig`] via [`resolve_signing_keys`]
277/// and shared via `Arc` across session, CSRF, and local storage signing.
278///
279/// - `current` signs new tokens.
280/// - `previous` are accepted during a rotation grace window.
281#[derive(Clone, Debug)]
282pub struct ResolvedSigningKeys {
283    /// Key used to sign new tokens.
284    pub current: Arc<[u8]>,
285    /// Former keys accepted during a rotation grace window. New signatures always
286    /// use `current`; tokens carrying a `previous` HMAC continue to verify until
287    /// removed (see docs/guide/signing-secrets.md).
288    pub previous: Vec<Arc<[u8]>>,
289}
290
291impl ResolvedSigningKeys {
292    /// Build from raw byte vectors.
293    pub fn new(current: Vec<u8>, previous: Vec<Vec<u8>>) -> Self {
294        Self {
295            current: current.into(),
296            previous: previous.into_iter().map(|v: Vec<u8>| v.into()).collect(),
297        }
298    }
299
300    /// HMAC-SHA256 of `message` under the current key, hex-encoded.
301    pub fn sign(&self, message: &[u8]) -> String {
302        hmac_sha256_hex(&self.current, message)
303    }
304
305    /// Returns `true` when `hex_sig` is a valid HMAC-SHA256 of `message` under
306    /// any key (current first, then previous). All comparisons are constant-time.
307    pub fn verify(&self, message: &[u8], hex_sig: &str) -> bool {
308        if ct_eq_str(&hmac_sha256_hex(&self.current, message), hex_sig) {
309            return true;
310        }
311        for prev in &self.previous {
312            if ct_eq_str(&hmac_sha256_hex(prev, message), hex_sig) {
313                return true;
314            }
315        }
316        false
317    }
318}
319
320/// Resolve signing keys from a [`SigningSecretConfig`].
321///
322/// - When `secret` is set, its bytes become the current key.
323/// - When `secret` is absent (dev/test), an ephemeral random key is generated.
324///   This means signed tokens do not survive process restarts.
325/// - `previous_secrets` are always included for rotation grace-window verification.
326///
327/// Production boot validation (requiring `secret` to be non-empty, long enough,
328/// and not a demo value) is a separate step via [`validate_signing_secret`].
329pub fn resolve_signing_keys(config: &SigningSecretConfig) -> ResolvedSigningKeys {
330    let current = config
331        .secret
332        .as_deref()
333        .map_or_else(generate_ephemeral_key, |s| s.as_bytes().to_vec());
334    let previous = config
335        .previous_secrets
336        .iter()
337        .map(|s| s.as_bytes().to_vec())
338        .collect();
339    ResolvedSigningKeys::new(current, previous)
340}
341
342/// Top-level security configuration section.
343///
344/// Groups security headers and CSRF protection under `[security]`
345/// in `autumn.toml`.
346///
347/// # Examples
348///
349/// ```rust
350/// use autumn_web::security::SecurityConfig;
351///
352/// let config = SecurityConfig::default();
353/// assert_eq!(config.headers.x_frame_options, "DENY");
354/// assert!(config.headers.x_content_type_options);
355/// assert!(!config.csrf.enabled);
356/// assert!(!config.rate_limit.enabled);
357/// ```
358#[derive(Debug, Clone, Default, Deserialize)]
359pub struct SecurityConfig {
360    /// HTTP security headers applied to all responses.
361    #[serde(default)]
362    pub headers: HeadersConfig,
363
364    /// CSRF (Cross-Site Request Forgery) protection.
365    #[serde(default)]
366    pub csrf: CsrfConfig,
367
368    /// One-time submit tokens — at-most-once form submissions.
369    #[serde(default)]
370    pub submit_token: SubmitTokenConfig,
371
372    /// Rate limiting (per-client-IP token bucket).
373    #[serde(default)]
374    pub rate_limit: RateLimitConfig,
375
376    /// Multipart upload safeguards and validation policy.
377    #[serde(default)]
378    pub upload: UploadConfig,
379
380    /// Signed webhook intake endpoints.
381    #[serde(default)]
382    pub webhooks: crate::webhook::WebhookConfig,
383
384    /// Paths that must bypass CAPTCHA bot-protection independently of CSRF
385    /// exemptions.  Framework-managed inbound-mail and webhook receiver paths
386    /// are added here automatically; user-configured CSRF-exempt paths are
387    /// deliberately NOT copied here so that a form route that skips CSRF for
388    /// non-cookie auth still requires a CAPTCHA token.
389    #[serde(default)]
390    pub captcha_exempt_paths: Vec<String>,
391
392    /// HTTP status returned when a [`Policy`](crate::authorization::Policy)
393    /// denies a record-level action. Defaults to `"404"` to mirror the
394    /// Rails / Phoenix posture of hiding existence from unauthorized
395    /// clients.
396    #[serde(default)]
397    pub forbidden_response: crate::authorization::ForbiddenResponse,
398
399    /// Allow `#[repository(api = "...")]` to mount auto-generated
400    /// CRUD endpoints in `prod` builds without a paired `policy =`
401    /// argument.
402    ///
403    /// Default: `false`. The framework refuses to start when an
404    /// `api =` repository has no `policy =` because the auto-
405    /// generated endpoints would be reachable by any authenticated
406    /// user. Flip this to `true` only when the lack of authz is
407    /// genuinely intended (e.g. a fully-public read-only API).
408    #[serde(default)]
409    pub allow_unauthorized_repository_api: bool,
410
411    /// Signing-secret configuration for HMAC-signed framework surfaces.
412    ///
413    /// Covers sessions, CSRF tokens, flash/signed-cookie state, and
414    /// local-storage signed URLs. In dev the framework generates an
415    /// ephemeral per-process key; production MUST set a stable, private
416    /// secret via `AUTUMN_SECURITY__SIGNING_SECRET`.
417    #[serde(default)]
418    pub signing_secret: SigningSecretConfig,
419
420    /// Trusted Host header allow-list.
421    #[serde(default)]
422    pub trusted_hosts: TrustedHostsConfig,
423
424    /// Top-level trusted-proxy policy for `X-Forwarded-*` headers.
425    ///
426    /// When configured, every forwarding-aware middleware (rate limiter, CSRF
427    /// origin check, method-override, HSTS detection, tracing fields) honours
428    /// this policy.  The old per-subsystem `security.rate_limit.trusted_proxies`
429    /// and `security.rate_limit.trust_forwarded_headers` fields continue to work
430    /// for one minor release but are deprecated; configure this block instead.
431    #[serde(default)]
432    pub trusted_proxies: TrustedProxiesConfig,
433}
434
435impl SecurityConfig {
436    /// Check for conflicting configuration between the new top-level
437    /// `[security.trusted_proxies]` and the deprecated rate-limit-scoped fields.
438    ///
439    /// Returns `Some(message)` when both are set with values that differ; the
440    /// caller (e.g. `autumn doctor --strict`) should treat this as a failure.
441    #[must_use]
442    pub fn trusted_proxies_conflict(&self) -> Option<String> {
443        let new_set =
444            self.trusted_proxies.trust_forwarded_headers || !self.trusted_proxies.ranges.is_empty();
445        let old_set =
446            self.rate_limit.trust_forwarded_headers || !self.rate_limit.trusted_proxies.is_empty();
447
448        if new_set && old_set {
449            // Check for value-level conflicts.
450            let new_ranges: std::collections::HashSet<&str> = self
451                .trusted_proxies
452                .ranges
453                .iter()
454                .map(String::as_str)
455                .collect();
456            let old_ranges: std::collections::HashSet<&str> = self
457                .rate_limit
458                .trusted_proxies
459                .iter()
460                .map(String::as_str)
461                .collect();
462
463            // The legacy rate-limit fields have no hop-count equivalent, so any
464            // trusted_hops value in the new block is always a conflict.
465            let hops_conflict = self.trusted_proxies.trusted_hops.is_some();
466
467            if new_ranges != old_ranges
468                || self.trusted_proxies.trust_forwarded_headers
469                    != self.rate_limit.trust_forwarded_headers
470                || hops_conflict
471            {
472                return Some(
473                    "[security.trusted_proxies] and \
474                     [security.rate_limit] trusted_proxies/trust_forwarded_headers \
475                     are both set with conflicting values. Remove the deprecated \
476                     rate_limit fields and keep only [security.trusted_proxies]."
477                        .to_owned(),
478                );
479            }
480        }
481
482        None
483    }
484}
485
486#[derive(Debug, Clone, Default, Deserialize)]
487pub struct TrustedHostsConfig {
488    #[serde(default)]
489    pub hosts: Vec<String>,
490}
491
492/// Top-level trusted-proxy policy applied by every forwarding-aware middleware.
493///
494/// Declare this once under `[security.trusted_proxies]` and every framework
495/// middleware that reads `X-Forwarded-*` headers (rate limiter, CSRF origin
496/// check, method-override, HSTS detection, tracing fields) will honour it
497/// automatically.
498///
499/// # Examples
500///
501/// ```toml
502/// # Behind Cloudflare (known IP ranges) + an ALB in 10.0.0.0/8
503/// [security.trusted_proxies]
504/// ranges = ["173.245.48.0/20", "103.21.244.0/22", "10.0.0.0/8"]
505/// trust_forwarded_headers = true
506///
507/// # Behind exactly one ALB with dynamic IPs — trust the rightmost 1 hop
508/// [security.trusted_proxies]
509/// trusted_hops = 1
510/// trust_forwarded_headers = true
511/// ```
512#[derive(Debug, Clone, Default, Deserialize)]
513pub struct TrustedProxiesConfig {
514    /// Trusted proxy IP addresses or CIDR ranges.
515    ///
516    /// Walk the `X-Forwarded-For` chain from the right, skipping IPs in these
517    /// ranges.  The first IP that falls outside the ranges is the real client.
518    #[serde(default)]
519    pub ranges: Vec<String>,
520
521    /// Trust exactly this many proxy hops from the right of the
522    /// `X-Forwarded-For` chain, regardless of their IPs.
523    ///
524    /// Use when proxy IPs are dynamic (e.g., AWS ALB).  Takes precedence over
525    /// `ranges` when set.
526    #[serde(default)]
527    pub trusted_hops: Option<u32>,
528
529    /// Whether to consult `X-Forwarded-*` headers at all.
530    ///
531    /// Defaults to `false` in `prod` (safe default — no forwarding trust until
532    /// explicitly configured).  Set `true` when the application is behind a
533    /// reverse proxy that sets these headers.
534    #[serde(default)]
535    pub trust_forwarded_headers: bool,
536}
537
538/// Security response headers configuration.
539///
540/// Controls which protective HTTP headers are added to every response.
541/// Follows OWASP security header recommendations.
542///
543/// # Defaults
544///
545/// | Field | Default |
546/// |-------|---------|
547/// | `x_frame_options` | `"DENY"` |
548/// | `x_content_type_options` | `true` |
549/// | `xss_protection` | `true` |
550/// | `strict_transport_security` | `false` |
551/// | `hsts_max_age_secs` | `31_536_000` (1 year) |
552/// | `hsts_include_subdomains` | `true` |
553/// | `content_security_policy` | htmx-compatible policy (see [`default_content_security_policy`]) |
554/// | `referrer_policy` | `"strict-origin-when-cross-origin"` |
555/// | `permissions_policy` | `""` (disabled) |
556///
557/// # Examples
558///
559/// ```toml
560/// [security.headers]
561/// x_frame_options = "SAMEORIGIN"
562/// content_security_policy = "default-src 'self'; script-src 'self'"
563/// strict_transport_security = true
564/// ```
565#[derive(Debug, Clone, Deserialize)]
566#[allow(clippy::struct_excessive_bools)]
567pub struct HeadersConfig {
568    /// `X-Frame-Options` header value. Default: `"DENY"`.
569    ///
570    /// Prevents the page from being loaded in an iframe. Common values:
571    /// - `"DENY"` -- never allow framing
572    /// - `"SAMEORIGIN"` -- allow framing by same origin
573    /// - `""` -- do not send the header
574    #[serde(default = "default_x_frame_options")]
575    pub x_frame_options: String,
576
577    /// Add `X-Content-Type-Options: nosniff`. Default: `true`.
578    ///
579    /// Prevents MIME-type sniffing attacks.
580    #[serde(default = "default_true")]
581    pub x_content_type_options: bool,
582
583    /// Add `X-XSS-Protection: 1; mode=block`. Default: `true`.
584    ///
585    /// Enables the browser's built-in XSS filter (legacy but still useful).
586    #[serde(default = "default_true")]
587    pub xss_protection: bool,
588
589    /// Add `Strict-Transport-Security` (HSTS) header. Default: `false`.
590    ///
591    /// When `true`, tells browsers to only connect via HTTPS. Enabled
592    /// automatically for `prod` profile via smart defaults.
593    #[serde(default)]
594    pub strict_transport_security: bool,
595
596    /// HSTS `max-age` in seconds. Default: `31_536_000` (1 year).
597    ///
598    /// Only used when `strict_transport_security` is `true`.
599    #[serde(default = "default_hsts_max_age")]
600    pub hsts_max_age_secs: u64,
601
602    /// Include subdomains in HSTS policy. Default: `true`.
603    #[serde(default = "default_true")]
604    pub hsts_include_subdomains: bool,
605
606    /// `Content-Security-Policy` header value.
607    ///
608    /// Defaults to an htmx-compatible, same-origin policy (see
609    /// [`default_content_security_policy`]). When set to an empty string,
610    /// the header is not emitted (explicit opt-out).
611    ///
612    /// The default allows htmx to function normally because htmx and Autumn's
613    /// htmx CSRF helper are served from the same origin and operate via
614    /// `addEventListener` rather than inline scripts.
615    #[serde(default = "default_content_security_policy")]
616    pub content_security_policy: String,
617
618    /// `Referrer-Policy` header value. Default: `"strict-origin-when-cross-origin"`.
619    #[serde(default = "default_referrer_policy")]
620    pub referrer_policy: String,
621
622    /// `Permissions-Policy` header value. Default: `""` (not sent).
623    ///
624    /// Controls which browser features and APIs can be used.
625    /// Example: `"camera=(), microphone=(), geolocation=()"`.
626    #[serde(default)]
627    pub permissions_policy: String,
628
629    /// Per-request CSP nonce configuration.
630    ///
631    /// When enabled, a fresh cryptographically-random nonce is generated for
632    /// every request and injected into `script-src` and `style-src` of the
633    /// default `Content-Security-Policy`. The nonce is also available via the
634    /// [`CspNonce`] extractor for use in templates.
635    ///
636    /// Apps that set an explicit `content_security_policy` string opt out of
637    /// automatic nonce injection automatically — their custom CSP is used
638    /// verbatim, but the nonce is still generated and available via the
639    /// extractor.
640    ///
641    /// [`CspNonce`]: crate::security::CspNonce
642    #[serde(default)]
643    pub csp_nonce: CspNonceConfig,
644}
645
646impl Default for HeadersConfig {
647    fn default() -> Self {
648        Self {
649            x_frame_options: default_x_frame_options(),
650            x_content_type_options: true,
651            xss_protection: true,
652            strict_transport_security: false,
653            hsts_max_age_secs: default_hsts_max_age(),
654            hsts_include_subdomains: true,
655            content_security_policy: default_content_security_policy(),
656            referrer_policy: default_referrer_policy(),
657            permissions_policy: String::new(),
658            csp_nonce: CspNonceConfig::default(),
659        }
660    }
661}
662
663/// CSRF (Cross-Site Request Forgery) protection configuration.
664///
665/// When enabled, mutating requests (POST, PUT, DELETE, PATCH) must include
666/// a valid CSRF token either as:
667///
668/// - An HTTP header (default: `X-CSRF-Token`)
669/// - A form field (default: `_csrf`)
670///
671/// The token is generated per-session and stored in a cookie.
672///
673/// # Defaults
674///
675/// | Field | Default |
676/// |-------|---------|
677/// | `enabled` | `false` |
678/// | `token_header` | `"X-CSRF-Token"` |
679/// | `form_field` | `"_csrf"` |
680/// | `cookie_name` | `"autumn-csrf"` |
681/// | `safe_methods` | `["GET", "HEAD", "OPTIONS", "TRACE"]` |
682/// | `exempt_paths` | `[]` |
683/// | `token_scan_bytes` | `2_097_152` (2 MiB) |
684///
685/// # Examples
686///
687/// ```toml
688/// [security.csrf]
689/// enabled = true
690/// token_header = "X-XSRF-Token"
691/// cookie_name = "XSRF-TOKEN"
692/// exempt_paths = ["/api/"]
693/// ```
694#[derive(Debug, Clone, Deserialize)]
695pub struct CsrfConfig {
696    /// Enable CSRF protection. Default: `false`.
697    ///
698    /// Enabled automatically for `prod` profile via smart defaults.
699    #[serde(default)]
700    pub enabled: bool,
701
702    /// HTTP header name for the CSRF token. Default: `"X-CSRF-Token"`.
703    #[serde(default = "default_csrf_header")]
704    pub token_header: String,
705
706    /// Form field name for the CSRF token. Default: `"_csrf"`.
707    #[serde(default = "default_csrf_field")]
708    pub form_field: String,
709
710    /// Cookie name for storing the CSRF token. Default: `"autumn-csrf"`.
711    #[serde(default = "default_csrf_cookie")]
712    pub cookie_name: String,
713
714    /// HTTP methods that do NOT require CSRF validation.
715    /// Default: `["GET", "HEAD", "OPTIONS", "TRACE"]`.
716    #[serde(default = "default_safe_methods")]
717    pub safe_methods: Vec<String>,
718
719    /// Request path prefixes that are exempt from CSRF validation.
720    /// Default: `[]`.
721    ///
722    /// Use this to opt JSON API routes out of CSRF when they authenticate
723    /// with bearer tokens or other non-cookie credentials. Matches are by
724    /// prefix on the request path, e.g. `"/api/"` exempts all routes
725    /// under `/api/`.
726    #[serde(default)]
727    pub exempt_paths: Vec<String>,
728
729    /// Maximum number of leading request-body bytes scanned for the `_csrf`
730    /// form field on a urlencoded / multipart POST. Default: `2 MiB`
731    /// (`2 * 1024 * 1024`).
732    ///
733    /// The token scan reads at most this many bytes of the body into a prefix
734    /// buffer and looks for the `_csrf` field there. The rest of the body is
735    /// **streamed through unbuffered** to the handler, so a large file upload
736    /// is never fully copied into memory by the CSRF layer. This deliberately
737    /// does **not** track `upload.max_request_size_bytes`: buffering a whole
738    /// 32 MiB upload per request (× concurrency) just to locate a token would
739    /// be a DoS-shaped memory cost and would defeat the streaming upload path.
740    ///
741    /// **Token-early constraint:** because only this prefix is scanned, the
742    /// `_csrf` token must appear within the first `token_scan_bytes` of the
743    /// body. Scaffolded forms emit the hidden `_csrf` field *before* any file
744    /// field, so they are always safe. Hand-written forms that place large
745    /// fields ahead of `_csrf` should either move the token earlier or raise
746    /// this cap (the escape hatch). A genuinely oversized body whose token is
747    /// beyond the prefix is not found and is rejected downstream (403 missing
748    /// token, or the natural 413 from the upload/body limit).
749    #[serde(default = "default_csrf_token_scan_bytes")]
750    pub token_scan_bytes: usize,
751}
752
753impl Default for CsrfConfig {
754    fn default() -> Self {
755        Self {
756            enabled: false,
757            token_header: default_csrf_header(),
758            form_field: default_csrf_field(),
759            cookie_name: default_csrf_cookie(),
760            safe_methods: default_safe_methods(),
761            exempt_paths: Vec::new(),
762            token_scan_bytes: default_csrf_token_scan_bytes(),
763        }
764    }
765}
766
767/// One-time submit-token protection settings.
768///
769/// When enabled (the default), a per-render random token is exposed via the
770/// [`SubmitToken`](crate::security::SubmitToken) extractor and embedded as a
771/// hidden `_submit_token` field in scaffolded create/update forms. On the
772/// mutating POST the server consumes the token exactly once: a double-click,
773/// Back→resubmit, or browser retry carrying an already-consumed token replays
774/// the first response instead of re-running the handler, so no duplicate row is
775/// created — with no client-side JavaScript.
776///
777/// Unlike [`IdempotencyConfig`](crate::config::IdempotencyConfig), the guard is
778/// driven by a form field, not the `Idempotency-Key` header, so it protects
779/// bare browser form submits.
780///
781/// # Defaults
782///
783/// | Field | Default |
784/// |-------|---------|
785/// | `enabled` | `true` |
786/// | `field_name` | `"_submit_token"` |
787/// | `ttl_secs` | `600` (10 min) |
788/// | `in_flight_ttl_secs` | `86_400` (24 h) |
789/// | `backend` | *inherits `[idempotency].backend`* (in-memory in dev, Redis in prod) |
790/// | `exempt_paths` | `[]` |
791///
792/// # Examples
793///
794/// ```toml
795/// [security.submit_token]
796/// enabled = true
797/// ttl_secs = 900
798/// backend = "redis"   # override; reuses the [idempotency.redis] connection settings
799/// ```
800#[derive(Debug, Clone, Deserialize)]
801pub struct SubmitTokenConfig {
802    /// Enable one-time submit-token protection. Default: `true`.
803    #[serde(default = "default_submit_token_enabled")]
804    pub enabled: bool,
805
806    /// Hidden form field name carrying the token. Default: `"_submit_token"`.
807    #[serde(default = "default_submit_token_field")]
808    pub field_name: String,
809
810    /// Time-to-live in seconds for a consumed token's stored response.
811    /// Default: `600` (10 minutes).
812    #[serde(default = "default_submit_token_ttl_secs")]
813    pub ttl_secs: u64,
814
815    /// Maximum stale lifetime in seconds for an in-flight submission lock.
816    ///
817    /// While a mutating request is running, its token is locked so a concurrent
818    /// retry carrying the same token is excluded until the first request records
819    /// its consumed response. The lock is released as soon as that record is
820    /// stored, so this value is only the backend safety expiry for crashes or
821    /// lost unlocks — it must be comfortably longer than any supported mutating
822    /// request duration. Deliberately **independent of `ttl_secs`** (the replay
823    /// window): lowering `ttl_secs` must never shorten how long an active
824    /// submission is excluded from re-entry, which would let a slow request's
825    /// retry acquire a fresh lock and double-execute. Default: `86_400`
826    /// (24 hours), matching `[idempotency].in_flight_ttl_secs`.
827    #[serde(default = "default_submit_token_in_flight_ttl_secs")]
828    pub in_flight_ttl_secs: u64,
829
830    /// Storage backend for consumed submit tokens.
831    ///
832    /// When unset (the default, `None`), the submit-token store **inherits the
833    /// configured idempotency backend** (`[idempotency].backend`): a
834    /// Redis-configured app automatically shares one consumed-token store across
835    /// replicas, while a dev app on the default in-memory idempotency backend
836    /// keeps an in-memory token store. This matches issue #1360: the token store
837    /// is backed by the existing idempotency/session store backend (in-memory in
838    /// dev, Redis in prod), so a double-click load-balanced to a different
839    /// replica cannot re-run the mutation in production.
840    ///
841    /// Set explicitly to override the inherited backend for submit tokens only.
842    /// When it resolves to `"redis"`, the store reuses the `[idempotency.redis]`
843    /// connection settings so a multi-replica deployment shares one token store.
844    ///
845    /// Use [`Self::resolved_backend`] to obtain the effective backend.
846    #[serde(default)]
847    pub backend: Option<crate::config::IdempotencyBackend>,
848
849    /// Request path prefixes that are exempt from submit-token guarding.
850    /// Default: `[]`.
851    #[serde(default)]
852    pub exempt_paths: Vec<String>,
853}
854
855impl Default for SubmitTokenConfig {
856    fn default() -> Self {
857        Self {
858            enabled: default_submit_token_enabled(),
859            field_name: default_submit_token_field(),
860            ttl_secs: default_submit_token_ttl_secs(),
861            in_flight_ttl_secs: default_submit_token_in_flight_ttl_secs(),
862            backend: None,
863            exempt_paths: Vec::new(),
864        }
865    }
866}
867
868impl SubmitTokenConfig {
869    /// Resolve the effective consumed-token storage backend.
870    ///
871    /// Returns the explicit `backend` override when one is configured;
872    /// otherwise inherits `idempotency_backend` (the app's
873    /// `[idempotency].backend`) so submit tokens share the idempotency store by
874    /// default. This is the single source of truth for backend selection so the
875    /// idempotency layer and the submit-token layer cannot drift apart.
876    #[must_use]
877    pub fn resolved_backend(
878        &self,
879        idempotency_backend: crate::config::IdempotencyBackend,
880    ) -> crate::config::IdempotencyBackend {
881        self.backend.unwrap_or(idempotency_backend)
882    }
883
884    /// Decide the production safety action for the resolved consumed-token
885    /// backend.
886    ///
887    /// Submit tokens are DEFAULT-ON, so the resolved backend can silently land
888    /// on the in-memory store in production when neither `[idempotency]` nor
889    /// `[security.submit_token].backend` is configured. A per-process memory
890    /// store cannot deduplicate submits across replicas, so this mirrors the
891    /// idempotency production-memory guard
892    /// ([`fail_fast_on_invalid_idempotency_config`](crate::app)) — using the
893    /// same `prod`/`production` profile detection — while distinguishing an
894    /// EXPLICIT opt-in from an INHERITED default:
895    ///
896    /// - EXPLICIT `[security.submit_token].backend = "memory"` in production
897    ///   ([`Self::backend`] is `Some(Memory)`) → [`SubmitTokenMemoryGuard::FailExplicit`]:
898    ///   the operator deliberately chose an unsafe backend, so fail fast like
899    ///   idempotency's explicit enabled+memory prod guard.
900    /// - INHERITED default ([`Self::backend`] is `None`) that resolves to
901    ///   memory in production → [`SubmitTokenMemoryGuard::WarnInherited`]: only
902    ///   warn, so upgrading Autumn does not turn into "prod won't boot without
903    ///   Redis" for a single-replica app.
904    /// - Non-production, or a resolved backend that is not memory →
905    ///   [`SubmitTokenMemoryGuard::Ok`].
906    #[must_use]
907    pub(crate) fn production_memory_guard(
908        &self,
909        idempotency_backend: crate::config::IdempotencyBackend,
910        is_production: bool,
911    ) -> SubmitTokenMemoryGuard {
912        use crate::config::IdempotencyBackend;
913        if !is_production
914            || self.resolved_backend(idempotency_backend) != IdempotencyBackend::Memory
915        {
916            return SubmitTokenMemoryGuard::Ok;
917        }
918        // Resolved to the in-memory store in production. `backend == Some(Memory)`
919        // is an explicit opt-in (hard fail); `backend == None` inherited the
920        // memory idempotency backend (warn only). `Some(Redis)` cannot reach here
921        // because it would not resolve to memory.
922        match self.backend {
923            Some(IdempotencyBackend::Memory) => SubmitTokenMemoryGuard::FailExplicit,
924            _ => SubmitTokenMemoryGuard::WarnInherited,
925        }
926    }
927}
928
929/// Production safety decision for the resolved submit-token consumed-token
930/// backend. Produced by [`SubmitTokenConfig::production_memory_guard`].
931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
932pub enum SubmitTokenMemoryGuard {
933    /// No action: not production, or the resolved backend is not the in-memory
934    /// store.
935    Ok,
936    /// The default/inherited backend resolves to the in-memory store in
937    /// production. Boot proceeds, but an actionable startup warning is emitted.
938    WarnInherited,
939    /// An explicit `[security.submit_token].backend = "memory"` in production.
940    /// Boot must fail fast.
941    FailExplicit,
942}
943
944const fn default_submit_token_enabled() -> bool {
945    true
946}
947
948fn default_submit_token_field() -> String {
949    "_submit_token".to_owned()
950}
951
952const fn default_submit_token_ttl_secs() -> u64 {
953    600
954}
955
956const fn default_submit_token_in_flight_ttl_secs() -> u64 {
957    86_400
958}
959
960/// Strategy for identifying which client a rate-limit bucket belongs to.
961///
962/// Controls what value is used as the bucket key for incoming requests.
963///
964/// # `autumn.toml` example
965///
966/// ```toml
967/// [security.rate_limit]
968/// enabled = true
969/// key_strategy = "authenticated_principal"
970/// ```
971///
972/// | Value | Description |
973/// |-------|-------------|
974/// | `"ip"` | Connection peer address (default). Safe against header spoofing. |
975/// | `"api_token"` | `Authorization: Bearer <token>` value. Falls back to IP when no token. |
976/// | `"authenticated_principal"` | Principal ID set by auth middleware via `RateLimitPrincipal` extension. Falls back to IP for unauthenticated requests. |
977#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
978#[serde(rename_all = "snake_case")]
979pub enum KeyStrategy {
980    /// Key on client IP address (connection peer or trusted-proxy-resolved). **Default.**
981    #[default]
982    Ip,
983    /// Key on the `Authorization: Bearer` token value. Falls back to IP when absent.
984    ///
985    /// The `token` alias matches the `#[throttle(key = "token")]` macro spelling so an
986    /// operator can move an inline policy into `[security.rate_limit.named.*]` verbatim.
987    #[serde(alias = "token")]
988    ApiToken,
989    /// Key on the authenticated principal ID from the `RateLimitPrincipal` request
990    /// extension (set by the auth middleware). Falls back to IP for unauthenticated requests.
991    ///
992    /// The `principal` alias matches the `#[throttle(key = "principal")]` macro spelling so
993    /// an operator can move an inline policy into `[security.rate_limit.named.*]` verbatim.
994    #[serde(alias = "principal")]
995    AuthenticatedPrincipal,
996}
997
998impl KeyStrategy {
999    pub(crate) fn from_env_value(value: &str) -> Option<Self> {
1000        match value.trim().to_ascii_lowercase().as_str() {
1001            "ip" => Some(Self::Ip),
1002            "api_token" => Some(Self::ApiToken),
1003            "authenticated_principal" => Some(Self::AuthenticatedPrincipal),
1004            _ => None,
1005        }
1006    }
1007}
1008
1009/// Per-tier rate limit parameters for tiered quota configuration.
1010///
1011/// Declare named tiers under `[security.rate_limit.tiers.<name>]` in `autumn.toml`.
1012/// Each tier gets its own token bucket with independent `requests_per_second` and
1013/// `burst` values. The app maps callers to a tier via a tier-assignment hook
1014/// (see [`crate::security::rate_limit::RateLimitLayer::with_tier_hook`]).
1015///
1016/// # `autumn.toml` example
1017///
1018/// ```toml
1019/// [security.rate_limit.tiers.free]
1020/// requests_per_second = 1.0
1021/// burst = 10
1022///
1023/// [security.rate_limit.tiers.pro]
1024/// requests_per_second = 10.0
1025/// burst = 100
1026///
1027/// [security.rate_limit.tiers.enterprise]
1028/// requests_per_second = 100.0
1029/// burst = 1000
1030/// ```
1031#[derive(Debug, Clone, Deserialize)]
1032pub struct RateLimitTierConfig {
1033    /// Steady-state refill rate for this tier in requests per second.
1034    pub requests_per_second: f64,
1035    /// Maximum burst capacity (token bucket size) for this tier.
1036    pub burst: u32,
1037}
1038
1039/// Named per-route rate limit referenced from `#[throttle("name")]`.
1040///
1041/// Declare one entry per named limiter under `[security.rate_limit.named.<name>]`
1042/// and reference it from a handler via `#[throttle("name")]` so the throttling
1043/// policy lives in config, not code.
1044///
1045/// ```toml
1046/// [security.rate_limit.named.login]
1047/// limit = 5
1048/// per = "1m"
1049/// key = "ip"
1050/// ```
1051#[derive(Debug, Clone, Deserialize)]
1052pub struct RateLimitNamedConfig {
1053    /// Maximum number of requests allowed in each `per` window. Also serves as
1054    /// the token-bucket burst capacity.
1055    pub limit: u32,
1056    /// Window duration parsed by [`crate::task::parse_duration`] (e.g. `"1m"`,
1057    /// `"30s"`, `"1h"`). The steady-state refill rate is `limit / per`.
1058    pub per: String,
1059    /// Optional keying strategy override. When `None`, the named limiter uses
1060    /// the same key strategy as the global limiter (`security.rate_limit.key_strategy`).
1061    #[serde(default)]
1062    pub key: Option<KeyStrategy>,
1063}
1064
1065/// Rate limiting configuration.
1066///
1067/// Applies a token bucket to every request, keyed by client IP (default)
1068/// or by authenticated principal / API token. When a client exhausts their
1069/// bucket, the middleware returns `429 Too Many Requests` with `Retry-After`
1070/// and Problem Details (RFC 9457).
1071///
1072/// # Defaults
1073///
1074/// | Field | Default |
1075/// |-------|---------|
1076/// | `enabled` | `false` |
1077/// | `requests_per_second` | `10.0` |
1078/// | `burst` | `20` |
1079/// | `trust_forwarded_headers` | `false` |
1080/// | `trusted_proxies` | `[]` |
1081/// | `key_strategy` | `"ip"` |
1082/// | `tiers` | `{}` (no tiers; all callers share the default config) |
1083///
1084/// # Client IP resolution
1085///
1086/// By default the limiter keys on the **connection peer address**. This
1087/// prevents clients from bypassing throttling by rotating `X-Forwarded-For`
1088/// values. Set `trust_forwarded_headers = true` only when the server
1089/// sits behind a trusted reverse proxy that strips and rewrites
1090/// forwarding headers on every request.
1091///
1092/// If trusted upstream proxies append to `X-Forwarded-For`, configure
1093/// `trusted_proxies` with the trusted proxy IPs or CIDR ranges. Autumn
1094/// then walks the header from right to left, skips those trusted proxy
1095/// hops, and keys the bucket on the nearest untrusted client IP.
1096///
1097/// # Per-principal / API-token keying
1098///
1099/// Set `key_strategy = "authenticated_principal"` to key on the authenticated
1100/// user identity instead of IP. Auth middleware must insert a
1101/// `RateLimitPrincipal` extension on the request before the rate limiter runs.
1102/// Unauthenticated requests fall through to IP-based keying — never silently
1103/// unbounded.
1104///
1105/// Set `key_strategy = "api_token"` to key on the `Authorization: Bearer`
1106/// token value. Falls back to IP when no `Authorization` header is present.
1107///
1108/// # Tiered quotas
1109///
1110/// Declare named tiers and register a tier-assignment hook at startup:
1111///
1112/// ```toml
1113/// [security.rate_limit]
1114/// key_strategy = "authenticated_principal"
1115///
1116/// [security.rate_limit.tiers.free]
1117/// requests_per_second = 1.0
1118/// burst = 10
1119///
1120/// [security.rate_limit.tiers.pro]
1121/// requests_per_second = 10.0
1122/// burst = 100
1123/// ```
1124///
1125/// # Examples
1126///
1127/// ```toml
1128/// [security.rate_limit]
1129/// enabled = true
1130/// requests_per_second = 5.0
1131/// burst = 10
1132/// trust_forwarded_headers = false
1133/// trusted_proxies = ["10.0.0.10", "203.0.113.0/24"]
1134/// key_strategy = "authenticated_principal"
1135///
1136/// # Multi-replica: share the budget across all pods
1137/// backend = "redis"
1138/// on_backend_failure = "fail_open"
1139///
1140/// [security.rate_limit.redis]
1141/// url = "redis://redis:6379"
1142/// key_prefix = "myapp:rate_limit"
1143/// ```
1144#[derive(Debug, Clone, Deserialize)]
1145pub struct RateLimitConfig {
1146    /// Enable rate limiting. Default: `false`.
1147    #[serde(default)]
1148    pub enabled: bool,
1149
1150    /// Steady-state refill rate in requests per second. Default: `10.0`.
1151    ///
1152    /// Used as the default when no tier matches. Configure per-tier values
1153    /// under `[security.rate_limit.tiers.<name>]`.
1154    #[serde(default = "default_rps")]
1155    pub requests_per_second: f64,
1156
1157    /// Maximum burst capacity (number of tokens the bucket can hold).
1158    /// Default: `20`.
1159    ///
1160    /// Used as the default when no tier matches.
1161    #[serde(default = "default_burst")]
1162    pub burst: u32,
1163
1164    /// **Deprecated** — use `[security.trusted_proxies]` instead.
1165    ///
1166    /// Consult `X-Forwarded-For` / `X-Real-IP` before the connection peer
1167    /// when identifying the client. Default: `false`.
1168    ///
1169    /// This field is honoured for one minor release and emits a startup
1170    /// warning.  Configure [`SecurityConfig::trusted_proxies`] to silence
1171    /// the warning and share the policy with all middleware.
1172    #[serde(default)]
1173    pub trust_forwarded_headers: bool,
1174
1175    /// **Deprecated** — use `[security.trusted_proxies]` instead.
1176    ///
1177    /// Trusted proxy IP addresses or CIDR ranges to skip at the right
1178    /// side of an appended `X-Forwarded-For` chain.
1179    ///
1180    /// This field is honoured for one minor release and emits a startup
1181    /// warning.  Configure [`SecurityConfig::trusted_proxies`] to silence
1182    /// the warning and share the policy with all middleware.
1183    #[serde(default)]
1184    pub trusted_proxies: Vec<String>,
1185
1186    /// Key extraction strategy. Default: `"ip"`.
1187    ///
1188    /// Determines what value is used as the rate-limit bucket key.
1189    /// See [`KeyStrategy`] for the available options.
1190    #[serde(default)]
1191    pub key_strategy: KeyStrategy,
1192
1193    /// Named tiers with per-tier `requests_per_second` and `burst` values.
1194    ///
1195    /// When a tier-assignment hook is registered and returns a tier name that
1196    /// matches a key here, that tier's config is used for the caller's bucket
1197    /// instead of the top-level defaults.
1198    #[serde(default)]
1199    pub tiers: HashMap<String, RateLimitTierConfig>,
1200
1201    /// Named per-route limiters referenced from `#[throttle("name")]`.
1202    ///
1203    /// Each entry defines a `limit` (tokens per window) and `per` (window
1204    /// duration) so ops can tune per-route thresholds without a recompile.
1205    /// An optional `key` overrides the default keying strategy for that
1206    /// named limiter.
1207    ///
1208    /// ```toml
1209    /// [security.rate_limit.named.login]
1210    /// limit = 5
1211    /// per = "1m"
1212    /// key = "ip"
1213    /// ```
1214    #[serde(default)]
1215    pub named: HashMap<String, RateLimitNamedConfig>,
1216
1217    /// Bucket store backend. Default: `"memory"` (in-process, single-replica).
1218    ///
1219    /// Set to `"redis"` in multi-replica deployments so the configured
1220    /// rate cap is enforced globally rather than per pod. Requires the
1221    /// `redis` cargo feature to take effect; without it, a startup warning
1222    /// is emitted and the memory backend is used.
1223    #[serde(default)]
1224    pub backend: RateLimitBackend,
1225
1226    /// Redis backend options. Used when `backend = "redis"`.
1227    ///
1228    /// Requires the `redis` cargo feature.
1229    #[cfg(feature = "redis")]
1230    #[serde(default)]
1231    pub redis: RateLimitRedisConfig,
1232
1233    /// Behavior when the backend is unavailable. Default: `"fail_open"`.
1234    ///
1235    /// `"fail_open"` lets requests through (matches single-replica posture).
1236    /// `"fail_closed"` returns `429` until the backend recovers.
1237    ///
1238    /// Requires the `redis` cargo feature.
1239    #[cfg(feature = "redis")]
1240    #[serde(default)]
1241    pub on_backend_failure: RateLimitBackendFailure,
1242}
1243
1244impl Default for RateLimitConfig {
1245    fn default() -> Self {
1246        Self {
1247            enabled: false,
1248            requests_per_second: default_rps(),
1249            burst: default_burst(),
1250            trust_forwarded_headers: false,
1251            trusted_proxies: Vec::new(),
1252            key_strategy: KeyStrategy::default(),
1253            tiers: HashMap::new(),
1254            named: HashMap::new(),
1255            backend: RateLimitBackend::default(),
1256            #[cfg(feature = "redis")]
1257            redis: RateLimitRedisConfig::default(),
1258            #[cfg(feature = "redis")]
1259            on_backend_failure: RateLimitBackendFailure::default(),
1260        }
1261    }
1262}
1263
1264/// Storage backend for per-IP token buckets.
1265///
1266/// Matches the pattern established by [`CacheBackend`](crate::config::CacheBackend)
1267/// (issue #535) and `SchedulerBackend` (issue #531): one `backend = "redis"` flip
1268/// per subsystem, identical failure semantics.
1269///
1270/// The enum is always available so misconfiguration is detectable even when the
1271/// `redis` cargo feature is disabled. Without the feature, selecting `Redis`
1272/// emits a startup warning and falls back to `Memory`.
1273///
1274/// [`CacheBackend`]: crate::config::CacheBackend
1275#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
1276#[serde(rename_all = "lowercase")]
1277pub enum RateLimitBackend {
1278    /// In-process LRU of token buckets (default). Each replica maintains its own
1279    /// store; a 3-replica deployment permits up to 3× the configured rate.
1280    #[default]
1281    Memory,
1282    /// Shared Redis store coordinated via an atomic Lua script. The configured
1283    /// rate is enforced globally across all replicas.
1284    ///
1285    /// Requires the `redis` cargo feature.
1286    Redis,
1287}
1288
1289impl RateLimitBackend {
1290    pub(crate) fn from_env_value(value: &str) -> Option<Self> {
1291        match value.trim().to_ascii_lowercase().as_str() {
1292            "memory" => Some(Self::Memory),
1293            "redis" => Some(Self::Redis),
1294            _ => None,
1295        }
1296    }
1297}
1298
1299/// Behavior when the rate-limit backend becomes unreachable.
1300///
1301/// Configures the limiter's posture when the storage backend (Redis) is
1302/// unavailable. Matches the pattern used by the webhook replay store.
1303///
1304/// Requires the `redis` cargo feature.
1305#[cfg(feature = "redis")]
1306#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
1307#[serde(rename_all = "snake_case")]
1308pub enum RateLimitBackendFailure {
1309    /// Allow the request through. Matches the existing single-replica posture:
1310    /// a lost limiter is invisible to clients. **Default.**
1311    #[default]
1312    #[serde(alias = "open")]
1313    FailOpen,
1314    /// Deny the request with `429 Too Many Requests` until the backend recovers.
1315    #[serde(alias = "closed")]
1316    FailClosed,
1317}
1318
1319#[cfg(feature = "redis")]
1320impl RateLimitBackendFailure {
1321    pub(crate) fn from_env_value(value: &str) -> Option<Self> {
1322        match value.trim().to_ascii_lowercase().as_str() {
1323            "fail_open" | "open" => Some(Self::FailOpen),
1324            "fail_closed" | "closed" => Some(Self::FailClosed),
1325            _ => None,
1326        }
1327    }
1328}
1329
1330/// Redis-specific options for the rate-limit backend.
1331///
1332/// Used when `security.rate_limit.backend = "redis"`.
1333///
1334/// Requires the `redis` cargo feature.
1335#[cfg(feature = "redis")]
1336#[derive(Debug, Clone, Deserialize)]
1337pub struct RateLimitRedisConfig {
1338    /// Redis connection URL (e.g. `redis://127.0.0.1:6379`).
1339    /// Reuses the same Redis instance as sessions, cache, and the scheduler.
1340    #[serde(default)]
1341    pub url: Option<String>,
1342
1343    /// Key prefix for all token-bucket hashes stored in Redis.
1344    #[serde(default = "default_rate_limit_redis_key_prefix")]
1345    pub key_prefix: String,
1346}
1347
1348#[cfg(feature = "redis")]
1349impl Default for RateLimitRedisConfig {
1350    fn default() -> Self {
1351        Self {
1352            url: None,
1353            key_prefix: default_rate_limit_redis_key_prefix(),
1354        }
1355    }
1356}
1357
1358#[cfg(feature = "redis")]
1359fn default_rate_limit_redis_key_prefix() -> String {
1360    "autumn:rate_limit".to_owned()
1361}
1362
1363/// Multipart upload configuration.
1364///
1365/// Applies framework-level guardrails for `multipart/form-data` requests:
1366///
1367/// - `max_request_size_bytes`: global request body cap (enforced by middleware)
1368/// - `max_file_size_bytes`: per-file cap for `crate::extract::Multipart` helpers
1369/// - `allowed_mime_types`: optional MIME-type allow list for uploaded parts
1370/// - `reject_on_content_type_mismatch`: strict mode that rejects when the
1371///   client-declared `Content-Type` disagrees with the sniffed content
1372///
1373/// Leave `allowed_mime_types` empty to allow any content type.
1374///
1375/// # Content sniffing
1376///
1377/// The `crate::extract::Multipart` extractor validates uploaded file parts by
1378/// their actual content (magic bytes), **not** the spoofable client-declared
1379/// `Content-Type` header. See `allowed_mime_types` for the exact sniffed →
1380/// markup-guard → declared-fallback precedence used when a list is configured.
1381#[derive(Debug, Clone, Deserialize)]
1382pub struct UploadConfig {
1383    /// Maximum total multipart request body size in bytes.
1384    #[serde(default = "default_max_request_size_bytes")]
1385    pub max_request_size_bytes: usize,
1386    /// Maximum individual uploaded file size in bytes.
1387    #[serde(default = "default_max_file_size_bytes")]
1388    pub max_file_size_bytes: usize,
1389    /// Optional allowed MIME types (e.g. `["image/png", "image/jpeg"]`).
1390    ///
1391    /// Enforced primarily against the **sniffed** (magic-byte) content type,
1392    /// never blindly against the client-declared header. Because `infer` only
1393    /// recognizes binary formats, the check applies this precedence for each
1394    /// file part when the list is non-empty:
1395    ///
1396    /// 1. **Sniffed type recognized** → it must appear in the list, else the
1397    ///    upload is rejected (`400`). The declared header is ignored.
1398    /// 2. **Unrecognized but looks like markup** (leading `<…` after a BOM /
1399    ///    whitespace — HTML, SVG, XML) → always rejected (`400`), so scripts
1400    ///    or `<svg onload=…>` can't ride in under a spoofed declared type.
1401    /// 3. **Unrecognized and not markup** → the declared content-type essence
1402    ///    (media type without parameters) is trusted **only** when it names a
1403    ///    signature-less TEXT type (`text/*`, `application/json`,
1404    ///    `application/csv`) that appears in the list. Binary/sniffable types
1405    ///    (`image/*`, `application/pdf`, …) are always enforced strictly by
1406    ///    magic bytes: unrecognizable bytes declaring such a type are rejected
1407    ///    (`400`), since a genuine file of that type would have sniffed
1408    ///    positively. This is the only case where the declared header is
1409    ///    trusted, and only to disambiguate among signature-less text formats.
1410    #[serde(default)]
1411    pub allowed_mime_types: Vec<String>,
1412    /// When `true`, reject an uploaded file part if the client-declared
1413    /// `Content-Type` header disagrees with the sniffed (magic-byte) content
1414    /// type. Default: `false`.
1415    ///
1416    /// Behavior when enabled (comparison uses the declared essence — the media
1417    /// type without parameters):
1418    /// - declared and sniffed both known but differ → reject (`400`)
1419    /// - declared known but content unrecognized (sniffed unknown) → reject
1420    ///   (`400`, the declared type cannot be verified)
1421    /// - no declared header → reject (`400`); omitting `Content-Type` must not
1422    ///   silently bypass the mismatch check
1423    ///
1424    /// This is independent of `allowed_mime_types`; both checks apply when set.
1425    #[serde(default)]
1426    pub reject_on_content_type_mismatch: bool,
1427}
1428
1429impl Default for UploadConfig {
1430    fn default() -> Self {
1431        Self {
1432            max_request_size_bytes: default_max_request_size_bytes(),
1433            max_file_size_bytes: default_max_file_size_bytes(),
1434            allowed_mime_types: Vec::new(),
1435            reject_on_content_type_mismatch: false,
1436        }
1437    }
1438}
1439
1440/// Per-request Content Security Policy nonce configuration.
1441///
1442/// When `enabled = true`, the security-headers middleware generates a fresh
1443/// cryptographically-random nonce (≥128 bits, URL-safe base64) for every
1444/// request. The nonce is:
1445///
1446/// 1. Injected into `script-src` and `style-src` of the **default** CSP as
1447///    `'nonce-<value>'`, replacing `'unsafe-inline'`.
1448/// 2. Inserted into request extensions so handlers can extract it via
1449///    [`CspNonce`](crate::security::CspNonce).
1450///
1451/// Apps that override `content_security_policy` with an explicit string
1452/// automatically opt out of nonce injection for the header — the custom CSP
1453/// is used verbatim — but the nonce is still generated and available via the
1454/// extractor for template use.
1455///
1456/// # `autumn.toml` example
1457///
1458/// ```toml
1459/// [security.headers.csp_nonce]
1460/// enabled = true
1461/// ```
1462#[derive(Debug, Clone, Default, Deserialize)]
1463pub struct CspNonceConfig {
1464    /// Enable per-request CSP nonce generation. Default: `false`.
1465    #[serde(default)]
1466    pub enabled: bool,
1467}
1468
1469// ── Default value functions ────────────────────────────────────────
1470
1471const fn default_true() -> bool {
1472    true
1473}
1474
1475fn default_x_frame_options() -> String {
1476    "DENY".to_owned()
1477}
1478
1479const fn default_hsts_max_age() -> u64 {
1480    31_536_000 // 1 year
1481}
1482
1483fn default_referrer_policy() -> String {
1484    "strict-origin-when-cross-origin".to_owned()
1485}
1486
1487/// Default `Content-Security-Policy` value.
1488///
1489/// Designed to be "sensible by default" while allowing htmx to function
1490/// normally when served from the same origin (as Autumn does for htmx and its
1491/// CSRF helper under `/static/js/`).
1492///
1493/// Directives:
1494/// - `default-src 'self'` -- everything defaults to same-origin
1495/// - `img-src 'self' data:` -- images from self and inline data URIs
1496/// - `style-src 'self' 'unsafe-inline'` -- same-origin stylesheets plus
1497///   inline `style` attributes (required by many UI libraries and
1498///   template engines)
1499/// - `script-src 'self'` -- only same-origin scripts; htmx and Autumn's htmx
1500///   CSRF helper work here because they are served from `/static/js/`
1501/// - `connect-src 'self'` -- `fetch`/`XHR`/htmx requests go to same origin
1502/// - `form-action 'self'` -- forms can only POST to same origin
1503/// - `frame-ancestors 'none'` -- matches the default `X-Frame-Options: DENY`
1504/// - `base-uri 'self'` -- prevents `<base>` hijacking
1505#[must_use]
1506pub fn default_content_security_policy() -> String {
1507    "default-src 'self'; \
1508     img-src 'self' data:; \
1509     style-src 'self' 'unsafe-inline'; \
1510     script-src 'self'; \
1511     connect-src 'self'; \
1512     form-action 'self'; \
1513     frame-ancestors 'none'; \
1514     base-uri 'self'"
1515        .to_owned()
1516}
1517
1518fn default_csrf_header() -> String {
1519    "X-CSRF-Token".to_owned()
1520}
1521
1522fn default_csrf_field() -> String {
1523    "_csrf".to_owned()
1524}
1525
1526fn default_csrf_cookie() -> String {
1527    "autumn-csrf".to_owned()
1528}
1529
1530/// Default CSRF token-scan prefix cap: 2 MiB.
1531///
1532/// Deliberately independent of `upload.max_request_size_bytes` — only the
1533/// leading prefix is buffered to locate `_csrf`; the remainder streams through.
1534const fn default_csrf_token_scan_bytes() -> usize {
1535    2 * 1024 * 1024
1536}
1537
1538fn default_safe_methods() -> Vec<String> {
1539    vec![
1540        "GET".to_owned(),
1541        "HEAD".to_owned(),
1542        "OPTIONS".to_owned(),
1543        "TRACE".to_owned(),
1544    ]
1545}
1546
1547const fn default_rps() -> f64 {
1548    10.0
1549}
1550
1551const fn default_burst() -> u32 {
1552    20
1553}
1554
1555const fn default_max_request_size_bytes() -> usize {
1556    32 * 1024 * 1024
1557}
1558
1559const fn default_max_file_size_bytes() -> usize {
1560    16 * 1024 * 1024
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565    use super::*;
1566    use crate::config::IdempotencyBackend;
1567
1568    // ── submit-token backend resolution (Finding D: inherit idempotency) ─────
1569
1570    #[test]
1571    fn submit_token_backend_defaults_to_none_and_inherits_idempotency() {
1572        // Unset `[security.submit_token].backend` deserializes to `None`.
1573        let cfg: SubmitTokenConfig = toml::from_str("").unwrap();
1574        assert_eq!(cfg.backend, None, "unset backend must deserialize to None");
1575        // With idempotency on Redis, the resolved submit-token backend follows
1576        // it — NOT the old hardcoded Memory default.
1577        assert_eq!(
1578            cfg.resolved_backend(IdempotencyBackend::Redis),
1579            IdempotencyBackend::Redis,
1580            "an unset submit-token backend must inherit the Redis idempotency backend"
1581        );
1582        // A dev app on the default Memory idempotency backend stays Memory.
1583        assert_eq!(
1584            cfg.resolved_backend(IdempotencyBackend::Memory),
1585            IdempotencyBackend::Memory,
1586            "an unset submit-token backend on a Memory idempotency app stays Memory"
1587        );
1588    }
1589
1590    #[test]
1591    fn submit_token_explicit_backend_overrides_inherited_idempotency() {
1592        // An explicit `backend = "memory"` wins even when idempotency is Redis.
1593        let cfg: SubmitTokenConfig = toml::from_str("backend = \"memory\"").unwrap();
1594        assert_eq!(cfg.backend, Some(IdempotencyBackend::Memory));
1595        assert_eq!(
1596            cfg.resolved_backend(IdempotencyBackend::Redis),
1597            IdempotencyBackend::Memory,
1598            "an explicit submit-token backend override must win over the inherited backend"
1599        );
1600
1601        // An explicit `backend = "redis"` wins even when idempotency is Memory.
1602        let cfg: SubmitTokenConfig = toml::from_str("backend = \"redis\"").unwrap();
1603        assert_eq!(cfg.backend, Some(IdempotencyBackend::Redis));
1604        assert_eq!(
1605            cfg.resolved_backend(IdempotencyBackend::Memory),
1606            IdempotencyBackend::Redis,
1607            "an explicit redis override must win over an inherited Memory backend"
1608        );
1609    }
1610
1611    // ── submit-token production memory guard (Finding O) ────────────────────
1612
1613    #[test]
1614    fn submit_token_explicit_memory_in_production_fails_fast() {
1615        // EXPLICIT `[security.submit_token].backend = "memory"` in production
1616        // is a deliberate unsafe opt-in → hard fail, mirroring idempotency's
1617        // explicit enabled+memory prod guard.
1618        let cfg: SubmitTokenConfig = toml::from_str("backend = \"memory\"").unwrap();
1619        assert_eq!(cfg.backend, Some(IdempotencyBackend::Memory));
1620        assert_eq!(
1621            cfg.production_memory_guard(IdempotencyBackend::Redis, true),
1622            SubmitTokenMemoryGuard::FailExplicit,
1623        );
1624        assert_eq!(
1625            cfg.production_memory_guard(IdempotencyBackend::Memory, true),
1626            SubmitTokenMemoryGuard::FailExplicit,
1627        );
1628    }
1629
1630    #[test]
1631    fn submit_token_inherited_memory_in_production_only_warns() {
1632        // INHERITED default (`backend = None`) resolving to Memory in production
1633        // must NOT fail — upgrading Autumn must not turn into "prod won't boot
1634        // without Redis". It only warns.
1635        let cfg: SubmitTokenConfig = toml::from_str("").unwrap();
1636        assert_eq!(cfg.backend, None);
1637        assert_eq!(
1638            cfg.production_memory_guard(IdempotencyBackend::Memory, true),
1639            SubmitTokenMemoryGuard::WarnInherited,
1640        );
1641        // Inherited Redis resolves to Redis → no warning, no fail.
1642        assert_eq!(
1643            cfg.production_memory_guard(IdempotencyBackend::Redis, true),
1644            SubmitTokenMemoryGuard::Ok,
1645        );
1646    }
1647
1648    #[test]
1649    fn submit_token_memory_outside_production_is_ok() {
1650        // Dev / non-production → no warn, no fail, regardless of explicit or
1651        // inherited memory.
1652        let explicit: SubmitTokenConfig = toml::from_str("backend = \"memory\"").unwrap();
1653        assert_eq!(
1654            explicit.production_memory_guard(IdempotencyBackend::Memory, false),
1655            SubmitTokenMemoryGuard::Ok,
1656        );
1657        let inherited: SubmitTokenConfig = toml::from_str("").unwrap();
1658        assert_eq!(
1659            inherited.production_memory_guard(IdempotencyBackend::Memory, false),
1660            SubmitTokenMemoryGuard::Ok,
1661        );
1662    }
1663
1664    #[test]
1665    fn submit_token_explicit_redis_backend_never_triggers_guard() {
1666        // An explicit Redis override never resolves to memory, so the guard is
1667        // a no-op even in production.
1668        let cfg: SubmitTokenConfig = toml::from_str("backend = \"redis\"").unwrap();
1669        assert_eq!(
1670            cfg.production_memory_guard(IdempotencyBackend::Memory, true),
1671            SubmitTokenMemoryGuard::Ok,
1672        );
1673    }
1674
1675    // ── validate_signing_secret (RED phase) ─────────────────────────────────
1676
1677    #[test]
1678    fn signing_secret_dev_skips_validation_with_none() {
1679        assert!(validate_signing_secret(None, false).is_ok());
1680    }
1681
1682    #[test]
1683    fn signing_secret_dev_skips_validation_with_weak_value() {
1684        assert!(validate_signing_secret(Some("changeme"), false).is_ok());
1685    }
1686
1687    #[test]
1688    fn signing_secret_dev_skips_validation_with_short_value() {
1689        assert!(validate_signing_secret(Some("short"), false).is_ok());
1690    }
1691
1692    #[test]
1693    fn signing_secret_prod_missing_is_error() {
1694        let err = validate_signing_secret(None, true).unwrap_err();
1695        assert!(matches!(err, SigningSecretError::MissingInProduction));
1696    }
1697
1698    #[test]
1699    fn signing_secret_prod_too_short_is_error() {
1700        let short = "a".repeat(MIN_SECRET_LEN - 1);
1701        let err = validate_signing_secret(Some(&short), true).unwrap_err();
1702        assert!(matches!(err, SigningSecretError::TooShort { .. }));
1703    }
1704
1705    #[test]
1706    fn signing_secret_prod_exact_min_length_passes() {
1707        let exactly_min = "a".repeat(MIN_SECRET_LEN);
1708        assert!(validate_signing_secret(Some(&exactly_min), true).is_ok());
1709    }
1710
1711    #[test]
1712    fn signing_secret_prod_known_demo_value_is_error() {
1713        let err = validate_signing_secret(Some("changeme"), true).unwrap_err();
1714        assert!(matches!(err, SigningSecretError::KnownWeakValue(_)));
1715    }
1716
1717    #[test]
1718    fn signing_secret_prod_demo_value_case_insensitive() {
1719        let err = validate_signing_secret(Some("CHANGEME"), true).unwrap_err();
1720        assert!(matches!(err, SigningSecretError::KnownWeakValue(_)));
1721    }
1722
1723    #[test]
1724    fn signing_secret_prod_valid_64char_hex_passes() {
1725        let secret = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1726        assert!(validate_signing_secret(Some(secret), true).is_ok());
1727    }
1728
1729    #[test]
1730    fn signing_secret_config_defaults_to_none() {
1731        let config = SigningSecretConfig::default();
1732        assert!(config.secret.is_none());
1733        assert!(config.previous_secrets.is_empty());
1734    }
1735
1736    #[test]
1737    fn signing_secret_error_missing_display_mentions_env_var() {
1738        let err = SigningSecretError::MissingInProduction;
1739        assert!(err.to_string().contains("AUTUMN_SECURITY__SIGNING_SECRET"));
1740    }
1741
1742    #[test]
1743    fn signing_secret_error_too_short_display_shows_lengths() {
1744        let err = SigningSecretError::TooShort {
1745            actual: 8,
1746            required: 32,
1747        };
1748        let s = err.to_string();
1749        assert!(s.contains('8'));
1750        assert!(s.contains("32"));
1751    }
1752
1753    #[test]
1754    fn signing_secret_error_weak_value_display_mentions_demo() {
1755        let err = SigningSecretError::KnownWeakValue("changeme".to_owned());
1756        assert!(err.to_string().contains("template/demo"));
1757    }
1758
1759    #[test]
1760    fn signing_secret_prod_too_short_error_reports_actual_length() {
1761        let short = "tooshort"; // 8 bytes
1762        let err = validate_signing_secret(Some(short), true).unwrap_err();
1763        if let SigningSecretError::TooShort { actual, required } = err {
1764            assert_eq!(actual, 8);
1765            assert_eq!(required, MIN_SECRET_LEN);
1766        } else {
1767            panic!("expected TooShort error");
1768        }
1769    }
1770
1771    #[test]
1772    fn signing_secret_prod_secret_key_demo_value_fails() {
1773        assert!(matches!(
1774            validate_signing_secret(Some("secret"), true),
1775            Err(SigningSecretError::KnownWeakValue(_))
1776        ));
1777    }
1778
1779    #[test]
1780    fn signing_secret_prod_supersecret_demo_value_fails() {
1781        assert!(matches!(
1782            validate_signing_secret(Some("supersecret"), true),
1783            Err(SigningSecretError::KnownWeakValue(_))
1784        ));
1785    }
1786
1787    #[test]
1788    fn signing_secret_config_deserialize_from_toml() {
1789        let toml_str = r#"
1790            secret = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
1791            previous_secrets = ["oldsecret01234567890123456789012"]
1792        "#;
1793        let config: SigningSecretConfig = toml::from_str(toml_str).unwrap();
1794        assert_eq!(
1795            config.secret.as_deref(),
1796            Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
1797        );
1798        assert_eq!(config.previous_secrets.len(), 1);
1799    }
1800
1801    #[test]
1802    fn security_config_defaults() {
1803        let config = SecurityConfig::default();
1804        assert_eq!(config.headers.x_frame_options, "DENY");
1805        assert!(config.headers.x_content_type_options);
1806        assert!(config.headers.xss_protection);
1807        assert!(!config.headers.strict_transport_security);
1808        assert_eq!(config.headers.hsts_max_age_secs, 31_536_000);
1809        // Default CSP is non-empty and htmx-compatible.
1810        assert!(!config.headers.content_security_policy.is_empty());
1811        assert!(
1812            config
1813                .headers
1814                .content_security_policy
1815                .contains("default-src 'self'")
1816        );
1817        assert!(
1818            config
1819                .headers
1820                .content_security_policy
1821                .contains("script-src 'self'")
1822        );
1823        assert_eq!(
1824            config.headers.referrer_policy,
1825            "strict-origin-when-cross-origin"
1826        );
1827    }
1828
1829    #[test]
1830    fn default_csp_does_not_allow_unsafe_eval() {
1831        // htmx works without unsafe-eval; only `hx-on` opts into it.
1832        // Keep the default tight so that the baseline policy passes
1833        // Mozilla Observatory and similar automated scanners.
1834        let csp = default_content_security_policy();
1835        assert!(!csp.contains("'unsafe-eval'"), "csp = {csp}");
1836        assert!(
1837            !csp.contains("'unsafe-inline' 'unsafe-eval'"),
1838            "csp = {csp}"
1839        );
1840    }
1841
1842    #[test]
1843    fn csp_can_be_disabled_via_toml_empty_string() {
1844        let toml_str = r#"
1845            content_security_policy = ""
1846        "#;
1847        let config: HeadersConfig = toml::from_str(toml_str).unwrap();
1848        assert!(config.content_security_policy.is_empty());
1849    }
1850
1851    #[test]
1852    fn csp_can_be_overridden_via_toml() {
1853        let toml_str = r#"
1854            content_security_policy = "default-src 'none'"
1855        "#;
1856        let config: HeadersConfig = toml::from_str(toml_str).unwrap();
1857        assert_eq!(config.content_security_policy, "default-src 'none'");
1858    }
1859
1860    #[test]
1861    fn csrf_config_defaults() {
1862        let config = CsrfConfig::default();
1863        assert!(!config.enabled);
1864        assert_eq!(config.token_header, "X-CSRF-Token");
1865        assert_eq!(config.form_field, "_csrf");
1866        assert_eq!(config.cookie_name, "autumn-csrf");
1867        assert_eq!(config.safe_methods.len(), 4);
1868    }
1869
1870    #[test]
1871    fn headers_config_deserialize() {
1872        let toml_str = r#"
1873            x_frame_options = "SAMEORIGIN"
1874            strict_transport_security = true
1875            content_security_policy = "default-src 'self'"
1876        "#;
1877        let config: HeadersConfig = toml::from_str(toml_str).unwrap();
1878        assert_eq!(config.x_frame_options, "SAMEORIGIN");
1879        assert!(config.strict_transport_security);
1880        assert_eq!(config.content_security_policy, "default-src 'self'");
1881        // Defaults for unspecified fields
1882        assert!(config.x_content_type_options);
1883        assert!(config.xss_protection);
1884    }
1885
1886    #[test]
1887    fn csrf_config_deserialize() {
1888        let toml_str = r#"
1889            enabled = true
1890            token_header = "X-XSRF-Token"
1891        "#;
1892        let config: CsrfConfig = toml::from_str(toml_str).unwrap();
1893        assert!(config.enabled);
1894        assert_eq!(config.token_header, "X-XSRF-Token");
1895        assert_eq!(config.form_field, "_csrf"); // default preserved
1896    }
1897
1898    #[test]
1899    fn rate_limit_config_defaults() {
1900        let config = RateLimitConfig::default();
1901        assert!(!config.enabled);
1902        assert!((config.requests_per_second - 10.0).abs() < f64::EPSILON);
1903        assert_eq!(config.burst, 20);
1904        assert!(!config.trust_forwarded_headers);
1905        assert!(config.trusted_proxies.is_empty());
1906        #[cfg(feature = "redis")]
1907        {
1908            assert_eq!(config.backend, RateLimitBackend::Memory);
1909            assert_eq!(config.on_backend_failure, RateLimitBackendFailure::FailOpen);
1910            assert_eq!(config.redis.key_prefix, "autumn:rate_limit");
1911        }
1912    }
1913
1914    #[cfg(feature = "redis")]
1915    #[test]
1916    fn rate_limit_backend_deserializes_memory() {
1917        let config: RateLimitConfig = toml::from_str("backend = \"memory\"").unwrap();
1918        assert_eq!(config.backend, RateLimitBackend::Memory);
1919    }
1920
1921    #[cfg(feature = "redis")]
1922    #[test]
1923    fn rate_limit_backend_deserializes_redis() {
1924        let config: RateLimitConfig = toml::from_str("backend = \"redis\"").unwrap();
1925        assert_eq!(config.backend, RateLimitBackend::Redis);
1926    }
1927
1928    #[cfg(feature = "redis")]
1929    #[test]
1930    fn rate_limit_on_backend_failure_deserializes_fail_open() {
1931        let config: RateLimitConfig = toml::from_str("on_backend_failure = \"fail_open\"").unwrap();
1932        assert_eq!(config.on_backend_failure, RateLimitBackendFailure::FailOpen);
1933    }
1934
1935    #[cfg(feature = "redis")]
1936    #[test]
1937    fn rate_limit_on_backend_failure_deserializes_fail_closed() {
1938        let config: RateLimitConfig =
1939            toml::from_str("on_backend_failure = \"fail_closed\"").unwrap();
1940        assert_eq!(
1941            config.on_backend_failure,
1942            RateLimitBackendFailure::FailClosed
1943        );
1944    }
1945
1946    #[cfg(feature = "redis")]
1947    #[test]
1948    fn rate_limit_redis_config_deserializes() {
1949        let toml_str = r#"
1950            backend = "redis"
1951            [redis]
1952            url = "redis://localhost:6379"
1953            key_prefix = "myapp:rl"
1954        "#;
1955        let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
1956        assert_eq!(config.backend, RateLimitBackend::Redis);
1957        assert_eq!(config.redis.url.as_deref(), Some("redis://localhost:6379"));
1958        assert_eq!(config.redis.key_prefix, "myapp:rl");
1959    }
1960
1961    #[cfg(feature = "redis")]
1962    #[test]
1963    fn rate_limit_redis_config_defaults_key_prefix() {
1964        let config: RateLimitConfig = toml::from_str("backend = \"redis\"").unwrap();
1965        assert_eq!(config.redis.key_prefix, "autumn:rate_limit");
1966        assert!(config.redis.url.is_none());
1967    }
1968
1969    #[test]
1970    fn rate_limit_backend_from_env_value() {
1971        assert_eq!(
1972            RateLimitBackend::from_env_value("memory"),
1973            Some(RateLimitBackend::Memory)
1974        );
1975        assert_eq!(
1976            RateLimitBackend::from_env_value("redis"),
1977            Some(RateLimitBackend::Redis)
1978        );
1979        assert_eq!(
1980            RateLimitBackend::from_env_value("REDIS"),
1981            Some(RateLimitBackend::Redis)
1982        );
1983        assert_eq!(RateLimitBackend::from_env_value("postgres"), None);
1984        assert_eq!(RateLimitBackend::from_env_value(""), None);
1985    }
1986
1987    #[cfg(feature = "redis")] // RateLimitBackendFailure is redis-gated
1988    #[test]
1989    fn rate_limit_backend_failure_from_env_value() {
1990        assert_eq!(
1991            RateLimitBackendFailure::from_env_value("fail_open"),
1992            Some(RateLimitBackendFailure::FailOpen)
1993        );
1994        assert_eq!(
1995            RateLimitBackendFailure::from_env_value("open"),
1996            Some(RateLimitBackendFailure::FailOpen)
1997        );
1998        assert_eq!(
1999            RateLimitBackendFailure::from_env_value("FAIL_OPEN"),
2000            Some(RateLimitBackendFailure::FailOpen)
2001        );
2002        assert_eq!(
2003            RateLimitBackendFailure::from_env_value("fail_closed"),
2004            Some(RateLimitBackendFailure::FailClosed)
2005        );
2006        assert_eq!(
2007            RateLimitBackendFailure::from_env_value("closed"),
2008            Some(RateLimitBackendFailure::FailClosed)
2009        );
2010        assert_eq!(RateLimitBackendFailure::from_env_value("panic"), None);
2011        assert_eq!(RateLimitBackendFailure::from_env_value(""), None);
2012    }
2013
2014    #[test]
2015    fn rate_limit_config_deserialize() {
2016        let toml_str = r#"
2017            enabled = true
2018            requests_per_second = 5.0
2019            burst = 100
2020            trust_forwarded_headers = true
2021            trusted_proxies = ["10.0.0.10", "203.0.113.0/24"]
2022        "#;
2023        let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
2024        assert!(config.enabled);
2025        assert!((config.requests_per_second - 5.0).abs() < f64::EPSILON);
2026        assert_eq!(config.burst, 100);
2027        assert!(config.trust_forwarded_headers);
2028        assert_eq!(config.trusted_proxies, vec!["10.0.0.10", "203.0.113.0/24"]);
2029    }
2030
2031    #[test]
2032    fn rate_limit_config_partial_deserialize_uses_defaults() {
2033        let toml_str = "enabled = true";
2034        let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
2035        assert!(config.enabled);
2036        assert!((config.requests_per_second - 10.0).abs() < f64::EPSILON);
2037        assert_eq!(config.burst, 20);
2038        assert!(!config.trust_forwarded_headers);
2039        assert!(config.trusted_proxies.is_empty());
2040    }
2041
2042    #[test]
2043    fn rate_limit_named_key_accepts_short_macro_spellings() {
2044        // `#[throttle(key = "principal")]` / `key = "token"` use the SHORT macro
2045        // spellings; an operator moving an inline policy into config must be able
2046        // to use the same words. Serde aliases bridge macro and config vocabularies.
2047        let toml_str = r#"
2048            [named.login]
2049            limit = 5
2050            per = "1m"
2051            key = "principal"
2052
2053            [named.api]
2054            limit = 10
2055            per = "1s"
2056            key = "token"
2057        "#;
2058        let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
2059        assert_eq!(
2060            config.named["login"].key,
2061            Some(KeyStrategy::AuthenticatedPrincipal),
2062            "short `principal` must deserialize to AuthenticatedPrincipal"
2063        );
2064        assert_eq!(
2065            config.named["api"].key,
2066            Some(KeyStrategy::ApiToken),
2067            "short `token` must deserialize to ApiToken"
2068        );
2069    }
2070
2071    #[test]
2072    fn rate_limit_named_key_still_accepts_long_config_spellings() {
2073        // The canonical long spellings must keep working unchanged.
2074        let toml_str = r#"
2075            [named.login]
2076            limit = 5
2077            per = "1m"
2078            key = "authenticated_principal"
2079
2080            [named.api]
2081            limit = 10
2082            per = "1s"
2083            key = "api_token"
2084
2085            [named.byip]
2086            limit = 1
2087            per = "1s"
2088            key = "ip"
2089        "#;
2090        let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
2091        assert_eq!(
2092            config.named["login"].key,
2093            Some(KeyStrategy::AuthenticatedPrincipal)
2094        );
2095        assert_eq!(config.named["api"].key, Some(KeyStrategy::ApiToken));
2096        assert_eq!(config.named["byip"].key, Some(KeyStrategy::Ip));
2097    }
2098
2099    #[test]
2100    fn global_key_strategy_still_accepts_existing_spellings() {
2101        // The aliases live on the shared `KeyStrategy` enum, so the global
2102        // `key_strategy` field also accepts them — but its existing canonical
2103        // spellings must remain valid.
2104        for (toml_str, expected) in [
2105            (
2106                "key_strategy = \"authenticated_principal\"",
2107                KeyStrategy::AuthenticatedPrincipal,
2108            ),
2109            ("key_strategy = \"api_token\"", KeyStrategy::ApiToken),
2110            ("key_strategy = \"ip\"", KeyStrategy::Ip),
2111            // Aliases also work here (a beneficial side effect).
2112            (
2113                "key_strategy = \"principal\"",
2114                KeyStrategy::AuthenticatedPrincipal,
2115            ),
2116            ("key_strategy = \"token\"", KeyStrategy::ApiToken),
2117        ] {
2118            let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
2119            assert_eq!(config.key_strategy, expected, "for {toml_str}");
2120        }
2121    }
2122
2123    #[test]
2124    fn upload_config_defaults() {
2125        let config = UploadConfig::default();
2126        assert_eq!(config.max_request_size_bytes, 32 * 1024 * 1024);
2127        assert_eq!(config.max_file_size_bytes, 16 * 1024 * 1024);
2128        assert!(config.allowed_mime_types.is_empty());
2129    }
2130
2131    #[test]
2132    fn upload_config_deserialize() {
2133        let toml_str = r#"
2134            max_request_size_bytes = 1024
2135            max_file_size_bytes = 256
2136            allowed_mime_types = ["image/png", "image/jpeg"]
2137        "#;
2138        let config: UploadConfig = toml::from_str(toml_str).unwrap();
2139        assert_eq!(config.max_request_size_bytes, 1024);
2140        assert_eq!(config.max_file_size_bytes, 256);
2141        assert_eq!(config.allowed_mime_types.len(), 2);
2142    }
2143
2144    #[test]
2145    fn full_security_config_deserialize() {
2146        let toml_str = r#"
2147            [headers]
2148            x_frame_options = "DENY"
2149            strict_transport_security = true
2150
2151            [csrf]
2152            enabled = true
2153
2154            [rate_limit]
2155            enabled = true
2156            requests_per_second = 50.0
2157            burst = 100
2158
2159            [upload]
2160            max_request_size_bytes = 4096
2161            max_file_size_bytes = 1024
2162            allowed_mime_types = ["text/plain"]
2163        "#;
2164        let config: SecurityConfig = toml::from_str(toml_str).unwrap();
2165        assert_eq!(config.headers.x_frame_options, "DENY");
2166        assert!(config.headers.strict_transport_security);
2167        assert!(config.csrf.enabled);
2168        assert!(config.rate_limit.enabled);
2169        assert!((config.rate_limit.requests_per_second - 50.0).abs() < f64::EPSILON);
2170        assert_eq!(config.rate_limit.burst, 100);
2171        assert_eq!(config.upload.max_request_size_bytes, 4096);
2172        assert_eq!(config.upload.max_file_size_bytes, 1024);
2173        assert_eq!(config.upload.allowed_mime_types, vec!["text/plain"]);
2174    }
2175
2176    // ── ResolvedSigningKeys + resolve_signing_keys (RED phase) ─────────────
2177
2178    #[test]
2179    fn resolve_signing_keys_dev_generates_non_empty_ephemeral() {
2180        let config = SigningSecretConfig::default();
2181        let keys = resolve_signing_keys(&config);
2182        assert!(keys.current.len() >= MIN_SECRET_LEN);
2183    }
2184
2185    #[test]
2186    fn resolve_signing_keys_prod_uses_secret_bytes() {
2187        let secret = "a".repeat(MIN_SECRET_LEN);
2188        let config = SigningSecretConfig {
2189            secret: Some(secret.clone()),
2190            previous_secrets: vec![],
2191        };
2192        let keys = resolve_signing_keys(&config);
2193        assert_eq!(keys.current.as_ref(), secret.as_bytes());
2194    }
2195
2196    #[test]
2197    fn resolve_signing_keys_includes_previous_secrets() {
2198        let config = SigningSecretConfig {
2199            secret: Some("a".repeat(MIN_SECRET_LEN)),
2200            previous_secrets: vec!["b".repeat(MIN_SECRET_LEN)],
2201        };
2202        let keys = resolve_signing_keys(&config);
2203        assert_eq!(keys.previous.len(), 1);
2204        assert_eq!(
2205            keys.previous[0].as_ref(),
2206            "b".repeat(MIN_SECRET_LEN).as_bytes()
2207        );
2208    }
2209
2210    #[test]
2211    fn resolved_keys_sign_and_verify_current() {
2212        let keys = ResolvedSigningKeys::new(b"current-key-32-bytes-xxxxxxxxxx".to_vec(), vec![]);
2213        let sig = keys.sign(b"test-message");
2214        assert!(keys.verify(b"test-message", &sig));
2215    }
2216
2217    #[test]
2218    fn resolved_keys_verify_rejects_wrong_message() {
2219        let keys = ResolvedSigningKeys::new(b"current-key-32-bytes-xxxxxxxxxx".to_vec(), vec![]);
2220        let sig = keys.sign(b"message-a");
2221        assert!(!keys.verify(b"message-b", &sig));
2222    }
2223
2224    #[test]
2225    fn resolved_keys_verify_previous_key_passes() {
2226        let old_key = b"old-key-32-bytes-xxxxxxxxxxxx!x".to_vec();
2227        let new_key = b"new-key-32-bytes-xxxxxxxxxxxx!x".to_vec();
2228        let old_keys = ResolvedSigningKeys::new(old_key.clone(), vec![]);
2229        let old_sig = old_keys.sign(b"session-id");
2230        let new_keys = ResolvedSigningKeys::new(new_key, vec![old_key]);
2231        assert!(new_keys.verify(b"session-id", &old_sig));
2232    }
2233
2234    #[test]
2235    fn resolved_keys_verify_wrong_key_fails() {
2236        let keys_a = ResolvedSigningKeys::new(b"key-a-32-bytes-xxxxxxxxxxxxxxxx".to_vec(), vec![]);
2237        let keys_b = ResolvedSigningKeys::new(b"key-b-32-bytes-xxxxxxxxxxxxxxxx".to_vec(), vec![]);
2238        let sig = keys_a.sign(b"message");
2239        assert!(!keys_b.verify(b"message", &sig));
2240    }
2241
2242    #[test]
2243    fn resolved_keys_sign_produces_64_char_hex() {
2244        let keys = ResolvedSigningKeys::new(b"key".to_vec(), vec![]);
2245        let sig = keys.sign(b"msg");
2246        assert_eq!(sig.len(), 64, "HMAC-SHA256 hex is 64 chars");
2247        assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
2248    }
2249
2250    // ── CspNonceConfig (RED phase) ────────────────────────────────────────────
2251
2252    #[test]
2253    fn csp_nonce_config_defaults_to_disabled() {
2254        let config = CspNonceConfig::default();
2255        assert!(!config.enabled);
2256    }
2257
2258    #[test]
2259    fn headers_config_csp_nonce_defaults_to_disabled() {
2260        let config = HeadersConfig::default();
2261        assert!(!config.csp_nonce.enabled);
2262    }
2263
2264    #[test]
2265    fn csp_nonce_config_can_be_enabled_via_toml() {
2266        let toml_str = r"
2267            [csp_nonce]
2268            enabled = true
2269        ";
2270        let config: HeadersConfig = toml::from_str(toml_str).unwrap();
2271        assert!(config.csp_nonce.enabled);
2272    }
2273
2274    #[test]
2275    fn csp_nonce_config_deserialize_standalone() {
2276        let config: CspNonceConfig = toml::from_str("enabled = true").unwrap();
2277        assert!(config.enabled);
2278    }
2279
2280    #[test]
2281    fn csp_nonce_config_disabled_by_default_in_standalone() {
2282        let config: CspNonceConfig = toml::from_str("").unwrap();
2283        assert!(!config.enabled);
2284    }
2285
2286    // ── TrustedProxiesConfig & conflict detection ─────────────────────────────
2287
2288    #[test]
2289    fn trusted_proxies_config_parses_from_toml() {
2290        let toml = r#"
2291[trusted_proxies]
2292ranges = ["10.0.0.0/8", "203.0.113.0/24"]
2293trusted_hops = 2
2294trust_forwarded_headers = true
2295"#;
2296        let config: SecurityConfig = toml::from_str(toml).unwrap();
2297        assert_eq!(config.trusted_proxies.ranges.len(), 2);
2298        assert_eq!(config.trusted_proxies.trusted_hops, Some(2));
2299        assert!(config.trusted_proxies.trust_forwarded_headers);
2300    }
2301
2302    #[test]
2303    fn trusted_proxies_config_defaults_to_no_trust() {
2304        let config: SecurityConfig = toml::from_str("").unwrap();
2305        assert!(config.trusted_proxies.ranges.is_empty());
2306        assert!(config.trusted_proxies.trusted_hops.is_none());
2307        assert!(!config.trusted_proxies.trust_forwarded_headers);
2308    }
2309
2310    #[test]
2311    fn trusted_proxies_conflict_detected_when_both_set_with_different_values() {
2312        let toml = r#"
2313[trusted_proxies]
2314ranges = ["10.0.0.0/8"]
2315trust_forwarded_headers = true
2316
2317[rate_limit]
2318trusted_proxies = ["192.168.0.0/16"]
2319trust_forwarded_headers = true
2320"#;
2321        let config: SecurityConfig = toml::from_str(toml).unwrap();
2322        assert!(
2323            config.trusted_proxies_conflict().is_some(),
2324            "conflicting proxy configs must be detected"
2325        );
2326    }
2327
2328    #[test]
2329    fn trusted_proxies_no_conflict_when_only_new_set() {
2330        let toml = r#"
2331[trusted_proxies]
2332ranges = ["10.0.0.0/8"]
2333trust_forwarded_headers = true
2334"#;
2335        let config: SecurityConfig = toml::from_str(toml).unwrap();
2336        assert!(config.trusted_proxies_conflict().is_none());
2337    }
2338
2339    #[test]
2340    fn trusted_proxies_no_conflict_when_only_old_set() {
2341        let toml = r#"
2342[rate_limit]
2343trusted_proxies = ["10.0.0.0/8"]
2344trust_forwarded_headers = true
2345"#;
2346        let config: SecurityConfig = toml::from_str(toml).unwrap();
2347        assert!(config.trusted_proxies_conflict().is_none());
2348    }
2349
2350    #[test]
2351    fn trusted_proxies_no_conflict_when_same_values_in_both() {
2352        let toml = r#"
2353[trusted_proxies]
2354ranges = ["10.0.0.0/8"]
2355trust_forwarded_headers = true
2356
2357[rate_limit]
2358trusted_proxies = ["10.0.0.0/8"]
2359trust_forwarded_headers = true
2360"#;
2361        let config: SecurityConfig = toml::from_str(toml).unwrap();
2362        // Same values — no conflict (though old fields still warn at startup).
2363        assert!(config.trusted_proxies_conflict().is_none());
2364    }
2365}