pub fn normalize_origin(origin: &str) -> String {
let origin = origin.trim();
let Some((scheme, authority)) = origin.split_once("://") else {
return origin.to_ascii_lowercase();
};
let scheme = scheme.to_ascii_lowercase();
let (host, port) = split_authority(authority);
let mut out = format!("{scheme}://{}", host.to_ascii_lowercase());
if let Some(port) = port {
if !port.is_empty() && !is_default_port(&scheme, port) {
out.push(':');
out.push_str(port);
}
}
out
}
fn split_authority(authority: &str) -> (&str, Option<&str>) {
if authority.starts_with('[') {
if let Some(close) = authority.find(']') {
match authority[close + 1..].strip_prefix(':') {
Some(port) => return (&authority[..close + 1], Some(port)),
None => return (authority, None),
}
}
return (authority, None);
}
match authority.split_once(':') {
Some((host, port)) => (host, Some(port)),
None => (authority, None),
}
}
fn is_default_port(scheme: &str, port: &str) -> bool {
port.parse::<u16>().is_ok_and(|p| {
matches!(
(scheme, p),
("http", 80) | ("https", 443) | ("ws", 80) | ("wss", 443)
)
})
}
pub fn origin_allowed(origin: Option<&str>, allowed_origins: &[String]) -> bool {
if allowed_origins.is_empty() {
return true;
}
let Some(origin) = origin else {
return false;
};
let normalized = normalize_origin(origin);
allowed_origins
.iter()
.any(|entry| normalize_origin(entry) == normalized)
}
#[cfg(feature = "axum")]
pub fn origin_allowed_in_parts(parts: &http::request::Parts, allowed_origins: &[String]) -> bool {
let origin = parts
.headers
.get(http::header::ORIGIN)
.and_then(|value| value.to_str().ok());
origin_allowed(origin, allowed_origins)
}
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_origin_basic_and_case() {
assert_eq!(
normalize_origin("https://example.com"),
"https://example.com"
);
assert_eq!(
normalize_origin("HTTPS://EXAMPLE.COM"),
"https://example.com"
);
assert_eq!(
normalize_origin("https://EXAMPLE.com"),
"https://example.com"
);
assert_eq!(
normalize_origin(" https://example.com "),
"https://example.com"
);
}
#[test]
fn normalize_origin_default_ports_omitted() {
assert_eq!(
normalize_origin("https://example.com:443"),
"https://example.com"
);
assert_eq!(
normalize_origin("http://example.com:80"),
"http://example.com"
);
assert_eq!(
normalize_origin("wss://example.com:443"),
"wss://example.com"
);
assert_eq!(normalize_origin("ws://example.com:80"), "ws://example.com");
}
#[test]
fn normalize_origin_custom_ports_kept() {
assert_eq!(
normalize_origin("https://example.com:8443"),
"https://example.com:8443"
);
assert_ne!(
normalize_origin("https://example.com:8443"),
normalize_origin("https://example.com")
);
assert_ne!(
normalize_origin("http://example.com:8080"),
normalize_origin("http://example.com")
);
}
#[test]
fn normalize_origin_ipv6() {
assert_eq!(
normalize_origin("https://[2001:DB8::1]:443"),
"https://[2001:db8::1]"
);
assert_eq!(normalize_origin("http://[::1]:8080"), "http://[::1]:8080");
assert_eq!(normalize_origin("http://[::1]"), "http://[::1]");
}
#[test]
fn normalize_origin_no_scheme_fail_closed() {
assert_eq!(normalize_origin("example.com"), "example.com");
assert_eq!(normalize_origin("localhost:3000"), "localhost:3000");
}
#[test]
fn origin_allowed_empty_list_allows_all() {
assert!(origin_allowed(Some("https://anything.dev"), &[]));
assert!(origin_allowed(None, &[]));
}
#[test]
fn origin_allowed_match_and_normalization() {
let allowed = vec!["https://example.com".to_string()];
assert!(origin_allowed(Some("https://example.com"), &allowed));
assert!(origin_allowed(Some("https://example.com:443"), &allowed));
assert!(origin_allowed(Some("HTTPS://EXAMPLE.COM"), &allowed));
let allowed443 = vec!["https://example.com:443".to_string()];
assert!(origin_allowed(Some("https://example.com"), &allowed443));
}
#[test]
fn origin_allowed_missing_origin_rejected_when_listed() {
let allowed = vec!["https://example.com".to_string()];
assert!(!origin_allowed(None, &allowed));
}
#[test]
fn origin_allowed_no_wildcard_or_suffix_matching() {
let allowed = vec!["https://example.com".to_string()];
assert!(!origin_allowed(Some("https://evil-example.com"), &allowed));
assert!(!origin_allowed(
Some("https://example.com.evil.com"),
&allowed
));
assert!(!origin_allowed(Some("https://api.example.com"), &allowed));
assert!(!origin_allowed(Some("https://example.com:8443"), &allowed));
}
#[test]
fn origin_allowed_scheme_matters() {
let allowed = vec!["https://example.com".to_string()];
assert!(!origin_allowed(Some("http://example.com"), &allowed));
assert!(!origin_allowed(Some("ws://example.com"), &allowed));
}
#[cfg(feature = "axum")]
mod parts_tests {
use super::*;
use http::{HeaderMap, Request};
fn parts_with_origin(origin: Option<&str>) -> http::request::Parts {
let mut builder = Request::builder().uri("/ws");
if let Some(o) = origin {
builder = builder.header(http::header::ORIGIN, o);
}
let (parts, _) = builder.body(()).unwrap().into_parts();
parts
}
#[test]
fn origin_allowed_in_parts_reads_header() {
let allowed = vec!["https://example.com".to_string()];
assert!(origin_allowed_in_parts(
&parts_with_origin(Some("https://example.com")),
&allowed
));
assert!(!origin_allowed_in_parts(
&parts_with_origin(Some("https://evil.com")),
&allowed
));
assert!(!origin_allowed_in_parts(&parts_with_origin(None), &allowed));
assert!(origin_allowed_in_parts(&parts_with_origin(None), &[]));
}
#[test]
fn origin_allowed_in_parts_host_header_ignored() {
let allowed = vec!["https://example.com".to_string()];
let mut headers = HeaderMap::new();
headers.insert(http::header::HOST, "example.com".parse().unwrap());
let req = Request::builder().uri("/ws").body(()).unwrap();
let (mut parts, _) = req.into_parts();
parts.headers = headers;
assert!(!origin_allowed_in_parts(&parts, &allowed));
}
}
}