use axum::http::{header, HeaderName, HeaderValue, Method};
use tower_http::cors::{AllowOrigin, CorsLayer};
pub const CORS_ORIGINS_ENV: &str = "MUSHROOMDB_CORS_ORIGINS";
pub fn parse_cors_origins(spec: &str) -> Result<Option<CorsLayer>, String> {
let entries: Vec<&str> = spec
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if entries.is_empty() {
return Ok(None);
}
if entries.contains(&"*") {
if entries.len() > 1 {
return Err(format!(
"{CORS_ORIGINS_ENV}: `*` cannot be combined with other origins"
));
}
return Ok(Some(CorsLayer::permissive()));
}
let origins = entries
.iter()
.map(|entry| parse_origin(entry))
.collect::<Result<Vec<HeaderValue>, String>>()?;
Ok(Some(
CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([
header::AUTHORIZATION,
header::CONTENT_TYPE,
HeaderName::from_static("x-request-id"),
])
.allow_credentials(true),
))
}
pub(crate) async fn strip_unless_allowed(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let mut res = next.run(req).await;
if !res
.headers()
.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN)
{
let doomed: Vec<HeaderName> = res
.headers()
.keys()
.filter(|k| k.as_str().starts_with("access-control-"))
.cloned()
.collect();
for name in doomed {
res.headers_mut().remove(name);
}
}
res
}
pub fn cors_layer_from_env() -> Result<Option<CorsLayer>, String> {
match std::env::var(CORS_ORIGINS_ENV) {
Ok(spec) => parse_cors_origins(&spec),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => {
Err(format!("{CORS_ORIGINS_ENV}: value is not valid UTF-8"))
}
}
}
fn parse_origin(entry: &str) -> Result<HeaderValue, String> {
let bad = |why: &str| format!("{CORS_ORIGINS_ENV}: invalid origin `{entry}`: {why}");
let rest = entry
.strip_prefix("http://")
.or_else(|| entry.strip_prefix("https://"))
.ok_or_else(|| bad("must start with http:// or https://"))?;
if rest.is_empty()
|| rest.contains(['/', '?', '#', '@', '*'])
|| rest.contains(char::is_whitespace)
{
return Err(bad(
"must be scheme://host[:port] with no path, trailing slash or wildcard",
));
}
HeaderValue::from_str(entry).map_err(|_| bad("not a valid header value"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blank_is_none() {
assert!(parse_cors_origins("").unwrap().is_none());
assert!(parse_cors_origins(" , ").unwrap().is_none());
}
#[test]
fn rejects_bad_origins() {
for bad in [
"example.com",
"https://example.com/",
"https://example.com/path",
"ftp://example.com",
"https://",
"*,https://a.com",
"https://*.example.com",
] {
assert!(parse_cors_origins(bad).is_err(), "{bad} should be rejected");
}
}
#[test]
fn accepts_lists_and_wildcard() {
assert!(parse_cors_origins("*").unwrap().is_some());
assert!(parse_cors_origins("https://a.com, http://localhost:3000")
.unwrap()
.is_some());
}
}