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}