use crate::error::{Error, ErrorCode};
use http::HeaderMap;
use std::sync::Arc;
#[derive(Debug, Clone, Default)]
pub(crate) enum OriginPolicy {
#[default]
Loopback,
Allowlist(Arc<[Box<str>]>),
Any,
}
impl OriginPolicy {
pub(crate) fn for_addr(addr: &str) -> Self {
if is_loopback_host(host_of(addr)) {
Self::Loopback
} else {
Self::Any
}
}
pub(crate) fn rejection(&self, headers: &HeaderMap) -> Option<Error> {
if matches!(self, Self::Any) {
return None;
}
let refuse = |header: &str, value: &str| {
Some(Error::new(
ErrorCode::InvalidRequest,
format!("Request rejected: {header} {value:?} is not allowed by this server"),
))
};
if let Some(origin) = header(headers, "origin")
&& !self.allows_origin(origin)
{
return refuse("Origin", origin);
}
if let Some(value) = header(headers, "host")
&& !self.allows_host(host_of(value))
{
return refuse("Host", value);
}
None
}
fn allows_origin(&self, origin: &str) -> bool {
if matches!(self, Self::Any) {
return true;
}
let Some((scheme, host, port)) = split_origin(origin) else {
return false;
};
if is_loopback_host(host) {
return true;
}
match self {
Self::Any | Self::Loopback => false,
Self::Allowlist(allowed) => allowed
.iter()
.any(|entry| entry_allows_origin(entry, scheme, host, port)),
}
}
fn allows_host(&self, host: &str) -> bool {
match self {
Self::Any => true,
Self::Loopback => is_loopback_host(host),
Self::Allowlist(allowed) => {
is_loopback_host(host)
|| allowed
.iter()
.any(|entry| entry_host(entry).eq_ignore_ascii_case(host))
}
}
}
}
fn entry_host(entry: &str) -> &str {
host_of(entry.split_once("://").map_or(entry, |(_, rest)| rest))
}
fn entry_allows_origin(entry: &str, scheme: &str, host: &str, port: Option<&str>) -> bool {
match entry.split_once("://") {
Some((entry_scheme, rest)) => {
entry_scheme.eq_ignore_ascii_case(scheme)
&& host_of(rest).eq_ignore_ascii_case(host)
&& stated_port(entry_scheme, port_of(rest)) == stated_port(scheme, port)
}
None => {
host_of(entry).eq_ignore_ascii_case(host)
&& port_of(entry)
.is_none_or(|entry_port| Some(entry_port) == stated_port(scheme, port))
}
}
}
fn stated_port<'a>(scheme: &str, port: Option<&'a str>) -> Option<&'a str> {
port.or(match scheme.to_ascii_lowercase().as_str() {
"http" => Some("80"),
"https" => Some("443"),
_ => None,
})
}
fn header<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
headers.get(name).and_then(|v| v.to_str().ok())
}
fn split_origin(origin: &str) -> Option<(&str, &str, Option<&str>)> {
let (scheme, rest) = origin.trim().split_once("://")?;
if scheme.is_empty() || rest.is_empty() {
return None;
}
Some((scheme, host_of(rest), port_of(rest)))
}
fn port_of(value: &str) -> Option<&str> {
let value = value.trim();
let rest = match value.find(']') {
Some(end) => &value[end + 1..],
None if value.matches(':').count() > 1 => return None,
None => value,
};
rest.split_once(':')
.map(|(_, port)| port)
.filter(|port| !port.is_empty())
}
fn host_of(value: &str) -> &str {
let value = value.trim();
if let Some(end) = value.find(']') {
return &value[..=end];
}
match value.split_once(':') {
Some(_) if value.matches(':').count() > 1 => value,
Some((host, _)) => host,
None => value,
}
}
fn is_loopback_host(host: &str) -> bool {
let host = host.trim_start_matches('[').trim_end_matches(']');
if host.eq_ignore_ascii_case("localhost") {
return true;
}
match host.parse::<std::net::IpAddr>() {
Ok(ip) => ip.is_loopback(),
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (name, value) in pairs {
map.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
http::HeaderValue::from_str(value).unwrap(),
);
}
map
}
#[test]
fn loopback_policy_accepts_local_names_on_any_port() {
let policy = OriginPolicy::Loopback;
for host in [
"localhost:3000",
"127.0.0.1:8080",
"[::1]:3000",
"127.0.0.5",
] {
assert!(
policy
.rejection(&headers(&[
("host", host),
("origin", &format!("http://{host}"))
]))
.is_none(),
"`{host}` must be accepted"
);
}
}
#[test]
fn loopback_policy_rejects_a_rebound_name() {
let policy = OriginPolicy::Loopback;
let rejected = policy.rejection(&headers(&[
("host", "evil.example.com"),
("origin", "http://evil.example.com"),
]));
assert!(rejected.is_some(), "a rebound name must be rejected");
assert!(
policy
.rejection(&headers(&[("host", "evil.example.com")]))
.is_some()
);
assert!(
policy
.rejection(&headers(&[
("host", "127.0.0.1:3000"),
("origin", "https://evil.example.com")
]))
.is_some()
);
}
#[test]
fn a_request_naming_nothing_is_left_alone() {
assert!(
OriginPolicy::Loopback
.rejection(&HeaderMap::new())
.is_none()
);
}
#[test]
fn the_opaque_origin_is_not_a_local_one() {
assert!(
OriginPolicy::Loopback
.rejection(&headers(&[("origin", "null")]))
.is_some()
);
}
#[test]
fn an_allowlist_extends_loopback_rather_than_replacing_it() {
let policy = OriginPolicy::Allowlist(Arc::from([Box::from("app.example.com")]));
assert!(
policy
.rejection(&headers(&[("origin", "https://app.example.com")]))
.is_none()
);
assert!(
policy
.rejection(&headers(&[("host", "app.example.com:8443")]))
.is_none()
);
assert!(
policy
.rejection(&headers(&[("host", "127.0.0.1:3000")]))
.is_none()
);
assert!(
policy
.rejection(&headers(&[("origin", "https://evil.example.com")]))
.is_some()
);
}
#[test]
fn a_listed_origin_is_matched_whole() {
let policy = OriginPolicy::Allowlist(Arc::from([Box::from("https://app.example.com")]));
let allows = |origin: &str| {
policy
.rejection(&headers(&[("origin", origin), ("host", "app.example.com")]))
.is_none()
};
assert!(allows("https://app.example.com"));
assert!(allows("https://app.example.com:443"));
assert!(!allows("http://app.example.com"), "another scheme");
assert!(!allows("http://app.example.com:8080"), "another port");
assert!(!allows("https://app.example.com:8443"), "another port");
assert!(!allows("https://other.example.com"), "another host");
}
#[test]
fn a_listed_host_covers_the_schemes_it_did_not_name() {
let policy = OriginPolicy::Allowlist(Arc::from([Box::from("app.example.com")]));
for origin in [
"https://app.example.com",
"http://app.example.com",
"http://app.example.com:8080",
] {
assert!(
policy.rejection(&headers(&[("origin", origin)])).is_none(),
"`{origin}` must be accepted"
);
}
let pinned = OriginPolicy::Allowlist(Arc::from([Box::from("app.example.com:8443")]));
assert!(
pinned
.rejection(&headers(&[("origin", "https://app.example.com:8443")]))
.is_none()
);
assert!(
pinned
.rejection(&headers(&[("origin", "https://app.example.com")]))
.is_some()
);
let default_port = OriginPolicy::Allowlist(Arc::from([Box::from("app.example.com:443")]));
assert!(
default_port
.rejection(&headers(&[("origin", "https://app.example.com")]))
.is_none(),
"an implicit :443 is the :443 the entry pinned"
);
assert!(
default_port
.rejection(&headers(&[("origin", "http://app.example.com")]))
.is_some(),
"but plain HTTP is port 80, which the entry did not pin"
);
assert!(
pinned
.rejection(&headers(&[("host", "app.example.com:443")]))
.is_none()
);
}
#[test]
fn an_origin_is_taken_apart_at_the_right_colons() {
assert_eq!(
split_origin("https://app.example.com:8443"),
Some(("https", "app.example.com", Some("8443")))
);
assert_eq!(
split_origin("http://[::1]:3000"),
Some(("http", "[::1]", Some("3000")))
);
assert_eq!(split_origin("http://[::1]"), Some(("http", "[::1]", None)));
assert_eq!(split_origin("null"), None);
assert_eq!(split_origin("app.example.com"), None);
assert_eq!(split_origin("https://"), None);
}
#[test]
fn any_policy_checks_nothing() {
assert!(
OriginPolicy::Any
.rejection(&headers(&[
("host", "evil.example.com"),
("origin", "http://evil.example.com")
]))
.is_none()
);
}
#[test]
fn the_default_policy_follows_the_bind_address() {
assert!(matches!(
OriginPolicy::for_addr("127.0.0.1:3000"),
OriginPolicy::Loopback
));
assert!(matches!(
OriginPolicy::for_addr("localhost:3000"),
OriginPolicy::Loopback
));
assert!(matches!(
OriginPolicy::for_addr("[::1]:3000"),
OriginPolicy::Loopback
));
assert!(matches!(
OriginPolicy::for_addr("0.0.0.0:3000"),
OriginPolicy::Any
));
assert!(matches!(
OriginPolicy::for_addr("192.168.1.5:3000"),
OriginPolicy::Any
));
}
#[test]
fn host_of_keeps_ipv6_literals_whole() {
assert_eq!(host_of("[::1]:3000"), "[::1]");
assert_eq!(host_of("localhost:3000"), "localhost");
assert_eq!(host_of("localhost"), "localhost");
assert_eq!(host_of("::1"), "::1");
}
}