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