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