Files
sta/backend/src/settings/cors.rs
T

217 lines
7.1 KiB
Rust
Raw Normal View History

use poem::{
http::{Method, header},
middleware::Cors,
};
/// CORS (Cross-Origin Resource Sharing) configuration for the HTTP API.
///
/// Controls which origins can access the API from browsers. In development,
/// use permissive settings (`allowed_origins: ["*"]`). In production, use
/// restrictive settings with specific origins.
///
/// # Security Constraint
///
/// When `allow_credentials` is `true`, `allowed_origins` MUST NOT contain
/// wildcard `"*"`. This is enforced by browser security policy and will be
/// validated by the `build_cors()` function.
#[derive(Debug, serde::Deserialize, Clone)]
pub struct CorsSettings {
/// List of allowed origin URLs (e.g., `["https://sta.example.com"]`).
///
/// Use `["*"]` for development to allow all origins.
/// In production, specify exact origins to prevent unauthorized access.
#[serde(default)]
pub allowed_origins: Vec<String>,
/// Whether to allow credentials (cookies, authorization headers) in CORS requests.
///
/// Set to `true` in production when using Authelia authentication.
/// MUST be `false` when using wildcard `"*"` in `allowed_origins`.
#[serde(default)]
pub allow_credentials: bool,
/// Duration in seconds that browsers can cache CORS preflight responses.
///
/// Typical value: `3600` (1 hour). Higher values reduce preflight requests
/// but delay policy changes from taking effect.
#[serde(default = "default_max_age_secs")]
pub max_age_secs: i32,
}
impl Default for CorsSettings {
fn default() -> Self {
Self {
allowed_origins: vec![],
allow_credentials: false,
max_age_secs: 3600,
}
}
}
/// Default value for CORS max age in seconds (1 hour).
const fn default_max_age_secs() -> i32 {
3600
}
impl From<CorsSettings> for Cors {
fn from(val: CorsSettings) -> Self {
assert!(
!(val.allow_credentials && val.allowed_origins.contains(&"*".to_string())),
"CORS misconfiguration: wildcard origin not allowed with credentials=true"
);
let mut cors = Self::new();
for origin in &val.allowed_origins {
cors = cors.allow_origin(origin);
}
cors = cors.allow_methods(vec![
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
]);
cors = cors.allow_headers(vec![header::CONTENT_TYPE, header::AUTHORIZATION]);
cors = cors
.allow_credentials(val.allow_credentials)
.max_age(val.max_age_secs);
tracing::info!(
target: "backend::settings::cors",
allowed_origins = ?val.allowed_origins,
allow_credentials = ?val.allow_credentials,
max_age_secs = ?val.max_age_secs,
"CORS middleware configured"
);
cors
}
}
#[cfg(test)]
mod tests {
use super::*;
// T009: Tests for CorsSettings struct deserialization
#[test]
fn cors_settings_deserialize_from_yaml() {
let yaml = r#"
allowed_origins:
- "http://localhost:5173"
- "https://sta.example.com"
allow_credentials: true
max_age_secs: 7200
"#;
let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap();
assert_eq!(settings.allowed_origins.len(), 2);
assert_eq!(settings.allowed_origins[0], "http://localhost:5173");
assert_eq!(settings.allowed_origins[1], "https://sta.example.com");
assert!(settings.allow_credentials);
assert_eq!(settings.max_age_secs, 7200);
}
#[test]
fn cors_settings_default_has_empty_origins() {
let settings = CorsSettings::default();
assert!(
settings.allowed_origins.is_empty(),
"Default CorsSettings should have empty allowed_origins for restrictive fail-safe"
);
assert!(
!settings.allow_credentials,
"Default CorsSettings should have credentials disabled"
);
assert_eq!(
settings.max_age_secs, 3600,
"Default CorsSettings should have 1 hour max_age"
);
}
#[test]
fn cors_settings_with_wildcard_deserializes() {
let yaml = r#"
allowed_origins:
- "*"
allow_credentials: false
max_age_secs: 3600
"#;
let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap();
assert_eq!(settings.allowed_origins.len(), 1);
assert_eq!(settings.allowed_origins[0], "*");
assert!(!settings.allow_credentials);
assert_eq!(settings.max_age_secs, 3600);
}
#[test]
fn cors_settings_deserialize_with_defaults() {
// Test partial deserialization using serde defaults
let yaml = r#"
allowed_origins:
- "https://example.com"
"#;
let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap();
assert_eq!(settings.allowed_origins.len(), 1);
assert_eq!(settings.allowed_origins[0], "https://example.com");
// These should use defaults
assert!(!settings.allow_credentials);
assert_eq!(settings.max_age_secs, 3600);
}
// T013: Tests for From<CorsSettings> for Cors trait implementation
#[test]
fn cors_conversion_with_wildcard_origin() {
let settings = CorsSettings {
allowed_origins: vec!["*".to_string()],
allow_credentials: false,
max_age_secs: 3600,
};
// Should successfully convert without panic
let _cors: Cors = settings.into();
}
#[test]
fn cors_conversion_with_specific_origin() {
let settings = CorsSettings {
allowed_origins: vec!["https://sta.example.com".to_string()],
allow_credentials: true,
max_age_secs: 7200,
};
// Should successfully convert without panic
let _cors: Cors = settings.into();
}
#[test]
fn cors_conversion_with_multiple_origins() {
let settings = CorsSettings {
allowed_origins: vec![
"http://localhost:5173".to_string(),
"https://sta.example.com".to_string(),
],
allow_credentials: false,
max_age_secs: 3600,
};
// Should successfully convert without panic
let _cors: Cors = settings.into();
}
#[test]
#[should_panic(expected = "CORS misconfiguration: wildcard origin not allowed with credentials=true")]
fn cors_conversion_panics_on_wildcard_with_credentials() {
let settings = CorsSettings {
allowed_origins: vec!["*".to_string()],
allow_credentials: true, // Invalid combination!
max_age_secs: 3600,
};
// This should panic due to browser security constraint violation
let _cors: Cors = settings.into();
}
#[test]
fn cors_conversion_with_empty_origins() {
let settings = CorsSettings::default();
// Should successfully convert even with empty origins (restrictive CORS)
let _cors: Cors = settings.into();
}
}