Skip to main content

autumn_web/security/
config.rs

1//! Security configuration for Autumn applications.
2//!
3//! Controls security headers and CSRF protection. All settings have
4//! sensible defaults and are profile-aware:
5//!
6//! - **`dev`**: Relaxed -- CSRF disabled, HSTS off, permissive headers.
7//! - **`prod`**: Strict -- CSRF enabled, HSTS on, all protective headers active.
8//!
9//! Session and authentication configuration live in their own modules
10//! ([`crate::session::SessionConfig`], [`crate::auth::AuthConfig`]).
11//!
12//! # `autumn.toml` example
13//!
14//! ```toml
15//! [security.headers]
16//! x_frame_options = "DENY"
17//! content_security_policy = "default-src 'self'"
18//!
19//! [security.csrf]
20//! enabled = true
21//!
22//! [security.rate_limit]
23//! enabled = true
24//! requests_per_second = 10.0
25//! burst = 20
26//! ```
27//!
28//! # Environment variable reference
29//!
30//! | Variable | Config field | Type |
31//! |----------|-------------|------|
32//! | `AUTUMN_SECURITY__HEADERS__X_FRAME_OPTIONS` | `security.headers.x_frame_options` | `String` |
33//! | `AUTUMN_SECURITY__HEADERS__HSTS_MAX_AGE_SECS` | `security.headers.hsts_max_age_secs` | `u64` |
34//! | `AUTUMN_SECURITY__HEADERS__CONTENT_SECURITY_POLICY` | `security.headers.content_security_policy` | `String` |
35//! | `AUTUMN_SECURITY__CSRF__ENABLED` | `security.csrf.enabled` | `bool` |
36//! | `AUTUMN_SECURITY__RATE_LIMIT__ENABLED` | `security.rate_limit.enabled` | `bool` |
37//! | `AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND` | `security.rate_limit.requests_per_second` | `f64` |
38//! | `AUTUMN_SECURITY__RATE_LIMIT__BURST` | `security.rate_limit.burst` | `u32` |
39//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS` | `security.rate_limit.trust_forwarded_headers` | `bool` |
40//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES` | `security.rate_limit.trusted_proxies` | comma-separated `String` |
41//! | `AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES` | `security.upload.max_request_size_bytes` | `usize` |
42//! | `AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES` | `security.upload.max_file_size_bytes` | `usize` |
43//! | `AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES` | `security.upload.allowed_mime_types` | comma-separated `String` |
44//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND` | `security.webhooks.replay.backend` | `memory` / `redis` |
45//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL` | `security.webhooks.replay.redis.url` | `String` |
46//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX` | `security.webhooks.replay.redis.key_prefix` | `String` |
47//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION` | `security.webhooks.replay.allow_memory_in_production` | `bool` |
48//! | per-endpoint `secret_env` | `security.webhooks.endpoints[*].secret` | environment variable name |
49//!
50//! Setting any header value to an empty string disables it (the header is
51//! not emitted). This is the escape hatch for opting out of a default.
52
53use std::sync::Arc;
54
55use serde::Deserialize;
56
57// ── Signing secret contract ────────────────────────────────────────────────
58
59/// Minimum byte length for a valid production signing secret (32 bytes / 256 bits).
60///
61/// A hex-encoded 32-byte value is 64 characters. Anything shorter is rejected
62/// at production startup.
63pub const MIN_SECRET_LEN: usize = 32;
64
65/// Known demo / template / placeholder values that must never reach production.
66const DEMO_VALUES: &[&str] = &[
67    "changeme",
68    "change_me",
69    "change-me",
70    "secret",
71    "supersecret",
72    "super-secret",
73    "super_secret",
74    "your-secret-here",
75    "your_secret_here",
76    "insert-secret-here",
77    "replace-this",
78    "replace_me",
79    "todo",
80    "fixme",
81    "example",
82    "placeholder",
83    "dev_only",
84    "dev-only",
85    "test_secret",
86    "test-secret",
87    "test",
88    "password",
89];
90
91/// Signing-secret configuration for HMAC-signed framework surfaces.
92///
93/// The signing secret is the shared key used to sign sessions, CSRF tokens,
94/// flash/signed-cookie state, and local-storage signed URLs.
95///
96/// # Development and test
97///
98/// Leave `secret` unset. An ephemeral per-process key is generated automatically.
99/// This means sessions and signed URLs do **not** survive process restarts and
100/// replicas cannot share state — acceptable in dev, unacceptable in production.
101///
102/// # Production
103///
104/// Set `secret` via the `AUTUMN_SECURITY__SIGNING_SECRET` environment variable
105/// (or `[security.signing_secret] secret` in `autumn.toml`). The secret must be:
106/// - At least [`MIN_SECRET_LEN`] bytes long.
107/// - Not a known template/demo value.
108/// - Stable across restarts and identical on every replica.
109///
110/// Generate a secret: `openssl rand -hex 32`
111///
112/// # Rotation
113///
114/// When rotating, move the current secret to `previous_secrets` and set the
115/// new value in `secret`. New signatures use `secret`; tokens signed with any
116/// entry in `previous_secrets` continue to validate during the grace window.
117/// Remove expired entries from `previous_secrets` after the maximum relevant
118/// cookie/token lifetime has elapsed.
119///
120/// # `autumn.toml` example
121///
122/// ```toml
123/// [security.signing_secret]
124/// # secret set via AUTUMN_SECURITY__SIGNING_SECRET env var (never commit this)
125///
126/// # rotation grace window — leave populated until all existing tokens expire:
127/// previous_secrets = ["oldsecretvalue..."]
128/// ```
129#[derive(Debug, Clone, Default, Deserialize)]
130pub struct SigningSecretConfig {
131    /// The current signing secret. In production, must come from an environment
132    /// variable or external secrets manager — never a committed literal.
133    pub secret: Option<String>,
134
135    /// Previous signing secrets accepted during a rotation grace window.
136    ///
137    /// New signatures always use `secret`. Tokens signed with an entry here
138    /// remain valid until removed. Remove entries after the maximum relevant
139    /// cookie/token lifetime has elapsed (e.g. `session.max_age_secs`).
140    #[serde(default)]
141    pub previous_secrets: Vec<String>,
142}
143
144/// Error returned when a signing secret fails production validation.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum SigningSecretError {
147    /// No secret is configured but the production profile requires one.
148    MissingInProduction,
149    /// The secret is too short to meet the minimum entropy requirement.
150    TooShort {
151        /// Actual byte length of the supplied secret.
152        actual: usize,
153        /// Minimum required byte length ([`MIN_SECRET_LEN`]).
154        required: usize,
155    },
156    /// The secret matches a known insecure demo or template value.
157    KnownWeakValue(String),
158}
159
160impl std::fmt::Display for SigningSecretError {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        match self {
163            Self::MissingInProduction => write!(
164                f,
165                "signing secret is required in production; set \
166                 AUTUMN_SECURITY__SIGNING_SECRET (generate with `openssl rand -hex 32`)"
167            ),
168            Self::TooShort { actual, required } => write!(
169                f,
170                "signing secret is too short ({actual} bytes, minimum {required}); \
171                 generate one with `openssl rand -hex 32`"
172            ),
173            Self::KnownWeakValue(v) => write!(
174                f,
175                "signing secret looks like a template/demo value ({v:?}); \
176                 generate one with `openssl rand -hex 32`"
177            ),
178        }
179    }
180}
181
182/// Validate a signing secret for production use.
183///
184/// In development and test the check is skipped — any value (including `None`)
185/// is accepted so zero-config local development continues to work.
186///
187/// In production:
188/// - `None` → [`SigningSecretError::MissingInProduction`]
189/// - Shorter than [`MIN_SECRET_LEN`] bytes → [`SigningSecretError::TooShort`]
190/// - Matches a known demo/template string → [`SigningSecretError::KnownWeakValue`]
191///
192/// # Errors
193///
194/// Returns [`SigningSecretError`] when production validation fails.
195pub fn validate_signing_secret(
196    secret: Option<&str>,
197    is_production: bool,
198) -> Result<(), SigningSecretError> {
199    if !is_production {
200        return Ok(());
201    }
202    let secret = secret.ok_or(SigningSecretError::MissingInProduction)?;
203    // Demo-value check first: "changeme" is more informative than "too short".
204    let lower = secret.to_ascii_lowercase();
205    for &demo in DEMO_VALUES {
206        if lower == demo {
207            return Err(SigningSecretError::KnownWeakValue(secret.to_owned()));
208        }
209    }
210    let byte_len = secret.len();
211    if byte_len < MIN_SECRET_LEN {
212        return Err(SigningSecretError::TooShort {
213            actual: byte_len,
214            required: MIN_SECRET_LEN,
215        });
216    }
217    Ok(())
218}
219
220// ── Resolved signing key material ─────────────────────────────────────────
221
222/// HMAC-SHA256 of `message` under `key`, returned as lowercase hex.
223///
224/// # Panics
225///
226/// This should not panic because HMAC accepts keys of any length. A panic would
227/// indicate a broken crypto crate invariant.
228#[must_use]
229pub fn hmac_sha256_hex(key: &[u8], message: &[u8]) -> String {
230    use hmac::{Hmac, Mac};
231    use sha2::Sha256;
232    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
233    mac.update(message);
234    let bytes = mac.finalize().into_bytes();
235    bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
236        use std::fmt::Write as _;
237        let _ = write!(acc, "{b:02x}");
238        acc
239    })
240}
241
242/// Constant-time string comparison for HMAC verification.
243fn ct_eq_str(a: &str, b: &str) -> bool {
244    use subtle::ConstantTimeEq;
245    a.as_bytes().ct_eq(b.as_bytes()).into()
246}
247
248/// Generate a random 32-byte ephemeral key from two UUID v4 values.
249fn generate_ephemeral_key() -> Vec<u8> {
250    let a = uuid::Uuid::new_v4();
251    let b = uuid::Uuid::new_v4();
252    let mut bytes = vec![0u8; 32];
253    bytes[..16].copy_from_slice(a.as_bytes());
254    bytes[16..].copy_from_slice(b.as_bytes());
255    bytes
256}
257
258/// Resolved signing keys for a running Autumn instance.
259///
260/// Created once at startup from [`SigningSecretConfig`] via [`resolve_signing_keys`]
261/// and shared via `Arc` across session, CSRF, and local storage signing.
262///
263/// - `current` signs new tokens.
264/// - `previous` are accepted during a rotation grace window.
265#[derive(Clone, Debug)]
266pub struct ResolvedSigningKeys {
267    /// Key used to sign new tokens.
268    pub current: Arc<[u8]>,
269    /// Former keys accepted during a rotation grace window. New signatures always
270    /// use `current`; tokens carrying a `previous` HMAC continue to verify until
271    /// removed (see docs/guide/signing-secrets.md).
272    pub previous: Vec<Arc<[u8]>>,
273}
274
275impl ResolvedSigningKeys {
276    /// Build from raw byte vectors.
277    pub fn new(current: Vec<u8>, previous: Vec<Vec<u8>>) -> Self {
278        Self {
279            current: current.into(),
280            previous: previous.into_iter().map(|v: Vec<u8>| v.into()).collect(),
281        }
282    }
283
284    /// HMAC-SHA256 of `message` under the current key, hex-encoded.
285    pub fn sign(&self, message: &[u8]) -> String {
286        hmac_sha256_hex(&self.current, message)
287    }
288
289    /// Returns `true` when `hex_sig` is a valid HMAC-SHA256 of `message` under
290    /// any key (current first, then previous). All comparisons are constant-time.
291    pub fn verify(&self, message: &[u8], hex_sig: &str) -> bool {
292        if ct_eq_str(&hmac_sha256_hex(&self.current, message), hex_sig) {
293            return true;
294        }
295        for prev in &self.previous {
296            if ct_eq_str(&hmac_sha256_hex(prev, message), hex_sig) {
297                return true;
298            }
299        }
300        false
301    }
302}
303
304/// Resolve signing keys from a [`SigningSecretConfig`].
305///
306/// - When `secret` is set, its bytes become the current key.
307/// - When `secret` is absent (dev/test), an ephemeral random key is generated.
308///   This means signed tokens do not survive process restarts.
309/// - `previous_secrets` are always included for rotation grace-window verification.
310///
311/// Production boot validation (requiring `secret` to be non-empty, long enough,
312/// and not a demo value) is a separate step via [`validate_signing_secret`].
313pub fn resolve_signing_keys(config: &SigningSecretConfig) -> ResolvedSigningKeys {
314    let current = config
315        .secret
316        .as_deref()
317        .map_or_else(generate_ephemeral_key, |s| s.as_bytes().to_vec());
318    let previous = config
319        .previous_secrets
320        .iter()
321        .map(|s| s.as_bytes().to_vec())
322        .collect();
323    ResolvedSigningKeys::new(current, previous)
324}
325
326/// Top-level security configuration section.
327///
328/// Groups security headers and CSRF protection under `[security]`
329/// in `autumn.toml`.
330///
331/// # Examples
332///
333/// ```rust
334/// use autumn_web::security::SecurityConfig;
335///
336/// let config = SecurityConfig::default();
337/// assert_eq!(config.headers.x_frame_options, "DENY");
338/// assert!(config.headers.x_content_type_options);
339/// assert!(!config.csrf.enabled);
340/// assert!(!config.rate_limit.enabled);
341/// ```
342#[derive(Debug, Clone, Default, Deserialize)]
343pub struct SecurityConfig {
344    /// HTTP security headers applied to all responses.
345    #[serde(default)]
346    pub headers: HeadersConfig,
347
348    /// CSRF (Cross-Site Request Forgery) protection.
349    #[serde(default)]
350    pub csrf: CsrfConfig,
351
352    /// Rate limiting (per-client-IP token bucket).
353    #[serde(default)]
354    pub rate_limit: RateLimitConfig,
355
356    /// Multipart upload safeguards and validation policy.
357    #[serde(default)]
358    pub upload: UploadConfig,
359
360    /// Signed webhook intake endpoints.
361    #[serde(default)]
362    pub webhooks: crate::webhook::WebhookConfig,
363
364    /// HTTP status returned when a [`Policy`](crate::authorization::Policy)
365    /// denies a record-level action. Defaults to `"404"` to mirror the
366    /// Rails / Phoenix posture of hiding existence from unauthorized
367    /// clients.
368    #[serde(default)]
369    pub forbidden_response: crate::authorization::ForbiddenResponse,
370
371    /// Allow `#[repository(api = "...")]` to mount auto-generated
372    /// CRUD endpoints in `prod` builds without a paired `policy =`
373    /// argument.
374    ///
375    /// Default: `false`. The framework refuses to start when an
376    /// `api =` repository has no `policy =` because the auto-
377    /// generated endpoints would be reachable by any authenticated
378    /// user. Flip this to `true` only when the lack of authz is
379    /// genuinely intended (e.g. a fully-public read-only API).
380    #[serde(default)]
381    pub allow_unauthorized_repository_api: bool,
382
383    /// Signing-secret configuration for HMAC-signed framework surfaces.
384    ///
385    /// Covers sessions, CSRF tokens, flash/signed-cookie state, and
386    /// local-storage signed URLs. In dev the framework generates an
387    /// ephemeral per-process key; production MUST set a stable, private
388    /// secret via `AUTUMN_SECURITY__SIGNING_SECRET`.
389    #[serde(default)]
390    pub signing_secret: SigningSecretConfig,
391}
392
393/// Security response headers configuration.
394///
395/// Controls which protective HTTP headers are added to every response.
396/// Follows OWASP security header recommendations.
397///
398/// # Defaults
399///
400/// | Field | Default |
401/// |-------|---------|
402/// | `x_frame_options` | `"DENY"` |
403/// | `x_content_type_options` | `true` |
404/// | `xss_protection` | `true` |
405/// | `strict_transport_security` | `false` |
406/// | `hsts_max_age_secs` | `31_536_000` (1 year) |
407/// | `hsts_include_subdomains` | `true` |
408/// | `content_security_policy` | htmx-compatible policy (see [`default_content_security_policy`]) |
409/// | `referrer_policy` | `"strict-origin-when-cross-origin"` |
410/// | `permissions_policy` | `""` (disabled) |
411///
412/// # Examples
413///
414/// ```toml
415/// [security.headers]
416/// x_frame_options = "SAMEORIGIN"
417/// content_security_policy = "default-src 'self'; script-src 'self'"
418/// strict_transport_security = true
419/// ```
420#[derive(Debug, Clone, Deserialize)]
421#[allow(clippy::struct_excessive_bools)]
422pub struct HeadersConfig {
423    /// `X-Frame-Options` header value. Default: `"DENY"`.
424    ///
425    /// Prevents the page from being loaded in an iframe. Common values:
426    /// - `"DENY"` -- never allow framing
427    /// - `"SAMEORIGIN"` -- allow framing by same origin
428    /// - `""` -- do not send the header
429    #[serde(default = "default_x_frame_options")]
430    pub x_frame_options: String,
431
432    /// Add `X-Content-Type-Options: nosniff`. Default: `true`.
433    ///
434    /// Prevents MIME-type sniffing attacks.
435    #[serde(default = "default_true")]
436    pub x_content_type_options: bool,
437
438    /// Add `X-XSS-Protection: 1; mode=block`. Default: `true`.
439    ///
440    /// Enables the browser's built-in XSS filter (legacy but still useful).
441    #[serde(default = "default_true")]
442    pub xss_protection: bool,
443
444    /// Add `Strict-Transport-Security` (HSTS) header. Default: `false`.
445    ///
446    /// When `true`, tells browsers to only connect via HTTPS. Enabled
447    /// automatically for `prod` profile via smart defaults.
448    #[serde(default)]
449    pub strict_transport_security: bool,
450
451    /// HSTS `max-age` in seconds. Default: `31_536_000` (1 year).
452    ///
453    /// Only used when `strict_transport_security` is `true`.
454    #[serde(default = "default_hsts_max_age")]
455    pub hsts_max_age_secs: u64,
456
457    /// Include subdomains in HSTS policy. Default: `true`.
458    #[serde(default = "default_true")]
459    pub hsts_include_subdomains: bool,
460
461    /// `Content-Security-Policy` header value.
462    ///
463    /// Defaults to an htmx-compatible, same-origin policy (see
464    /// [`default_content_security_policy`]). When set to an empty string,
465    /// the header is not emitted (explicit opt-out).
466    ///
467    /// The default allows htmx to function normally because htmx and Autumn's
468    /// htmx CSRF helper are served from the same origin and operate via
469    /// `addEventListener` rather than inline scripts.
470    #[serde(default = "default_content_security_policy")]
471    pub content_security_policy: String,
472
473    /// `Referrer-Policy` header value. Default: `"strict-origin-when-cross-origin"`.
474    #[serde(default = "default_referrer_policy")]
475    pub referrer_policy: String,
476
477    /// `Permissions-Policy` header value. Default: `""` (not sent).
478    ///
479    /// Controls which browser features and APIs can be used.
480    /// Example: `"camera=(), microphone=(), geolocation=()"`.
481    #[serde(default)]
482    pub permissions_policy: String,
483}
484
485impl Default for HeadersConfig {
486    fn default() -> Self {
487        Self {
488            x_frame_options: default_x_frame_options(),
489            x_content_type_options: true,
490            xss_protection: true,
491            strict_transport_security: false,
492            hsts_max_age_secs: default_hsts_max_age(),
493            hsts_include_subdomains: true,
494            content_security_policy: default_content_security_policy(),
495            referrer_policy: default_referrer_policy(),
496            permissions_policy: String::new(),
497        }
498    }
499}
500
501/// CSRF (Cross-Site Request Forgery) protection configuration.
502///
503/// When enabled, mutating requests (POST, PUT, DELETE, PATCH) must include
504/// a valid CSRF token either as:
505///
506/// - An HTTP header (default: `X-CSRF-Token`)
507/// - A form field (default: `_csrf`)
508///
509/// The token is generated per-session and stored in a cookie.
510///
511/// # Defaults
512///
513/// | Field | Default |
514/// |-------|---------|
515/// | `enabled` | `false` |
516/// | `token_header` | `"X-CSRF-Token"` |
517/// | `form_field` | `"_csrf"` |
518/// | `cookie_name` | `"autumn-csrf"` |
519/// | `safe_methods` | `["GET", "HEAD", "OPTIONS", "TRACE"]` |
520/// | `exempt_paths` | `[]` |
521///
522/// # Examples
523///
524/// ```toml
525/// [security.csrf]
526/// enabled = true
527/// token_header = "X-XSRF-Token"
528/// cookie_name = "XSRF-TOKEN"
529/// exempt_paths = ["/api/"]
530/// ```
531#[derive(Debug, Clone, Deserialize)]
532pub struct CsrfConfig {
533    /// Enable CSRF protection. Default: `false`.
534    ///
535    /// Enabled automatically for `prod` profile via smart defaults.
536    #[serde(default)]
537    pub enabled: bool,
538
539    /// HTTP header name for the CSRF token. Default: `"X-CSRF-Token"`.
540    #[serde(default = "default_csrf_header")]
541    pub token_header: String,
542
543    /// Form field name for the CSRF token. Default: `"_csrf"`.
544    #[serde(default = "default_csrf_field")]
545    pub form_field: String,
546
547    /// Cookie name for storing the CSRF token. Default: `"autumn-csrf"`.
548    #[serde(default = "default_csrf_cookie")]
549    pub cookie_name: String,
550
551    /// HTTP methods that do NOT require CSRF validation.
552    /// Default: `["GET", "HEAD", "OPTIONS", "TRACE"]`.
553    #[serde(default = "default_safe_methods")]
554    pub safe_methods: Vec<String>,
555
556    /// Request path prefixes that are exempt from CSRF validation.
557    /// Default: `[]`.
558    ///
559    /// Use this to opt JSON API routes out of CSRF when they authenticate
560    /// with bearer tokens or other non-cookie credentials. Matches are by
561    /// prefix on the request path, e.g. `"/api/"` exempts all routes
562    /// under `/api/`.
563    #[serde(default)]
564    pub exempt_paths: Vec<String>,
565}
566
567impl Default for CsrfConfig {
568    fn default() -> Self {
569        Self {
570            enabled: false,
571            token_header: default_csrf_header(),
572            form_field: default_csrf_field(),
573            cookie_name: default_csrf_cookie(),
574            safe_methods: default_safe_methods(),
575            exempt_paths: Vec::new(),
576        }
577    }
578}
579
580/// Rate limiting configuration.
581///
582/// Applies a per-client-IP token bucket to every request. When a client
583/// exceeds their bucket, the middleware returns `429 Too Many Requests`
584/// with a `Retry-After` header indicating when to retry.
585///
586/// # Defaults
587///
588/// | Field | Default |
589/// |-------|---------|
590/// | `enabled` | `false` |
591/// | `requests_per_second` | `10.0` |
592/// | `burst` | `20` |
593/// | `trust_forwarded_headers` | `false` |
594/// | `trusted_proxies` | `[]` |
595///
596/// # Client IP resolution
597///
598/// By default the limiter keys on the **connection peer address**. This
599/// prevents clients from bypassing throttling by rotating `X-Forwarded-For`
600/// values. Set `trust_forwarded_headers = true` only when the server
601/// sits behind a trusted reverse proxy that strips and rewrites
602/// forwarding headers on every request.
603///
604/// If trusted upstream proxies append to `X-Forwarded-For`, configure
605/// `trusted_proxies` with the trusted proxy IPs or CIDR ranges. Autumn
606/// then walks the header from right to left, skips those trusted proxy
607/// hops, and keys the bucket on the nearest untrusted client IP.
608///
609/// # Examples
610///
611/// ```toml
612/// [security.rate_limit]
613/// enabled = true
614/// requests_per_second = 5.0
615/// burst = 10
616/// trust_forwarded_headers = false
617/// trusted_proxies = ["10.0.0.10", "203.0.113.0/24"]
618/// ```
619#[derive(Debug, Clone, Deserialize)]
620pub struct RateLimitConfig {
621    /// Enable rate limiting. Default: `false`.
622    #[serde(default)]
623    pub enabled: bool,
624
625    /// Steady-state refill rate in requests per second. Default: `10.0`.
626    #[serde(default = "default_rps")]
627    pub requests_per_second: f64,
628
629    /// Maximum burst capacity (number of tokens the bucket can hold).
630    /// Default: `20`.
631    #[serde(default = "default_burst")]
632    pub burst: u32,
633
634    /// Consult `X-Forwarded-For` / `X-Real-IP` before the connection peer
635    /// when identifying the client. Default: `false`.
636    ///
637    /// Enable ONLY when the server is behind a trusted reverse proxy that
638    /// fully overrides these headers on every request. Otherwise a client
639    /// can rotate header values to bypass throttling.
640    #[serde(default)]
641    pub trust_forwarded_headers: bool,
642
643    /// Trusted proxy IP addresses or CIDR ranges to skip at the right
644    /// side of an appended `X-Forwarded-For` chain.
645    ///
646    /// This is only used when `trust_forwarded_headers = true`. Include
647    /// the immediate peer proxy when `ConnectInfo` is available; forwarded
648    /// headers from non-trusted peers, or requests without peer metadata,
649    /// are ignored. Invalid entries are ignored; if every configured
650    /// entry is invalid, forwarded headers are ignored rather than trusted.
651    #[serde(default)]
652    pub trusted_proxies: Vec<String>,
653}
654
655impl Default for RateLimitConfig {
656    fn default() -> Self {
657        Self {
658            enabled: false,
659            requests_per_second: default_rps(),
660            burst: default_burst(),
661            trust_forwarded_headers: false,
662            trusted_proxies: Vec::new(),
663        }
664    }
665}
666
667/// Multipart upload configuration.
668///
669/// Applies framework-level guardrails for `multipart/form-data` requests:
670///
671/// - `max_request_size_bytes`: global request body cap (enforced by middleware)
672/// - `max_file_size_bytes`: per-file cap for `crate::extract::Multipart` helpers
673/// - `allowed_mime_types`: optional MIME-type allow list for uploaded parts
674///
675/// Leave `allowed_mime_types` empty to allow any content type.
676#[derive(Debug, Clone, Deserialize)]
677pub struct UploadConfig {
678    /// Maximum total multipart request body size in bytes.
679    #[serde(default = "default_max_request_size_bytes")]
680    pub max_request_size_bytes: usize,
681    /// Maximum individual uploaded file size in bytes.
682    #[serde(default = "default_max_file_size_bytes")]
683    pub max_file_size_bytes: usize,
684    /// Optional allowed MIME types (e.g. `["image/png", "image/jpeg"]`).
685    #[serde(default)]
686    pub allowed_mime_types: Vec<String>,
687}
688
689impl Default for UploadConfig {
690    fn default() -> Self {
691        Self {
692            max_request_size_bytes: default_max_request_size_bytes(),
693            max_file_size_bytes: default_max_file_size_bytes(),
694            allowed_mime_types: Vec::new(),
695        }
696    }
697}
698
699// ── Default value functions ────────────────────────────────────────
700
701const fn default_true() -> bool {
702    true
703}
704
705fn default_x_frame_options() -> String {
706    "DENY".to_owned()
707}
708
709const fn default_hsts_max_age() -> u64 {
710    31_536_000 // 1 year
711}
712
713fn default_referrer_policy() -> String {
714    "strict-origin-when-cross-origin".to_owned()
715}
716
717/// Default `Content-Security-Policy` value.
718///
719/// Designed to be "sensible by default" while allowing htmx to function
720/// normally when served from the same origin (as Autumn does for htmx and its
721/// CSRF helper under `/static/js/`).
722///
723/// Directives:
724/// - `default-src 'self'` -- everything defaults to same-origin
725/// - `img-src 'self' data:` -- images from self and inline data URIs
726/// - `style-src 'self' 'unsafe-inline'` -- same-origin stylesheets plus
727///   inline `style` attributes (required by many UI libraries and
728///   template engines)
729/// - `script-src 'self'` -- only same-origin scripts; htmx and Autumn's htmx
730///   CSRF helper work here because they are served from `/static/js/`
731/// - `connect-src 'self'` -- `fetch`/`XHR`/htmx requests go to same origin
732/// - `form-action 'self'` -- forms can only POST to same origin
733/// - `frame-ancestors 'none'` -- matches the default `X-Frame-Options: DENY`
734/// - `base-uri 'self'` -- prevents `<base>` hijacking
735#[must_use]
736pub fn default_content_security_policy() -> String {
737    "default-src 'self'; \
738     img-src 'self' data:; \
739     style-src 'self' 'unsafe-inline'; \
740     script-src 'self'; \
741     connect-src 'self'; \
742     form-action 'self'; \
743     frame-ancestors 'none'; \
744     base-uri 'self'"
745        .to_owned()
746}
747
748fn default_csrf_header() -> String {
749    "X-CSRF-Token".to_owned()
750}
751
752fn default_csrf_field() -> String {
753    "_csrf".to_owned()
754}
755
756fn default_csrf_cookie() -> String {
757    "autumn-csrf".to_owned()
758}
759
760fn default_safe_methods() -> Vec<String> {
761    vec![
762        "GET".to_owned(),
763        "HEAD".to_owned(),
764        "OPTIONS".to_owned(),
765        "TRACE".to_owned(),
766    ]
767}
768
769const fn default_rps() -> f64 {
770    10.0
771}
772
773const fn default_burst() -> u32 {
774    20
775}
776
777const fn default_max_request_size_bytes() -> usize {
778    32 * 1024 * 1024
779}
780
781const fn default_max_file_size_bytes() -> usize {
782    16 * 1024 * 1024
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788
789    // ── validate_signing_secret (RED phase) ─────────────────────────────────
790
791    #[test]
792    fn signing_secret_dev_skips_validation_with_none() {
793        assert!(validate_signing_secret(None, false).is_ok());
794    }
795
796    #[test]
797    fn signing_secret_dev_skips_validation_with_weak_value() {
798        assert!(validate_signing_secret(Some("changeme"), false).is_ok());
799    }
800
801    #[test]
802    fn signing_secret_dev_skips_validation_with_short_value() {
803        assert!(validate_signing_secret(Some("short"), false).is_ok());
804    }
805
806    #[test]
807    fn signing_secret_prod_missing_is_error() {
808        let err = validate_signing_secret(None, true).unwrap_err();
809        assert!(matches!(err, SigningSecretError::MissingInProduction));
810    }
811
812    #[test]
813    fn signing_secret_prod_too_short_is_error() {
814        let short = "a".repeat(MIN_SECRET_LEN - 1);
815        let err = validate_signing_secret(Some(&short), true).unwrap_err();
816        assert!(matches!(err, SigningSecretError::TooShort { .. }));
817    }
818
819    #[test]
820    fn signing_secret_prod_exact_min_length_passes() {
821        let exactly_min = "a".repeat(MIN_SECRET_LEN);
822        assert!(validate_signing_secret(Some(&exactly_min), true).is_ok());
823    }
824
825    #[test]
826    fn signing_secret_prod_known_demo_value_is_error() {
827        let err = validate_signing_secret(Some("changeme"), true).unwrap_err();
828        assert!(matches!(err, SigningSecretError::KnownWeakValue(_)));
829    }
830
831    #[test]
832    fn signing_secret_prod_demo_value_case_insensitive() {
833        let err = validate_signing_secret(Some("CHANGEME"), true).unwrap_err();
834        assert!(matches!(err, SigningSecretError::KnownWeakValue(_)));
835    }
836
837    #[test]
838    fn signing_secret_prod_valid_64char_hex_passes() {
839        let secret = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
840        assert!(validate_signing_secret(Some(secret), true).is_ok());
841    }
842
843    #[test]
844    fn signing_secret_config_defaults_to_none() {
845        let config = SigningSecretConfig::default();
846        assert!(config.secret.is_none());
847        assert!(config.previous_secrets.is_empty());
848    }
849
850    #[test]
851    fn signing_secret_error_missing_display_mentions_env_var() {
852        let err = SigningSecretError::MissingInProduction;
853        assert!(err.to_string().contains("AUTUMN_SECURITY__SIGNING_SECRET"));
854    }
855
856    #[test]
857    fn signing_secret_error_too_short_display_shows_lengths() {
858        let err = SigningSecretError::TooShort {
859            actual: 8,
860            required: 32,
861        };
862        let s = err.to_string();
863        assert!(s.contains('8'));
864        assert!(s.contains("32"));
865    }
866
867    #[test]
868    fn signing_secret_error_weak_value_display_mentions_demo() {
869        let err = SigningSecretError::KnownWeakValue("changeme".to_owned());
870        assert!(err.to_string().contains("template/demo"));
871    }
872
873    #[test]
874    fn signing_secret_prod_too_short_error_reports_actual_length() {
875        let short = "tooshort"; // 8 bytes
876        let err = validate_signing_secret(Some(short), true).unwrap_err();
877        if let SigningSecretError::TooShort { actual, required } = err {
878            assert_eq!(actual, 8);
879            assert_eq!(required, MIN_SECRET_LEN);
880        } else {
881            panic!("expected TooShort error");
882        }
883    }
884
885    #[test]
886    fn signing_secret_prod_secret_key_demo_value_fails() {
887        assert!(matches!(
888            validate_signing_secret(Some("secret"), true),
889            Err(SigningSecretError::KnownWeakValue(_))
890        ));
891    }
892
893    #[test]
894    fn signing_secret_prod_supersecret_demo_value_fails() {
895        assert!(matches!(
896            validate_signing_secret(Some("supersecret"), true),
897            Err(SigningSecretError::KnownWeakValue(_))
898        ));
899    }
900
901    #[test]
902    fn signing_secret_config_deserialize_from_toml() {
903        let toml_str = r#"
904            secret = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
905            previous_secrets = ["oldsecret01234567890123456789012"]
906        "#;
907        let config: SigningSecretConfig = toml::from_str(toml_str).unwrap();
908        assert_eq!(
909            config.secret.as_deref(),
910            Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
911        );
912        assert_eq!(config.previous_secrets.len(), 1);
913    }
914
915    #[test]
916    fn security_config_defaults() {
917        let config = SecurityConfig::default();
918        assert_eq!(config.headers.x_frame_options, "DENY");
919        assert!(config.headers.x_content_type_options);
920        assert!(config.headers.xss_protection);
921        assert!(!config.headers.strict_transport_security);
922        assert_eq!(config.headers.hsts_max_age_secs, 31_536_000);
923        // Default CSP is non-empty and htmx-compatible.
924        assert!(!config.headers.content_security_policy.is_empty());
925        assert!(
926            config
927                .headers
928                .content_security_policy
929                .contains("default-src 'self'")
930        );
931        assert!(
932            config
933                .headers
934                .content_security_policy
935                .contains("script-src 'self'")
936        );
937        assert_eq!(
938            config.headers.referrer_policy,
939            "strict-origin-when-cross-origin"
940        );
941    }
942
943    #[test]
944    fn default_csp_does_not_allow_unsafe_eval() {
945        // htmx works without unsafe-eval; only `hx-on` opts into it.
946        // Keep the default tight so that the baseline policy passes
947        // Mozilla Observatory and similar automated scanners.
948        let csp = default_content_security_policy();
949        assert!(!csp.contains("'unsafe-eval'"), "csp = {csp}");
950        assert!(
951            !csp.contains("'unsafe-inline' 'unsafe-eval'"),
952            "csp = {csp}"
953        );
954    }
955
956    #[test]
957    fn csp_can_be_disabled_via_toml_empty_string() {
958        let toml_str = r#"
959            content_security_policy = ""
960        "#;
961        let config: HeadersConfig = toml::from_str(toml_str).unwrap();
962        assert!(config.content_security_policy.is_empty());
963    }
964
965    #[test]
966    fn csp_can_be_overridden_via_toml() {
967        let toml_str = r#"
968            content_security_policy = "default-src 'none'"
969        "#;
970        let config: HeadersConfig = toml::from_str(toml_str).unwrap();
971        assert_eq!(config.content_security_policy, "default-src 'none'");
972    }
973
974    #[test]
975    fn csrf_config_defaults() {
976        let config = CsrfConfig::default();
977        assert!(!config.enabled);
978        assert_eq!(config.token_header, "X-CSRF-Token");
979        assert_eq!(config.form_field, "_csrf");
980        assert_eq!(config.cookie_name, "autumn-csrf");
981        assert_eq!(config.safe_methods.len(), 4);
982    }
983
984    #[test]
985    fn headers_config_deserialize() {
986        let toml_str = r#"
987            x_frame_options = "SAMEORIGIN"
988            strict_transport_security = true
989            content_security_policy = "default-src 'self'"
990        "#;
991        let config: HeadersConfig = toml::from_str(toml_str).unwrap();
992        assert_eq!(config.x_frame_options, "SAMEORIGIN");
993        assert!(config.strict_transport_security);
994        assert_eq!(config.content_security_policy, "default-src 'self'");
995        // Defaults for unspecified fields
996        assert!(config.x_content_type_options);
997        assert!(config.xss_protection);
998    }
999
1000    #[test]
1001    fn csrf_config_deserialize() {
1002        let toml_str = r#"
1003            enabled = true
1004            token_header = "X-XSRF-Token"
1005        "#;
1006        let config: CsrfConfig = toml::from_str(toml_str).unwrap();
1007        assert!(config.enabled);
1008        assert_eq!(config.token_header, "X-XSRF-Token");
1009        assert_eq!(config.form_field, "_csrf"); // default preserved
1010    }
1011
1012    #[test]
1013    fn rate_limit_config_defaults() {
1014        let config = RateLimitConfig::default();
1015        assert!(!config.enabled);
1016        assert!((config.requests_per_second - 10.0).abs() < f64::EPSILON);
1017        assert_eq!(config.burst, 20);
1018        assert!(!config.trust_forwarded_headers);
1019        assert!(config.trusted_proxies.is_empty());
1020    }
1021
1022    #[test]
1023    fn rate_limit_config_deserialize() {
1024        let toml_str = r#"
1025            enabled = true
1026            requests_per_second = 5.0
1027            burst = 100
1028            trust_forwarded_headers = true
1029            trusted_proxies = ["10.0.0.10", "203.0.113.0/24"]
1030        "#;
1031        let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
1032        assert!(config.enabled);
1033        assert!((config.requests_per_second - 5.0).abs() < f64::EPSILON);
1034        assert_eq!(config.burst, 100);
1035        assert!(config.trust_forwarded_headers);
1036        assert_eq!(config.trusted_proxies, vec!["10.0.0.10", "203.0.113.0/24"]);
1037    }
1038
1039    #[test]
1040    fn rate_limit_config_partial_deserialize_uses_defaults() {
1041        let toml_str = "enabled = true";
1042        let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
1043        assert!(config.enabled);
1044        assert!((config.requests_per_second - 10.0).abs() < f64::EPSILON);
1045        assert_eq!(config.burst, 20);
1046        assert!(!config.trust_forwarded_headers);
1047        assert!(config.trusted_proxies.is_empty());
1048    }
1049
1050    #[test]
1051    fn upload_config_defaults() {
1052        let config = UploadConfig::default();
1053        assert_eq!(config.max_request_size_bytes, 32 * 1024 * 1024);
1054        assert_eq!(config.max_file_size_bytes, 16 * 1024 * 1024);
1055        assert!(config.allowed_mime_types.is_empty());
1056    }
1057
1058    #[test]
1059    fn upload_config_deserialize() {
1060        let toml_str = r#"
1061            max_request_size_bytes = 1024
1062            max_file_size_bytes = 256
1063            allowed_mime_types = ["image/png", "image/jpeg"]
1064        "#;
1065        let config: UploadConfig = toml::from_str(toml_str).unwrap();
1066        assert_eq!(config.max_request_size_bytes, 1024);
1067        assert_eq!(config.max_file_size_bytes, 256);
1068        assert_eq!(config.allowed_mime_types.len(), 2);
1069    }
1070
1071    #[test]
1072    fn full_security_config_deserialize() {
1073        let toml_str = r#"
1074            [headers]
1075            x_frame_options = "DENY"
1076            strict_transport_security = true
1077
1078            [csrf]
1079            enabled = true
1080
1081            [rate_limit]
1082            enabled = true
1083            requests_per_second = 50.0
1084            burst = 100
1085
1086            [upload]
1087            max_request_size_bytes = 4096
1088            max_file_size_bytes = 1024
1089            allowed_mime_types = ["text/plain"]
1090        "#;
1091        let config: SecurityConfig = toml::from_str(toml_str).unwrap();
1092        assert_eq!(config.headers.x_frame_options, "DENY");
1093        assert!(config.headers.strict_transport_security);
1094        assert!(config.csrf.enabled);
1095        assert!(config.rate_limit.enabled);
1096        assert!((config.rate_limit.requests_per_second - 50.0).abs() < f64::EPSILON);
1097        assert_eq!(config.rate_limit.burst, 100);
1098        assert_eq!(config.upload.max_request_size_bytes, 4096);
1099        assert_eq!(config.upload.max_file_size_bytes, 1024);
1100        assert_eq!(config.upload.allowed_mime_types, vec!["text/plain"]);
1101    }
1102
1103    // ── ResolvedSigningKeys + resolve_signing_keys (RED phase) ─────────────
1104
1105    #[test]
1106    fn resolve_signing_keys_dev_generates_non_empty_ephemeral() {
1107        let config = SigningSecretConfig::default();
1108        let keys = resolve_signing_keys(&config);
1109        assert!(keys.current.len() >= MIN_SECRET_LEN);
1110    }
1111
1112    #[test]
1113    fn resolve_signing_keys_prod_uses_secret_bytes() {
1114        let secret = "a".repeat(MIN_SECRET_LEN);
1115        let config = SigningSecretConfig {
1116            secret: Some(secret.clone()),
1117            previous_secrets: vec![],
1118        };
1119        let keys = resolve_signing_keys(&config);
1120        assert_eq!(keys.current.as_ref(), secret.as_bytes());
1121    }
1122
1123    #[test]
1124    fn resolve_signing_keys_includes_previous_secrets() {
1125        let config = SigningSecretConfig {
1126            secret: Some("a".repeat(MIN_SECRET_LEN)),
1127            previous_secrets: vec!["b".repeat(MIN_SECRET_LEN)],
1128        };
1129        let keys = resolve_signing_keys(&config);
1130        assert_eq!(keys.previous.len(), 1);
1131        assert_eq!(
1132            keys.previous[0].as_ref(),
1133            "b".repeat(MIN_SECRET_LEN).as_bytes()
1134        );
1135    }
1136
1137    #[test]
1138    fn resolved_keys_sign_and_verify_current() {
1139        let keys = ResolvedSigningKeys::new(b"current-key-32-bytes-xxxxxxxxxx".to_vec(), vec![]);
1140        let sig = keys.sign(b"test-message");
1141        assert!(keys.verify(b"test-message", &sig));
1142    }
1143
1144    #[test]
1145    fn resolved_keys_verify_rejects_wrong_message() {
1146        let keys = ResolvedSigningKeys::new(b"current-key-32-bytes-xxxxxxxxxx".to_vec(), vec![]);
1147        let sig = keys.sign(b"message-a");
1148        assert!(!keys.verify(b"message-b", &sig));
1149    }
1150
1151    #[test]
1152    fn resolved_keys_verify_previous_key_passes() {
1153        let old_key = b"old-key-32-bytes-xxxxxxxxxxxx!x".to_vec();
1154        let new_key = b"new-key-32-bytes-xxxxxxxxxxxx!x".to_vec();
1155        let old_keys = ResolvedSigningKeys::new(old_key.clone(), vec![]);
1156        let old_sig = old_keys.sign(b"session-id");
1157        let new_keys = ResolvedSigningKeys::new(new_key, vec![old_key]);
1158        assert!(new_keys.verify(b"session-id", &old_sig));
1159    }
1160
1161    #[test]
1162    fn resolved_keys_verify_wrong_key_fails() {
1163        let keys_a = ResolvedSigningKeys::new(b"key-a-32-bytes-xxxxxxxxxxxxxxxx".to_vec(), vec![]);
1164        let keys_b = ResolvedSigningKeys::new(b"key-b-32-bytes-xxxxxxxxxxxxxxxx".to_vec(), vec![]);
1165        let sig = keys_a.sign(b"message");
1166        assert!(!keys_b.verify(b"message", &sig));
1167    }
1168
1169    #[test]
1170    fn resolved_keys_sign_produces_64_char_hex() {
1171        let keys = ResolvedSigningKeys::new(b"key".to_vec(), vec![]);
1172        let sig = keys.sign(b"msg");
1173        assert_eq!(sig.len(), 64, "HMAC-SHA256 hex is 64 chars");
1174        assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
1175    }
1176}