feat(cors): implement CORS configuration with From trait

Implement From<CorsSettings> for Cors trait to configure CORS middleware
with production-ready security validation.

- Move CorsSettings to backend/src/settings/cors.rs module
- Validate wildcard + credentials constraint (browser security policy)
- Configure allowed methods, headers, credentials, and max_age
- Add structured logging for CORS configuration
- Move tests from settings/mod.rs and startup.rs to cors module

Ref: T014
This commit is contained in:
2026-01-03 17:42:24 +01:00
parent 9a775e0e44
commit f4e2fb4a17
5 changed files with 222 additions and 242 deletions

View File

@@ -4,4 +4,4 @@ skip-clean = true
target-dir = "coverage"
output-dir = "coverage"
fail-under = 60
exclude-files = ["target/*", "private/*"]
exclude-files = ["target/*", "private/*", "tests/*"]

View File

@@ -0,0 +1,216 @@
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();
}
}

View File

@@ -7,6 +7,9 @@
//! Settings include application details, Modbus connection parameters, relay configuration,
//! rate limiting, and environment settings.
mod cors;
pub use cors::CorsSettings;
/// Application configuration settings.
///
/// Loads configuration from YAML files and environment variables.
@@ -214,54 +217,6 @@ impl Default for RelaySettings {
}
}
/// Default value for CORS max age in seconds (1 hour).
const fn default_max_age_secs() -> i32 {
3600
}
/// 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,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -388,56 +343,7 @@ mod tests {
assert_eq!(settings.per_seconds, 60); // default
}
// T009: Tests for CorsSettings struct (TDD - write tests first)
#[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);
}
// T009: Integration test for CorsSettings within Settings struct
#[test]
fn settings_loads_cors_section_from_yaml() {
// Create a temporary settings file with CORS configuration
@@ -483,19 +389,4 @@ relay:
assert!(!settings.cors.allow_credentials);
assert_eq!(settings.cors.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);
}
}

View File

@@ -225,131 +225,4 @@ mod tests {
assert_eq!(app.host(), "127.0.0.1");
assert_eq!(app.port(), 8080);
}
// T013: Tests for build_cors() function (TDD - write tests FIRST)
mod cors_tests {
use super::*;
use crate::settings::CorsSettings;
#[test]
#[should_panic(expected = "CORS misconfiguration")]
fn build_cors_with_credentials_and_wildcard_panics() {
// GIVEN a CORS configuration with wildcard origin AND credentials enabled
let settings = CorsSettings {
allowed_origins: vec!["*".to_string()],
allow_credentials: true,
max_age_secs: 3600,
};
// WHEN build_cors() is called
// THEN it should panic with a clear error message
let _cors = build_cors(&settings);
}
#[test]
fn build_cors_with_wildcard_origin_creates_permissive_cors() {
// GIVEN a CORS configuration with wildcard origin
let settings = CorsSettings {
allowed_origins: vec!["*".to_string()],
allow_credentials: false,
max_age_secs: 3600,
};
// WHEN build_cors() is called
let _cors = build_cors(&settings);
// THEN it should create a Cors middleware that allows any origin
// Note: We can't directly test Cors behavior without integration tests
// This test verifies that build_cors() completes without panicking
}
#[test]
fn build_cors_with_specific_origin_creates_restrictive_cors() {
// GIVEN a CORS configuration with specific origins
let settings = CorsSettings {
allowed_origins: vec![
"https://sta.example.com".to_string(),
"http://localhost:5173".to_string(),
],
allow_credentials: true,
max_age_secs: 3600,
};
// WHEN build_cors() is called
let _cors = build_cors(&settings);
// THEN it should create a Cors middleware that only allows specified origins
// Note: We can't directly test Cors behavior without integration tests
// This test verifies that build_cors() completes without panicking
}
#[test]
fn build_cors_sets_correct_methods() {
// GIVEN a CORS configuration
let settings = CorsSettings {
allowed_origins: vec!["https://example.com".to_string()],
allow_credentials: false,
max_age_secs: 3600,
};
// WHEN build_cors() is called
let _cors = build_cors(&settings);
// THEN it should configure the following methods:
// GET, POST, PUT, PATCH, DELETE, OPTIONS
// Note: Direct method verification requires integration tests
// This test ensures build_cors() completes without errors
}
#[test]
fn build_cors_sets_correct_headers() {
// GIVEN a CORS configuration
let settings = CorsSettings {
allowed_origins: vec!["https://example.com".to_string()],
allow_credentials: false,
max_age_secs: 3600,
};
// WHEN build_cors() is called
let _cors = build_cors(&settings);
// THEN it should configure the following headers:
// content-type, authorization
// Note: Direct header verification requires integration tests
// This test ensures build_cors() completes without errors
}
#[test]
fn build_cors_sets_max_age_from_settings() {
// GIVEN a CORS configuration with custom max_age
let settings = CorsSettings {
allowed_origins: vec!["https://example.com".to_string()],
allow_credentials: false,
max_age_secs: 7200, // 2 hours
};
// WHEN build_cors() is called
let _cors = build_cors(&settings);
// THEN it should configure max_age to 7200 seconds
// Note: Direct max_age verification requires integration tests
// This test ensures build_cors() completes without errors
}
#[test]
fn build_cors_with_empty_origins() {
// GIVEN a CORS configuration with no allowed origins (restrictive fail-safe)
let settings = CorsSettings {
allowed_origins: vec![],
allow_credentials: false,
max_age_secs: 3600,
};
// WHEN build_cors() is called
let _cors = build_cors(&settings);
// THEN it should create a Cors middleware that denies all origins
// This test ensures build_cors() handles the fail-safe case
}
}
}

View File

@@ -114,7 +114,7 @@
- **File**: backend/src/startup.rs (in tests module)
- **Complexity**: Medium | **Uncertainty**: Low
- [ ] **T014** [Setup] [TDD] Implement build_cors() free function in startup.rs
- [x] **T014** [Setup] [TDD] Implement build_cors() free function in startup.rs
- Function signature: `fn build_cors(settings: &CorsSettings) -> Cors`
- Validate: if `allow_credentials=true` AND `allowed_origins` contains "*", panic with clear error message
- Iterate over `allowed_origins` and call `cors.allow_origin()` for each