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:
@@ -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/*"]
|
||||
|
||||
216
backend/src/settings/cors.rs
Normal file
216
backend/src/settings/cors.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user