use std::net::IpAddr;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
pub(crate) fn is_localhost_origin(origin: &str) -> bool {
if strip_scheme_ci(origin, "http://")
.or_else(|| strip_scheme_ci(origin, "https://"))
.is_none()
{
return false;
}
let authority = origin
.split_once("://")
.map_or("", |(_, rest)| rest.strip_suffix('/').unwrap_or(rest));
parse_origin(origin).is_ok() && is_localhost_host(authority)
}
fn strip_scheme_ci<'a>(s: &'a str, scheme: &str) -> Option<&'a str> {
let prefix = scheme.as_bytes();
if s.len() >= prefix.len() && s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix) {
Some(&s[prefix.len()..])
} else {
None
}
}
fn is_valid_port(port: &str) -> bool {
!port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) && port.parse::<u16>().is_ok()
}
pub(crate) fn is_localhost_host(host: &str) -> bool {
if let Ok(ip) = host.parse::<IpAddr>() {
return ip.is_loopback();
}
let host_only = if host.starts_with('[') {
let Some(close) = host.find(']') else {
return false;
};
let after_bracket = &host[close + 1..];
let port_ok =
after_bracket.is_empty() || after_bracket.strip_prefix(':').is_some_and(is_valid_port);
if !port_ok {
return false;
}
&host[1..close]
} else {
match host.split_once(':') {
Some((_, port)) if !is_valid_port(port) => return false,
Some((name, _)) => name,
None => host,
}
};
if host_only.eq_ignore_ascii_case("localhost") || host_only.eq_ignore_ascii_case("localhost.") {
return true;
}
host_only.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AllowedOrigin {
Any,
Origin {
scheme: &'static str,
host: String,
port: u16,
},
}
fn parse_origin(value: &str) -> Result<(&'static str, String, u16), String> {
if value.contains('#') {
return Err(format!(
"{value:?} has a fragment, which an Origin cannot carry"
));
}
let uri: axum::http::Uri = value
.parse()
.map_err(|e| format!("{value:?} is not a valid Origin: {e}"))?;
let scheme = uri
.scheme_str()
.ok_or_else(|| format!("{value:?} has no scheme"))?;
let (scheme, default_port) = match scheme.to_ascii_lowercase().as_str() {
"http" => ("http", 80u16),
"https" => ("https", 443u16),
"ws" => ("http", 80u16),
"wss" => ("https", 443u16),
other => return Err(format!("{value:?} has unsupported scheme {other:?}")),
};
let authority = uri
.authority()
.ok_or_else(|| format!("{value:?} has no host"))?;
if authority.as_str().contains('@') {
return Err(format!("{value:?} must not contain userinfo"));
}
let path = uri.path();
if !path.is_empty() && path != "/" {
return Err(format!("{value:?} must not have a path"));
}
if uri.query().is_some() {
return Err(format!("{value:?} must not have a query"));
}
Ok((
scheme,
authority.host().to_ascii_lowercase(),
authority.port_u16().unwrap_or(default_port),
))
}
pub(crate) fn parse_allowed_origins(entries: &[String]) -> Vec<AllowedOrigin> {
entries
.iter()
.filter_map(|entry| {
if entry == "*" {
return Some(AllowedOrigin::Any);
}
match parse_origin(entry) {
Ok((scheme, host, port)) => Some(AllowedOrigin::Origin { scheme, host, port }),
Err(reason) => {
tracing::warn!(
entry = %entry,
"Ignoring invalid allowed_origins entry: {reason} (each entry must be \"*\" \
or a bare origin like \"https://example.com\" or \"https://example.com:8443\")"
);
None
}
}
})
.collect()
}
pub(crate) fn validate_origin(
headers: &HeaderMap,
enabled: bool,
allowed_origins: &[AllowedOrigin],
) -> Option<Response> {
if !enabled {
return None;
}
let mut origins = headers.get_all(header::ORIGIN).iter();
let first = origins.next();
if origins.next().is_some() {
tracing::warn!("Rejecting request: multiple Origin headers");
return Some((StatusCode::FORBIDDEN, "Origin not allowed").into_response());
}
if let Some(origin) = first {
let origin_str = origin.to_str().unwrap_or("");
if is_localhost_origin(origin_str) {
return None;
}
if allowed_origins.is_empty() {
tracing::warn!(
origin = %origin_str,
"Rejecting request: cross-origin not allowed (no allowlist configured)"
);
return Some(
(StatusCode::FORBIDDEN, "Cross-origin requests not allowed").into_response(),
);
}
if allowed_origins
.iter()
.any(|o| matches!(o, AllowedOrigin::Any))
{
return None;
}
let matched = match parse_origin(origin_str) {
Ok((scheme, host, port)) => allowed_origins.iter().any(|o| {
matches!(
o,
AllowedOrigin::Origin {
scheme: s,
host: h,
port: p,
} if *s == scheme && *h == host && *p == port
)
}),
Err(_) => false,
};
if !matched {
tracing::warn!(origin = %origin_str, "Rejecting request: Origin not in allowlist");
return Some((StatusCode::FORBIDDEN, "Origin not allowed").into_response());
}
}
None
}
pub(crate) fn effective_host<'a>(
headers: &'a HeaderMap,
uri: &'a axum::http::Uri,
) -> Option<&'a str> {
if let Some(value) = headers.get(header::HOST)
&& let Ok(s) = value.to_str()
{
return Some(s);
}
uri.authority().map(|a| a.as_str())
}
pub(crate) fn validate_host(
headers: &HeaderMap,
uri: &axum::http::Uri,
enabled: bool,
allowed_hosts: &[String],
) -> Option<Response> {
if !enabled {
return None;
}
let Some(host) = effective_host(headers, uri) else {
if allowed_hosts.is_empty() {
return None;
}
tracing::warn!("Rejecting request: missing Host header and no :authority fallback");
return Some((StatusCode::BAD_REQUEST, "Missing Host header").into_response());
};
if is_localhost_host(host) {
return None;
}
if allowed_hosts.is_empty() {
return None;
}
if allowed_hosts.iter().any(|h| h == host) {
return None;
}
tracing::warn!(host = %host, "Rejecting request: Host not in allowlist");
Some((StatusCode::BAD_REQUEST, "Host not allowed").into_response())
}
#[cfg(test)]
mod tests {
use super::*;
fn header_map(origin: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(header::ORIGIN, origin.parse().unwrap());
headers
}
fn is_allowed(origin: &str, entries: &[&str]) -> bool {
let allowed =
parse_allowed_origins(&entries.iter().map(|s| s.to_string()).collect::<Vec<_>>());
validate_origin(&header_map(origin), true, &allowed).is_none()
}
#[test]
fn default_https_port_normalizes() {
assert!(is_allowed(
"https://example.com",
&["https://example.com:443"]
));
}
#[test]
fn default_http_port_normalizes() {
assert!(is_allowed("http://example.com", &["http://example.com:80"]));
}
#[test]
fn host_case_normalizes() {
assert!(is_allowed("https://example.com", &["https://Example.com"]));
}
#[test]
fn trailing_slash_normalizes() {
assert!(is_allowed("https://example.com", &["https://example.com/"]));
}
#[test]
fn non_default_port_does_not_match() {
assert!(!is_allowed(
"https://example.com",
&["https://example.com:8443"]
));
}
#[test]
fn scheme_mismatch_does_not_match() {
assert!(!is_allowed("https://example.com", &["http://example.com"]));
}
#[test]
fn ws_and_wss_entries_normalize_to_the_browser_visible_scheme() {
assert!(is_allowed("https://example.com", &["wss://example.com"]));
assert!(is_allowed("http://example.com", &["ws://example.com"]));
assert!(!is_allowed("http://example.com", &["wss://example.com"]));
}
#[test]
fn wildcard_matches_anything_including_an_unparseable_origin() {
assert!(is_allowed("https://example.com", &["*"]));
assert!(is_allowed("not a valid origin $$", &["*"]));
}
#[test]
fn unparseable_origin_is_rejected_without_a_wildcard() {
assert!(!is_allowed(
"not a valid origin $$",
&["https://example.com"]
));
}
fn header_map_all(origins: &[&str]) -> HeaderMap {
let mut headers = HeaderMap::new();
for origin in origins {
headers.append(header::ORIGIN, origin.parse().unwrap());
}
headers
}
#[test]
fn duplicate_origin_headers_are_rejected() {
let rejected = |origins: &[&str], entries: &[&str]| {
let allowed =
parse_allowed_origins(&entries.iter().map(|s| s.to_string()).collect::<Vec<_>>());
validate_origin(&header_map_all(origins), true, &allowed)
.is_some_and(|r| r.status() == StatusCode::FORBIDDEN)
};
assert!(rejected(&["http://localhost", "https://evil.example"], &[]));
assert!(rejected(&["https://evil.example", "http://localhost"], &[]));
assert!(rejected(
&["https://app.example", "https://app.example"],
&["https://app.example"]
));
assert!(rejected(&["http://localhost", "http://localhost"], &["*"]));
assert!(!rejected(&["http://localhost"], &[]));
}
#[test]
fn userinfo_does_not_pass_as_localhost() {
for origin in [
"http://localhost:1@evil.com",
"http://localhost@evil.com",
"https://127.0.0.1:443@evil.com",
"http://[::1]:3000@evil.com",
"http://user:pass@localhost",
] {
assert!(!is_localhost_origin(origin), "{origin}");
assert!(!is_allowed(origin, &[]), "{origin}");
assert!(!is_allowed(origin, &["https://app.example"]), "{origin}");
}
}
#[test]
fn malformed_localhost_lookalikes_are_not_localhost() {
for origin in [
"http://localhost/path",
"http://localhost?x=1",
"http://localhost#frag",
"http://localhost:99999",
"http://localhost:abc",
"http://localhost evil.com",
"http://",
"http:///",
"http://localhost:3000, http://evil.com",
] {
assert!(!is_localhost_origin(origin), "{origin}");
assert!(!is_allowed(origin, &["https://app.example"]), "{origin}");
}
}
#[test]
fn legitimate_localhost_origins_still_pass() {
for origin in [
"http://localhost:3000",
"http://127.0.0.1",
"http://127.0.0.1:8080",
"https://localhost",
"http://[::1]:3000",
"HTTP://LocalHost:3000",
] {
assert!(is_localhost_origin(origin), "{origin}");
assert!(is_allowed(origin, &[]), "{origin}");
assert!(is_allowed(origin, &["https://app.example"]), "{origin}");
}
}
#[test]
fn invalid_ports_are_not_localhost() {
for host in [
"localhost:abc",
"localhost:",
"localhost:99999",
"localhost:+80",
"localhost:1:2",
] {
assert!(!is_localhost_host(host), "{host}");
}
assert!(is_localhost_host("localhost:65535"));
}
#[test]
fn invalid_entries_are_rejected_with_a_reason() {
for (entry, reason) in [
("https://example.com/app", "must not have a path"),
("https://example.com?x=1", "must not have a query"),
("https://example.com#frag", "has a fragment"),
("https://user:pass@example.com", "must not contain userinfo"),
("ftp://example.com", "unsupported scheme"),
("example.com", "has no scheme"),
] {
let error = parse_origin(entry).expect_err(entry);
assert!(error.contains(reason), "{entry}: {error}");
}
}
#[test]
fn invalid_entries_are_skipped_and_valid_ones_kept() {
let parsed = parse_allowed_origins(&[
"https://example.com/app".to_string(),
"https://Example.com:443".to_string(),
"ftp://example.com".to_string(),
"*".to_string(),
]);
assert_eq!(parsed.len(), 2, "{parsed:?}");
assert!(matches!(parsed[1], AllowedOrigin::Any));
}
}