use axum::http::HeaderMap;
use axum::http::header::{HOST, ORIGIN};
pub fn origin_allowed(headers: &HeaderMap) -> bool {
let Some(origin) = header_str(headers, ORIGIN) else {
return true;
};
let Some(origin_authority) = authority_of(origin) else {
return false;
};
if is_loopback_host(origin_authority) {
return true;
}
match header_str(headers, HOST) {
Some(host) => origin_authority.eq_ignore_ascii_case(host),
None => false,
}
}
fn header_str(headers: &HeaderMap, name: axum::http::HeaderName) -> Option<&str> {
headers.get(name).and_then(|v| v.to_str().ok())
}
fn authority_of(origin: &str) -> Option<&str> {
origin
.split_once("://")
.map(|(_scheme, rest)| rest.split('/').next().unwrap_or(rest))
}
fn is_loopback_host(authority: &str) -> bool {
let host = if let Some(rest) = authority.strip_prefix('[') {
rest.split(']').next().unwrap_or(rest)
} else {
authority.split(':').next().unwrap_or(authority)
};
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
#[cfg(test)]
mod origin_guard_tests {
use super::*;
use axum::http::HeaderValue;
fn headers(origin: Option<&str>, host: Option<&str>) -> HeaderMap {
let mut h = HeaderMap::new();
if let Some(o) = origin {
h.insert(ORIGIN, HeaderValue::from_str(o).expect("valid origin"));
}
if let Some(host) = host {
h.insert(HOST, HeaderValue::from_str(host).expect("valid host"));
}
h
}
#[test]
fn no_origin_allowed() {
assert!(origin_allowed(&headers(None, None)));
assert!(origin_allowed(&headers(None, Some("127.0.0.1:8765"))));
}
#[test]
fn loopback_origin_allowed() {
assert!(origin_allowed(&headers(
Some("http://127.0.0.1:8765"),
Some("127.0.0.1:8765")
)));
assert!(origin_allowed(&headers(
Some("http://localhost:3000"),
Some("example.com")
)));
assert!(origin_allowed(&headers(Some("http://localhost"), None)));
assert!(origin_allowed(&headers(
Some("http://[::1]:8765"),
Some("[::1]:8765")
)));
}
#[test]
fn same_origin_allowed() {
assert!(origin_allowed(&headers(
Some("https://daemon.internal:9000"),
Some("daemon.internal:9000")
)));
assert!(origin_allowed(&headers(
Some("https://Daemon.Internal"),
Some("daemon.internal")
)));
}
#[test]
fn same_origin_with_trailing_slash_allowed() {
assert!(origin_allowed(&headers(
Some("https://daemon.internal:9000/"),
Some("daemon.internal:9000")
)));
assert!(origin_allowed(&headers(
Some("http://127.0.0.1:8765/"),
Some("127.0.0.1:8765")
)));
}
#[test]
fn cross_origin_rejected() {
assert!(!origin_allowed(&headers(
Some("https://evil.example.com"),
Some("127.0.0.1:8765")
)));
assert!(!origin_allowed(&headers(
Some("http://daemon.internal:1234"),
Some("daemon.internal:9000")
)));
assert!(!origin_allowed(&headers(
Some("null"),
Some("daemon.internal:9000")
)));
assert!(!origin_allowed(&headers(
Some("https://evil.example.com"),
None
)));
}
}