use serde::{Deserialize, Serialize}; pub const DEFAULT_CORS_ALLOW_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; pub const DEFAULT_CORS_ALLOW_HEADERS: &[&str] = &[ "accept", "authorization", "content-type", "x-csrf-token", "x-request-id", ]; pub const DEFAULT_CORS_EXPOSE_HEADERS: &[&str] = &[ "cache-control", "content-disposition", "etag", "location", "retry-after", "x-request-id", ]; pub const DEFAULT_CORS_MAX_AGE_SECS: u64 = 600; pub const MAX_CORS_MAX_AGE_SECS: u64 = 86_400; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct WebConfig { pub web_base_url: String, pub cors_allow_origins: Vec, pub cors_allow_methods: Vec, pub cors_allow_headers: Vec, pub cors_expose_headers: Vec, pub cors_allow_credentials: bool, pub cors_max_age_secs: u64, } impl Default for WebConfig { fn default() -> Self { Self { web_base_url: String::new(), cors_allow_origins: Vec::new(), cors_allow_methods: expand(DEFAULT_CORS_ALLOW_METHODS), cors_allow_headers: expand(DEFAULT_CORS_ALLOW_HEADERS), cors_expose_headers: expand(DEFAULT_CORS_EXPOSE_HEADERS), cors_allow_credentials: false, cors_max_age_secs: DEFAULT_CORS_MAX_AGE_SECS, } } } impl WebConfig { pub fn allow_origins(&self) -> Vec { let mut origins: Vec = Vec::new(); for raw in std::iter::once(&self.web_base_url).chain(self.cors_allow_origins.iter()) { let origin = raw.trim().trim_end_matches('/'); if origin.is_empty() { continue; } if origins .iter() .any(|existing| existing.eq_ignore_ascii_case(origin)) { continue; } origins.push(origin.to_owned()); } origins } pub fn allow_any_origin(&self) -> bool { self.allow_origins().iter().any(|origin| origin == "*") } pub fn allow_methods(&self) -> Vec { resolve(&self.cors_allow_methods, DEFAULT_CORS_ALLOW_METHODS) } pub fn allow_headers(&self) -> Vec { resolve(&self.cors_allow_headers, DEFAULT_CORS_ALLOW_HEADERS) } pub fn expose_headers(&self) -> Vec { resolve(&self.cors_expose_headers, DEFAULT_CORS_EXPOSE_HEADERS) } pub fn is_valid(&self) -> Result<(), String> { let wildcard = self.allow_any_origin(); if wildcard && self.allow_origins().len() != 1 { return Err( "the allowed origins must list \"*\" alone or name explicit origins".into(), ); } if wildcard && self.cors_allow_credentials { return Err( "web.cors_allow_credentials must be false when the allowed origins include \"*\"" .into(), ); } for origin in &self.cors_allow_origins { let origin = origin.trim(); if origin.is_empty() || origin == "*" { continue; } if !is_origin(origin) { return Err(format!( "web.cors_allow_origins entry {origin:?} must be scheme://host[:port] without path or trailing slash" )); } } let base = self.web_base_url.trim().trim_end_matches('/'); if !base.is_empty() && base != "*" && !is_origin(base) { return Err(format!( "web.web_base_url {base:?} must be scheme://host[:port] without path" )); } for method in &self.cors_allow_methods { let method = method.trim(); if method.is_empty() || !method.bytes().all(|byte| byte.is_ascii_uppercase()) { return Err(format!( "web.cors_allow_methods entry {method:?} must be an upper case HTTP method" )); } } for header in &self.cors_allow_headers { if header.trim().is_empty() { return Err("web.cors_allow_headers entry must not be empty".into()); } } for header in &self.cors_expose_headers { if header.trim().is_empty() { return Err("web.cors_expose_headers entry must not be empty".into()); } } if self.cors_max_age_secs > MAX_CORS_MAX_AGE_SECS { return Err(format!( "web.cors_max_age_secs must be between 0 and {MAX_CORS_MAX_AGE_SECS}" )); } Ok(()) } } fn is_origin(origin: &str) -> bool { let Some(rest) = origin .strip_prefix("https://") .or_else(|| origin.strip_prefix("http://")) else { return false; }; !rest.is_empty() && !rest.contains(['/', '?', '#', ' ', ',']) } fn resolve(configured: &[String], fallback: &[&str]) -> Vec { if configured.is_empty() { return expand(fallback); } configured .iter() .map(|value| value.trim().to_owned()) .filter(|value| !value.is_empty()) .collect() } fn expand(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_owned()).collect() } impl crate::loader::Section for WebConfig { const KEY: &'static str = "web"; }