// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! CORS (Cross-Origin Resource Sharing) Support for Swift API //! //! This module implements CORS configuration and response header injection //! for Swift containers and objects, enabling web browsers to access Swift //! resources from different origins. //! //! # Configuration //! //! CORS is configured via container metadata: //! //! - `X-Container-Meta-Access-Control-Allow-Origin`: Allowed origins (e.g., `*` or `https://example.com`) //! - `X-Container-Meta-Access-Control-Max-Age`: Preflight cache duration in seconds //! - `X-Container-Meta-Access-Control-Expose-Headers`: Headers exposed to browser //! - `X-Container-Meta-Access-Control-Allow-Credentials`: Allow credentials (true/false) //! //! # Usage //! //! ```bash //! # Enable CORS for all origins //! swift post my-container \ //! -H "X-Container-Meta-Access-Control-Allow-Origin: *" \ //! -H "X-Container-Meta-Access-Control-Max-Age: 86400" //! //! # Enable CORS for specific origin //! swift post my-container \ //! -H "X-Container-Meta-Access-Control-Allow-Origin: https://example.com" //! //! # Expose custom headers //! swift post my-container \ //! -H "X-Container-Meta-Access-Control-Expose-Headers: X-Custom-Header, X-Another-Header" //! ``` //! //! # Preflight Requests //! //! Browsers send OPTIONS requests for preflight checks. This module handles //! these requests by returning appropriate Access-Control-* headers based on //! container configuration. use super::{SwiftError, SwiftResult, container}; use axum::http::{HeaderMap, HeaderValue, Response, StatusCode}; use rustfs_credentials::Credentials; use s3s::Body; use tracing::debug; const LOG_COMPONENT_PROTOCOLS: &str = "protocols"; const LOG_SUBSYSTEM_SWIFT_CORS: &str = "swift_cors"; const EVENT_SWIFT_CORS_STATE: &str = "swift_cors_state"; /// CORS configuration for a container #[derive(Debug, Clone, Default)] pub struct CorsConfig { /// Allowed origins (e.g., "*" or "https://example.com") pub allow_origin: Option, /// Maximum age for preflight cache in seconds pub max_age: Option, /// Headers exposed to browser pub expose_headers: Option>, /// Allow credentials (cookies, authorization headers) pub allow_credentials: bool, } impl CorsConfig { /// Load CORS configuration from container metadata pub async fn load(account: &str, container_name: &str, credentials: &Credentials) -> SwiftResult { // Get container metadata let container_info = container::get_container_metadata(account, container_name, credentials).await?; let mut config = CorsConfig::default(); // Parse Access-Control-Allow-Origin if let Some(origin) = container_info .custom_metadata .get("x-container-meta-access-control-allow-origin") { config.allow_origin = Some(origin.clone()); } // Parse Access-Control-Max-Age if let Some(max_age_str) = container_info.custom_metadata.get("x-container-meta-access-control-max-age") { config.max_age = max_age_str.parse().ok(); } // Parse Access-Control-Expose-Headers if let Some(expose_headers_str) = container_info .custom_metadata .get("x-container-meta-access-control-expose-headers") { config.expose_headers = Some(expose_headers_str.split(',').map(|s: &str| s.trim().to_string()).collect()); } // Parse Access-Control-Allow-Credentials if let Some(allow_creds) = container_info .custom_metadata .get("x-container-meta-access-control-allow-credentials") { config.allow_credentials = allow_creds.to_lowercase() == "true"; } Ok(config) } /// Check if CORS is enabled pub fn is_enabled(&self) -> bool { self.allow_origin.is_some() } /// Add CORS headers to response pub fn inject_headers(&self, response: &mut Response, request_origin: Option<&str>) { if !self.is_enabled() { return; } // Add Access-Control-Allow-Origin if let Some(allow_origin) = &self.allow_origin { if allow_origin == "*" { // Wildcard origin let _ = response .headers_mut() .insert("access-control-allow-origin", HeaderValue::from_static("*")); } else if let Some(origin) = request_origin { // Check if request origin matches configured origin if allow_origin == origin && let Ok(header_value) = HeaderValue::from_str(origin) { let _ = response.headers_mut().insert("access-control-allow-origin", header_value); } } } // Add Access-Control-Expose-Headers if let Some(expose_headers) = &self.expose_headers { let headers_str = expose_headers.join(", "); if let Ok(header_value) = HeaderValue::from_str(&headers_str) { let _ = response.headers_mut().insert("access-control-expose-headers", header_value); } } // Add Access-Control-Allow-Credentials if self.allow_credentials { let _ = response .headers_mut() .insert("access-control-allow-credentials", HeaderValue::from_static("true")); } } } /// Handle OPTIONS preflight request pub async fn handle_preflight( account: &str, container_name: &str, credentials: &Credentials, request_headers: &HeaderMap, ) -> SwiftResult> { debug!( event = EVENT_SWIFT_CORS_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_CORS, state = "preflight_requested", account = %account, container = %container_name, "swift cors state changed" ); // Load CORS configuration let config = CorsConfig::load(account, container_name, credentials).await?; if !config.is_enabled() { return Err(SwiftError::Forbidden("CORS not configured for this container".to_string())); } // Get request origin let request_origin = request_headers.get("origin").and_then(|v| v.to_str().ok()); // Build preflight response let mut response = Response::builder() .status(StatusCode::OK) .body(Body::empty()) .map_err(|e| SwiftError::InternalServerError(format!("Failed to build response: {}", e)))?; // Add CORS headers config.inject_headers(&mut response, request_origin); // Add Access-Control-Allow-Methods (all Swift methods) response.headers_mut().insert( "access-control-allow-methods", HeaderValue::from_static("GET, PUT, POST, DELETE, HEAD, OPTIONS"), ); // Add Access-Control-Max-Age if let Some(max_age) = config.max_age && let Ok(header_value) = HeaderValue::from_str(&max_age.to_string()) { response.headers_mut().insert("access-control-max-age", header_value); } // Echo back Access-Control-Request-Headers if present if let Some(request_headers_value) = request_headers.get("access-control-request-headers") { response .headers_mut() .insert("access-control-allow-headers", request_headers_value.clone()); } let trans_id = super::handler::generate_trans_id(); response.headers_mut().insert( "x-trans-id", HeaderValue::from_str(&trans_id).unwrap_or_else(|_| HeaderValue::from_static("")), ); response.headers_mut().insert( "x-openstack-request-id", HeaderValue::from_str(&trans_id).unwrap_or_else(|_| HeaderValue::from_static("")), ); Ok(response) } /// Check if CORS is enabled for a container pub async fn is_enabled(account: &str, container_name: &str, credentials: &Credentials) -> SwiftResult { match CorsConfig::load(account, container_name, credentials).await { Ok(config) => Ok(config.is_enabled()), Err(_) => Ok(false), // Container doesn't exist or no CORS configured } } #[cfg(test)] mod tests { use super::*; #[test] fn test_cors_config_default() { let config = CorsConfig::default(); assert!(!config.is_enabled()); assert!(config.allow_origin.is_none()); assert!(config.max_age.is_none()); assert!(config.expose_headers.is_none()); assert!(!config.allow_credentials); } #[test] fn test_cors_config_enabled() { let config = CorsConfig { allow_origin: Some("*".to_string()), max_age: Some(86400), expose_headers: None, allow_credentials: false, }; assert!(config.is_enabled()); } #[test] fn test_cors_config_wildcard_origin() { let config = CorsConfig { allow_origin: Some("*".to_string()), max_age: None, expose_headers: None, allow_credentials: false, }; let mut response = Response::new(Body::empty()); config.inject_headers(&mut response, Some("https://example.com")); let origin = response.headers().get("access-control-allow-origin"); assert_eq!(origin.unwrap().to_str().unwrap(), "*"); } #[test] fn test_cors_config_specific_origin_match() { let config = CorsConfig { allow_origin: Some("https://example.com".to_string()), max_age: None, expose_headers: None, allow_credentials: false, }; let mut response = Response::new(Body::empty()); config.inject_headers(&mut response, Some("https://example.com")); let origin = response.headers().get("access-control-allow-origin"); assert_eq!(origin.unwrap().to_str().unwrap(), "https://example.com"); } #[test] fn test_cors_config_specific_origin_mismatch() { let config = CorsConfig { allow_origin: Some("https://example.com".to_string()), max_age: None, expose_headers: None, allow_credentials: false, }; let mut response = Response::new(Body::empty()); config.inject_headers(&mut response, Some("https://evil.com")); let origin = response.headers().get("access-control-allow-origin"); assert!(origin.is_none()); } #[test] fn test_cors_config_expose_headers() { let config = CorsConfig { allow_origin: Some("*".to_string()), max_age: None, expose_headers: Some(vec!["X-Custom-Header".to_string(), "X-Another-Header".to_string()]), allow_credentials: false, }; let mut response = Response::new(Body::empty()); config.inject_headers(&mut response, None); let expose = response.headers().get("access-control-expose-headers"); assert_eq!(expose.unwrap().to_str().unwrap(), "X-Custom-Header, X-Another-Header"); } #[test] fn test_cors_config_allow_credentials() { let config = CorsConfig { allow_origin: Some("https://example.com".to_string()), max_age: None, expose_headers: None, allow_credentials: true, }; let mut response = Response::new(Body::empty()); config.inject_headers(&mut response, Some("https://example.com")); let creds = response.headers().get("access-control-allow-credentials"); assert_eq!(creds.unwrap().to_str().unwrap(), "true"); } #[test] fn test_cors_config_disabled() { let config = CorsConfig::default(); let mut response = Response::new(Body::empty()); config.inject_headers(&mut response, Some("https://example.com")); // No CORS headers should be added assert!(response.headers().get("access-control-allow-origin").is_none()); } #[test] fn test_parse_expose_headers_multiple() { let headers_str = "X-Header-1, X-Header-2, X-Header-3"; let headers: Vec = headers_str.split(',').map(|s| s.trim().to_string()).collect(); assert_eq!(headers.len(), 3); assert_eq!(headers[0], "X-Header-1"); assert_eq!(headers[1], "X-Header-2"); assert_eq!(headers[2], "X-Header-3"); } #[test] fn test_parse_expose_headers_single() { let headers_str = "X-Single-Header"; let headers: Vec = headers_str.split(',').map(|s| s.trim().to_string()).collect(); assert_eq!(headers.len(), 1); assert_eq!(headers[0], "X-Single-Header"); } }