use anyhow::{bail, Result};
use axum::{
extract::{Request, State},
http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use std::sync::Arc;
use tower_http::cors::{AllowOrigin, CorsLayer};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CorsOrigins {
Any,
List(Vec<HeaderValue>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CorsConfig {
origins: CorsOrigins,
allow_credentials: bool,
}
impl CorsConfig {
pub fn from_args(origins: &[String], allow_credentials: bool) -> Result<Self> {
let wildcard = origins.iter().any(|o| o == "*");
if wildcard && origins.iter().any(|o| o != "*") {
bail!(
"--cors-origin '*' cannot be combined with explicit origins; \
pass either '*' or a list of origins, not both"
);
}
let origins = if origins.is_empty() || wildcard {
CorsOrigins::Any
} else {
let parsed = origins
.iter()
.map(|origin| parse_origin(origin))
.collect::<Result<Vec<_>>>()?;
CorsOrigins::List(parsed)
};
if allow_credentials && matches!(origins, CorsOrigins::Any) {
bail!(
"--cors-allow-credentials requires explicit --cors-origin values; \
browsers reject credentialed requests against a wildcard origin"
);
}
Ok(Self {
origins,
allow_credentials,
})
}
fn methods(&self) -> Vec<Method> {
match &self.origins {
CorsOrigins::Any => vec![Method::GET],
CorsOrigins::List(_) => {
vec![Method::GET, Method::POST, Method::PUT, Method::DELETE]
}
}
}
pub fn layer(&self) -> CorsLayer {
let allow_origin = match &self.origins {
CorsOrigins::Any => AllowOrigin::any(),
CorsOrigins::List(list) => AllowOrigin::list(list.iter().cloned()),
};
CorsLayer::new()
.allow_origin(allow_origin)
.allow_methods(self.methods())
.allow_headers([header::CONTENT_TYPE])
.allow_credentials(self.allow_credentials)
}
fn allows_write(&self, method: &Method, headers: &HeaderMap, host: &str) -> bool {
if *method == Method::GET || *method == Method::HEAD || *method == Method::OPTIONS {
return true;
}
let Some(origin) = headers.get(header::ORIGIN) else {
return true;
};
let Ok(origin) = origin.to_str() else {
return false;
};
origin_matches_host(origin, host) || self.lists_origin(origin)
}
fn lists_origin(&self, origin: &str) -> bool {
match &self.origins {
CorsOrigins::Any => false,
CorsOrigins::List(list) => list
.iter()
.any(|listed| listed.as_bytes() == origin.as_bytes()),
}
}
}
pub(super) fn host_header(headers: &HeaderMap) -> Option<&str> {
let mut hosts = headers.get_all(header::HOST).iter();
let host = hosts.next()?;
if hosts.next().is_some() {
return None;
}
host.to_str().ok()
}
fn request_host(request: &Request) -> Option<&str> {
request_authority(request.headers(), request.uri())
}
pub(super) fn request_authority<'a>(headers: &'a HeaderMap, uri: &'a Uri) -> Option<&'a str> {
host_header(headers).or_else(|| uri.authority().map(|authority| authority.as_str()))
}
pub(super) fn origin_matches_host(origin: &str, host: &str) -> bool {
let Ok(url) = url::Url::parse(origin) else {
return false;
};
let Some(origin_host) = url.host_str() else {
return false;
};
match url.port() {
Some(port) => host.eq_ignore_ascii_case(&format!("{origin_host}:{port}")),
None => match url.port_or_known_default() {
Some(default) => {
host.eq_ignore_ascii_case(origin_host)
|| host.eq_ignore_ascii_case(&format!("{origin_host}:{default}"))
}
None => host.eq_ignore_ascii_case(origin_host),
},
}
}
fn parse_origin(origin: &str) -> Result<HeaderValue> {
let Some((scheme, rest)) = origin.split_once("://") else {
bail!("invalid --cors-origin {origin:?}: expected a scheme, e.g. http://localhost:3000");
};
if scheme.is_empty() || rest.is_empty() {
bail!("invalid --cors-origin {origin:?}: expected scheme://host[:port]");
}
if !scheme
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '-' | '.'))
{
bail!(
"invalid --cors-origin {origin:?}: {scheme:?} is not a valid lowercase scheme, \
e.g. http://localhost:3000"
);
}
if let Some(bad) = rest
.chars()
.find(|c| c.is_whitespace() || c.is_ascii_uppercase() || matches!(c, '/' | '?' | '#' | '@'))
{
bail!(
"invalid --cors-origin {origin:?}: a browser sends a bare lowercase origin, \
so {bad:?} can never match; expected scheme://host[:port], \
e.g. http://localhost:3000"
);
}
let (host, port) = if let Some(rest) = rest.strip_prefix('[') {
let Some((host, after)) = rest.split_once(']') else {
bail!(
"invalid --cors-origin {origin:?}: unterminated IPv6 host, e.g. http://[::1]:3000"
);
};
let port = match after {
"" => None,
_ => Some(after.strip_prefix(':').ok_or_else(|| {
anyhow::anyhow!("invalid --cors-origin {origin:?}: expected scheme://host[:port]")
})?),
};
(host, port)
} else {
match rest.rsplit_once(':') {
Some((host, port)) => (host, Some(port)),
None => (rest, None),
}
};
if host.is_empty() {
bail!("invalid --cors-origin {origin:?}: missing host, expected scheme://host[:port]");
}
if let Some(port) = port {
if port.parse::<u16>().is_err() {
bail!("invalid --cors-origin {origin:?}: {port:?} is not a valid port number");
}
}
HeaderValue::from_str(origin)
.map_err(|e| anyhow::anyhow!("invalid --cors-origin {origin:?}: {e}"))
}
pub async fn write_guard(
State(config): State<Arc<CorsConfig>>,
request: Request,
next: Next,
) -> Response {
let host = request_host(&request).unwrap_or_default();
if config.allows_write(request.method(), request.headers(), host) {
return next.run(request).await;
}
tracing::warn!(
method = %request.method(),
path = %request.uri().path(),
"refused a cross-origin request that would modify recipes"
);
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "Cross-origin requests may not modify recipes. Start the server with \
--cors-origin <ORIGIN> to allow this origin, or --no-csrf-check to \
disable this check."
})),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn origins(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn no_flag_defaults_to_any() {
let config = CorsConfig::from_args(&[], false).expect("valid");
assert_eq!(config.origins, CorsOrigins::Any);
assert!(!config.allow_credentials);
}
#[test]
fn explicit_wildcard_is_any() {
let config = CorsConfig::from_args(&origins(&["*"]), false).expect("valid");
assert_eq!(config.origins, CorsOrigins::Any);
assert_eq!(config.methods(), vec![Method::GET]);
}
#[test]
fn any_allows_only_get() {
let config = CorsConfig::from_args(&[], false).expect("valid");
assert_eq!(config.methods(), vec![Method::GET]);
}
#[test]
fn explicit_origins_keep_order_and_allow_mutation() {
let config =
CorsConfig::from_args(&origins(&["http://a.test", "https://b.test:8443"]), false)
.expect("valid");
assert_eq!(
config.origins,
CorsOrigins::List(vec![
HeaderValue::from_static("http://a.test"),
HeaderValue::from_static("https://b.test:8443"),
])
);
assert_eq!(
config.methods(),
vec![Method::GET, Method::POST, Method::PUT, Method::DELETE]
);
}
#[test]
fn wildcard_mixed_with_explicit_origin_is_rejected() {
let err = CorsConfig::from_args(&origins(&["*", "http://a.test"]), false)
.expect_err("must reject");
assert!(
err.to_string().contains("cannot be combined"),
"unhelpful error: {err}"
);
}
#[test]
fn credentials_with_wildcard_is_rejected() {
let err = CorsConfig::from_args(&[], true).expect_err("must reject");
assert!(
err.to_string().contains("--cors-origin"),
"error must point at the fix: {err}"
);
let err = CorsConfig::from_args(&origins(&["*"]), true).expect_err("must reject");
assert!(
err.to_string().contains("--cors-origin"),
"error must point at the fix: {err}"
);
}
#[test]
fn credentials_with_explicit_origins_is_allowed() {
let config = CorsConfig::from_args(&origins(&["http://a.test"]), true).expect("valid");
assert!(config.allow_credentials);
}
#[test]
fn origin_with_trailing_slash_is_rejected() {
let err =
CorsConfig::from_args(&origins(&["http://a.test/"]), false).expect_err("must reject");
assert!(
err.to_string().contains("http://a.test/"),
"error must name the bad origin: {err}"
);
}
#[test]
fn origin_without_scheme_is_rejected() {
let err = CorsConfig::from_args(&origins(&["a.test"]), false).expect_err("must reject");
assert!(
err.to_string().contains("expected a scheme"),
"unhelpful error: {err}"
);
}
#[test]
fn empty_origin_is_rejected() {
let err = CorsConfig::from_args(&origins(&[""]), false).expect_err("must reject");
assert!(
err.to_string().contains("expected a scheme"),
"unhelpful error: {err}"
);
}
#[test]
fn origins_that_could_never_match_a_browser_are_rejected() {
for bad in [
"HTTP://A.test",
"http://a.test ",
" http://a.test",
"http://a.test\t",
"http://a.test/foo",
"http://a.test?x=1",
"http://a.test#frag",
"http://user:pass@a.test",
"http://a.test:notaport",
"http://a.test:99999",
"*://a.test",
"://a.test",
"http://[::1",
"http://[::1]x",
"http://[]",
"http://:3000",
] {
CorsConfig::from_args(&origins(&[bad]), false)
.expect_err(&format!("{bad:?} must be rejected"));
}
}
#[test]
fn unusual_but_valid_origins_are_accepted() {
for good in [
"chrome-extension://abcdefghijklmnop",
"moz-extension://abcdefghijklmnop",
"tauri://localhost",
"https://a.test:8443",
"http://[::1]:3000",
"http://[::1]",
] {
CorsConfig::from_args(&origins(&[good]), false)
.unwrap_or_else(|e| panic!("{good:?} must be accepted: {e}"));
}
}
#[test]
fn userinfo_is_reported_as_userinfo_not_as_a_bad_port() {
let err = CorsConfig::from_args(&origins(&["http://user:pass@a.test"]), false)
.expect_err("must reject");
assert!(
err.to_string().contains("bare lowercase origin"),
"userinfo must not be misreported as a bad port: {err}"
);
}
fn assert_layer_applies(config: &CorsConfig) {
let _ = axum::Router::<()>::new()
.route("/", axum::routing::get(|| async {}))
.layer(config.layer());
}
#[test]
fn layer_applies_for_wildcard() {
let config = CorsConfig::from_args(&[], false).expect("valid");
assert_layer_applies(&config);
}
#[test]
fn layer_applies_for_explicit_origins_with_credentials() {
let config = CorsConfig::from_args(&origins(&["http://a.test"]), true).expect("valid");
assert_layer_applies(&config);
}
fn headers_with_origin(origin: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
header::ORIGIN,
HeaderValue::from_str(origin).expect("valid"),
);
headers
}
#[test]
fn origin_matches_its_own_host() {
assert!(origin_matches_host("http://a.test:9080", "a.test:9080"));
assert!(origin_matches_host("http://a.test", "a.test"));
assert!(origin_matches_host("https://a.test", "a.test:443"));
assert!(origin_matches_host("http://a.test", "a.test:80"));
assert!(origin_matches_host("http://A.Test:9080", "a.test:9080"));
assert!(origin_matches_host("http://a.test:9080", "A.TEST:9080"));
}
#[test]
fn origin_on_a_different_port_is_not_the_same_host() {
assert!(!origin_matches_host("http://a.test:9080", "a.test"));
assert!(!origin_matches_host("http://a.test:9080", "a.test:3000"));
assert!(!origin_matches_host("http://a.test", "b.test"));
assert!(!origin_matches_host("not a url", "a.test"));
}
#[test]
fn the_scheme_is_not_compared() {
assert!(origin_matches_host(
"https://cook.example.test",
"cook.example.test"
));
assert!(!origin_matches_host(
"https://cook.example.test",
"cook.example.test:80"
));
}
fn headers_from(headers: &[(&str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (name, value) in headers {
map.append(
axum::http::HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
HeaderValue::from_str(value).expect("valid header value"),
);
}
map
}
fn request_with(headers: &[(&str, &str)]) -> Request {
let mut builder = axum::http::Request::builder().uri("/api/pantry/add");
for (name, value) in headers {
builder = builder.header(*name, *value);
}
builder
.body(axum::body::Body::empty())
.expect("valid request")
}
#[test]
fn host_comes_from_the_host_header_not_a_forwarded_one() {
let request = request_with(&[
("host", "127.0.0.1:9080"),
("x-forwarded-host", "evil.test"),
("forwarded", "host=evil.test"),
]);
assert_eq!(request_host(&request), Some("127.0.0.1:9080"));
}
#[test]
fn a_request_with_no_host_at_all_has_no_authority() {
let request = request_with(&[]);
assert_eq!(request_host(&request), None);
}
#[test]
fn two_host_headers_are_treated_as_none() {
let request = request_with(&[("host", "evil.test"), ("host", "127.0.0.1:9080")]);
assert_eq!(request_host(&request), None);
}
#[test]
fn request_authority_ignores_a_forwarded_host() {
let headers = headers_from(&[
("host", "127.0.0.1:9080"),
("x-forwarded-host", "evil.test"),
]);
let uri: Uri = "/api-docs".parse().expect("valid uri");
assert_eq!(request_authority(&headers, &uri), Some("127.0.0.1:9080"));
}
#[test]
fn request_authority_has_nothing_without_a_host_or_authority() {
let headers = headers_from(&[]);
let uri: Uri = "/api-docs".parse().expect("valid uri");
assert_eq!(request_authority(&headers, &uri), None);
}
#[test]
fn http2_falls_back_to_the_uri_authority() {
let request = axum::http::Request::builder()
.uri("http://127.0.0.1:9080/api/pantry/add")
.body(axum::body::Body::empty())
.expect("valid request");
assert_eq!(request_host(&request), Some("127.0.0.1:9080"));
}
#[test]
fn reads_are_always_allowed() {
let config = CorsConfig::from_args(&[], false).expect("valid");
for method in [Method::GET, Method::HEAD, Method::OPTIONS] {
assert!(
config.allows_write(&method, &headers_with_origin("http://evil.test"), "a.test"),
"{method} must pass the guard"
);
}
}
#[test]
fn a_request_without_an_origin_is_not_a_browser() {
let config = CorsConfig::from_args(&[], false).expect("valid");
assert!(config.allows_write(&Method::POST, &HeaderMap::new(), "a.test"));
}
#[test]
fn same_origin_writes_are_allowed() {
let config = CorsConfig::from_args(&[], false).expect("valid");
assert!(config.allows_write(
&Method::POST,
&headers_with_origin("http://a.test:9080"),
"a.test:9080"
));
}
#[test]
fn cross_origin_writes_are_refused_under_the_wildcard_default() {
let config = CorsConfig::from_args(&[], false).expect("valid");
for method in [Method::POST, Method::PUT, Method::DELETE] {
assert!(
!config.allows_write(
&method,
&headers_with_origin("http://evil.test"),
"a.test:9080"
),
"cross-origin {method} must be refused"
);
}
}
#[test]
fn a_listed_origin_may_write() {
let config =
CorsConfig::from_args(&origins(&["http://app.test:3000"]), false).expect("valid");
assert!(config.allows_write(
&Method::POST,
&headers_with_origin("http://app.test:3000"),
"a.test:9080"
));
assert!(!config.allows_write(
&Method::POST,
&headers_with_origin("http://evil.test"),
"a.test:9080"
));
}
}