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, /// 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 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 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(); } }