2026-01-03 17:42:24 +01:00
|
|
|
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]
|
2026-01-03 17:50:06 +01:00
|
|
|
#[should_panic(
|
|
|
|
|
expected = "CORS misconfiguration: wildcard origin not allowed with credentials=true"
|
|
|
|
|
)]
|
2026-01-03 17:42:24 +01:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|