d6dc02f
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<String>,
pub cors_allow_methods: Vec<String>,
pub cors_allow_headers: Vec<String>,
pub cors_expose_headers: Vec<String>,
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<String> {
let mut origins: Vec<String> = 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<String> {
resolve(&self.cors_allow_methods, DEFAULT_CORS_ALLOW_METHODS)
}
pub fn allow_headers(&self) -> Vec<String> {
resolve(&self.cors_allow_headers, DEFAULT_CORS_ALLOW_HEADERS)
}
pub fn expose_headers(&self) -> Vec<String> {
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<String> {
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<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
impl crate::loader::Section for WebConfig {
const KEY: &'static str = "web";
}