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(¤t);
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}