use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SecurityConfig {
#[serde(default)]
pub headers: HeadersConfig,
#[serde(default)]
pub csrf: CsrfConfig,
#[serde(default)]
pub rate_limit: RateLimitConfig,
#[serde(default)]
pub upload: UploadConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct HeadersConfig {
#[serde(default = "default_x_frame_options")]
pub x_frame_options: String,
#[serde(default = "default_true")]
pub x_content_type_options: bool,
#[serde(default = "default_true")]
pub xss_protection: bool,
#[serde(default)]
pub strict_transport_security: bool,
#[serde(default = "default_hsts_max_age")]
pub hsts_max_age_secs: u64,
#[serde(default = "default_true")]
pub hsts_include_subdomains: bool,
#[serde(default = "default_content_security_policy")]
pub content_security_policy: String,
#[serde(default = "default_referrer_policy")]
pub referrer_policy: String,
#[serde(default)]
pub permissions_policy: String,
}
impl Default for HeadersConfig {
fn default() -> Self {
Self {
x_frame_options: default_x_frame_options(),
x_content_type_options: true,
xss_protection: true,
strict_transport_security: false,
hsts_max_age_secs: default_hsts_max_age(),
hsts_include_subdomains: true,
content_security_policy: default_content_security_policy(),
referrer_policy: default_referrer_policy(),
permissions_policy: String::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct CsrfConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_csrf_header")]
pub token_header: String,
#[serde(default = "default_csrf_field")]
pub form_field: String,
#[serde(default = "default_csrf_cookie")]
pub cookie_name: String,
#[serde(default = "default_safe_methods")]
pub safe_methods: Vec<String>,
#[serde(default)]
pub exempt_paths: Vec<String>,
}
impl Default for CsrfConfig {
fn default() -> Self {
Self {
enabled: false,
token_header: default_csrf_header(),
form_field: default_csrf_field(),
cookie_name: default_csrf_cookie(),
safe_methods: default_safe_methods(),
exempt_paths: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct RateLimitConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_rps")]
pub requests_per_second: f64,
#[serde(default = "default_burst")]
pub burst: u32,
#[serde(default)]
pub trust_forwarded_headers: bool,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: false,
requests_per_second: default_rps(),
burst: default_burst(),
trust_forwarded_headers: false,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct UploadConfig {
#[serde(default = "default_max_request_size_bytes")]
pub max_request_size_bytes: usize,
#[serde(default = "default_max_file_size_bytes")]
pub max_file_size_bytes: usize,
#[serde(default)]
pub allowed_mime_types: Vec<String>,
}
impl Default for UploadConfig {
fn default() -> Self {
Self {
max_request_size_bytes: default_max_request_size_bytes(),
max_file_size_bytes: default_max_file_size_bytes(),
allowed_mime_types: Vec::new(),
}
}
}
const fn default_true() -> bool {
true
}
fn default_x_frame_options() -> String {
"DENY".to_owned()
}
const fn default_hsts_max_age() -> u64 {
31_536_000 }
fn default_referrer_policy() -> String {
"strict-origin-when-cross-origin".to_owned()
}
#[must_use]
pub fn default_content_security_policy() -> String {
"default-src 'self'; \
img-src 'self' data:; \
style-src 'self' 'unsafe-inline'; \
script-src 'self'; \
connect-src 'self'; \
form-action 'self'; \
frame-ancestors 'none'; \
base-uri 'self'"
.to_owned()
}
fn default_csrf_header() -> String {
"X-CSRF-Token".to_owned()
}
fn default_csrf_field() -> String {
"_csrf".to_owned()
}
fn default_csrf_cookie() -> String {
"autumn-csrf".to_owned()
}
fn default_safe_methods() -> Vec<String> {
vec![
"GET".to_owned(),
"HEAD".to_owned(),
"OPTIONS".to_owned(),
"TRACE".to_owned(),
]
}
const fn default_rps() -> f64 {
10.0
}
const fn default_burst() -> u32 {
20
}
const fn default_max_request_size_bytes() -> usize {
32 * 1024 * 1024
}
const fn default_max_file_size_bytes() -> usize {
16 * 1024 * 1024
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn security_config_defaults() {
let config = SecurityConfig::default();
assert_eq!(config.headers.x_frame_options, "DENY");
assert!(config.headers.x_content_type_options);
assert!(config.headers.xss_protection);
assert!(!config.headers.strict_transport_security);
assert_eq!(config.headers.hsts_max_age_secs, 31_536_000);
assert!(!config.headers.content_security_policy.is_empty());
assert!(
config
.headers
.content_security_policy
.contains("default-src 'self'")
);
assert!(
config
.headers
.content_security_policy
.contains("script-src 'self'")
);
assert_eq!(
config.headers.referrer_policy,
"strict-origin-when-cross-origin"
);
}
#[test]
fn default_csp_does_not_allow_unsafe_eval() {
let csp = default_content_security_policy();
assert!(!csp.contains("'unsafe-eval'"), "csp = {csp}");
assert!(
!csp.contains("'unsafe-inline' 'unsafe-eval'"),
"csp = {csp}"
);
}
#[test]
fn csp_can_be_disabled_via_toml_empty_string() {
let toml_str = r#"
content_security_policy = ""
"#;
let config: HeadersConfig = toml::from_str(toml_str).unwrap();
assert!(config.content_security_policy.is_empty());
}
#[test]
fn csp_can_be_overridden_via_toml() {
let toml_str = r#"
content_security_policy = "default-src 'none'"
"#;
let config: HeadersConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.content_security_policy, "default-src 'none'");
}
#[test]
fn csrf_config_defaults() {
let config = CsrfConfig::default();
assert!(!config.enabled);
assert_eq!(config.token_header, "X-CSRF-Token");
assert_eq!(config.form_field, "_csrf");
assert_eq!(config.cookie_name, "autumn-csrf");
assert_eq!(config.safe_methods.len(), 4);
}
#[test]
fn headers_config_deserialize() {
let toml_str = r#"
x_frame_options = "SAMEORIGIN"
strict_transport_security = true
content_security_policy = "default-src 'self'"
"#;
let config: HeadersConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.x_frame_options, "SAMEORIGIN");
assert!(config.strict_transport_security);
assert_eq!(config.content_security_policy, "default-src 'self'");
assert!(config.x_content_type_options);
assert!(config.xss_protection);
}
#[test]
fn csrf_config_deserialize() {
let toml_str = r#"
enabled = true
token_header = "X-XSRF-Token"
"#;
let config: CsrfConfig = toml::from_str(toml_str).unwrap();
assert!(config.enabled);
assert_eq!(config.token_header, "X-XSRF-Token");
assert_eq!(config.form_field, "_csrf"); }
#[test]
fn rate_limit_config_defaults() {
let config = RateLimitConfig::default();
assert!(!config.enabled);
assert!((config.requests_per_second - 10.0).abs() < f64::EPSILON);
assert_eq!(config.burst, 20);
assert!(!config.trust_forwarded_headers);
}
#[test]
fn rate_limit_config_deserialize() {
let toml_str = r"
enabled = true
requests_per_second = 5.0
burst = 100
trust_forwarded_headers = true
";
let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
assert!(config.enabled);
assert!((config.requests_per_second - 5.0).abs() < f64::EPSILON);
assert_eq!(config.burst, 100);
assert!(config.trust_forwarded_headers);
}
#[test]
fn rate_limit_config_partial_deserialize_uses_defaults() {
let toml_str = "enabled = true";
let config: RateLimitConfig = toml::from_str(toml_str).unwrap();
assert!(config.enabled);
assert!((config.requests_per_second - 10.0).abs() < f64::EPSILON);
assert_eq!(config.burst, 20);
assert!(!config.trust_forwarded_headers);
}
#[test]
fn upload_config_defaults() {
let config = UploadConfig::default();
assert_eq!(config.max_request_size_bytes, 32 * 1024 * 1024);
assert_eq!(config.max_file_size_bytes, 16 * 1024 * 1024);
assert!(config.allowed_mime_types.is_empty());
}
#[test]
fn upload_config_deserialize() {
let toml_str = r#"
max_request_size_bytes = 1024
max_file_size_bytes = 256
allowed_mime_types = ["image/png", "image/jpeg"]
"#;
let config: UploadConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.max_request_size_bytes, 1024);
assert_eq!(config.max_file_size_bytes, 256);
assert_eq!(config.allowed_mime_types.len(), 2);
}
#[test]
fn full_security_config_deserialize() {
let toml_str = r#"
[headers]
x_frame_options = "DENY"
strict_transport_security = true
[csrf]
enabled = true
[rate_limit]
enabled = true
requests_per_second = 50.0
burst = 100
[upload]
max_request_size_bytes = 4096
max_file_size_bytes = 1024
allowed_mime_types = ["text/plain"]
"#;
let config: SecurityConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.headers.x_frame_options, "DENY");
assert!(config.headers.strict_transport_security);
assert!(config.csrf.enabled);
assert!(config.rate_limit.enabled);
assert!((config.rate_limit.requests_per_second - 50.0).abs() < f64::EPSILON);
assert_eq!(config.rate_limit.burst, 100);
assert_eq!(config.upload.max_request_size_bytes, 4096);
assert_eq!(config.upload.max_file_size_bytes, 1024);
assert_eq!(config.upload.allowed_mime_types, vec!["text/plain"]);
}
}