use core::str::FromStr;
use crate::adapters::primary::transport::http::Refusal;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Origin(String);
impl Origin {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn host(&self) -> &str {
let authority = self
.0
.split_once("://")
.map_or(self.0.as_str(), |(_, rest)| rest);
host_of(authority)
}
#[must_use]
pub fn is_loopback(&self) -> bool {
is_loopback_host(self.host())
}
}
fn host_of(authority: &str) -> &str {
if let Some(bracketed) = authority.strip_prefix('[') {
return bracketed.split(']').next().unwrap_or(authority);
}
match authority.split_once(':') {
Some((host, port))
if !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()) =>
{
host
}
_ => authority,
}
}
fn is_loopback_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OriginError {
#[error("an Origin header was present but empty")]
Empty,
#[error("an Origin header must be scheme://host[:port] with no path")]
NotAnOrigin,
#[error("an Origin header must use the http or https scheme")]
UnsupportedScheme,
}
impl FromStr for Origin {
type Err = OriginError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.is_empty() {
return Err(OriginError::Empty);
}
let Some((scheme, rest)) = value.split_once("://") else {
return Err(OriginError::NotAnOrigin);
};
let scheme = scheme.to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return Err(OriginError::UnsupportedScheme);
}
if rest.is_empty() || rest.contains('/') {
return Err(OriginError::NotAnOrigin);
}
Ok(Self(format!("{scheme}://{}", rest.to_ascii_lowercase())))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OriginPolicy {
allowed: Vec<Origin>,
own: Option<Origin>,
}
impl OriginPolicy {
#[must_use]
pub const fn strict(own: Option<Origin>) -> Self {
Self {
allowed: Vec::new(),
own,
}
}
#[must_use]
pub fn allowing(mut self, origin: Origin) -> Self {
self.allowed.push(origin);
self
}
#[must_use]
pub const fn own(&self) -> Option<&Origin> {
self.own.as_ref()
}
pub fn permitted(&self) -> impl Iterator<Item = &Origin> {
self.own.iter().chain(self.allowed.iter())
}
pub fn check(&self, header: Option<&str>) -> Result<(), Refusal> {
let Some(raw) = header else {
return Ok(());
};
let origin = Origin::from_str(raw).map_err(|_| Refusal::ForeignOrigin)?;
if origin.is_loopback() || self.permitted().any(|permitted| permitted == &origin) {
Ok(())
} else {
Err(Refusal::ForeignOrigin)
}
}
pub fn check_host(&self, header: Option<&str>) -> Result<(), Refusal> {
let Some(raw) = header else {
return Err(Refusal::ForeignOrigin);
};
let host = host_of(raw.trim());
if is_loopback_host(host) || self.permitted().any(|permitted| permitted.host() == host) {
Ok(())
} else {
Err(Refusal::ForeignOrigin)
}
}
}
#[cfg(test)]
mod tests {
use super::{Origin, OriginError, OriginPolicy};
use crate::adapters::primary::transport::http::Refusal;
use core::str::FromStr;
type TestError = Box<dyn std::error::Error>;
fn accepts(value: &str, expected: &str) -> Result<(), TestError> {
let origin = Origin::from_str(value)?;
if origin.as_str() != expected {
return Err(format!("{value:?} read back as {:?}", origin.as_str()).into());
}
Ok(())
}
fn refuses(value: &str, expected: &OriginError) -> Result<(), TestError> {
match Origin::from_str(value) {
Err(error) if &error == expected => Ok(()),
Err(error) => {
Err(format!("{value:?} refused as {error:?}, wanted {expected:?}").into())
}
Ok(origin) => Err(format!("{value:?} was accepted as {:?}", origin.as_str()).into()),
}
}
fn policy() -> Result<OriginPolicy, TestError> {
Ok(OriginPolicy::strict(Some(Origin::from_str(
"http://127.0.0.1:8080",
)?)))
}
#[test]
fn scheme_and_host_are_lowercased() -> Result<(), TestError> {
accepts("HTTP://Example.COM", "http://example.com")
}
#[test]
fn a_trailing_path_is_refused() -> Result<(), TestError> {
refuses("http://example.com/steal", &OriginError::NotAnOrigin)
}
#[test]
fn the_null_origin_is_refused() -> Result<(), TestError> {
refuses("null", &OriginError::NotAnOrigin)
}
#[test]
fn a_non_http_scheme_is_refused() -> Result<(), TestError> {
refuses("file://example.com", &OriginError::UnsupportedScheme)
}
#[test]
fn a_foreign_origin_is_refused() -> Result<(), TestError> {
if policy()?.check(Some("http://evil.example")).is_ok() {
return Err("a foreign origin was answered".into());
}
Ok(())
}
#[test]
fn a_listed_origin_is_answered() -> Result<(), TestError> {
let policy = policy()?.allowing(Origin::from_str("http://localhost:3000")?);
if policy.check(Some("http://localhost:3000")).is_err() {
return Err("a listed origin was refused".into());
}
Ok(())
}
#[test]
fn a_neighbouring_host_is_not_answered() -> Result<(), TestError> {
let policy = policy()?.allowing(Origin::from_str("http://app.internal:3000")?);
for neighbour in [
"http://notapp.internal:3000",
"http://app.internal.evil.example:3000",
"http://app.internal:3001",
"http://app.internal:3000.evil.example",
"http://app.internalx:3000",
] {
if policy.check(Some(neighbour)).is_ok() {
return Err(format!("{neighbour} was answered").into());
}
}
Ok(())
}
#[test]
fn every_listed_origin_answers_not_only_the_first() -> Result<(), TestError> {
let policy = policy()?
.allowing(Origin::from_str("http://localhost:3000")?)
.allowing(Origin::from_str("http://localhost:3001")?)
.allowing(Origin::from_str("http://app.internal")?);
for listed in [
"http://localhost:3000",
"http://localhost:3001",
"http://app.internal",
"http://127.0.0.1:8080",
] {
if policy.check(Some(listed)).is_err() {
return Err(format!("{listed} was refused").into());
}
}
Ok(())
}
#[test]
fn a_different_port_is_a_different_origin() -> Result<(), TestError> {
if policy()?.check(Some("http://app.internal:9999")).is_ok() {
return Err("a different port was answered".into());
}
Ok(())
}
#[test]
fn a_loopback_origin_is_answered_on_any_port() -> Result<(), TestError> {
let policy = policy()?;
for origin in [
"http://localhost:1",
"http://localhost:9999",
"http://127.0.0.1:65535",
"http://[::1]:18099",
] {
if policy.check(Some(origin)).is_err() {
return Err(format!("{origin} was refused").into());
}
}
Ok(())
}
#[test]
fn an_absent_origin_is_answered() -> Result<(), TestError> {
if policy()?.check(None).is_err() {
return Err("a client sending no Origin was refused".into());
}
Ok(())
}
#[test]
fn a_server_that_knows_its_own_origin_still_refuses_foreign_ones() -> Result<(), TestError> {
let policy = OriginPolicy::strict(None);
if policy.check(Some("http://anything.example")).is_ok() {
return Err("a server with no known origin answered one".into());
}
Ok(())
}
#[test]
fn a_malformed_origin_is_refused_rather_than_ignored() -> Result<(), TestError> {
if policy()?.check(Some("garbage")).is_ok() {
return Err("a malformed Origin was answered".into());
}
Ok(())
}
#[test]
fn the_refusal_does_not_echo_the_header() -> Result<(), TestError> {
let forged = "http://evil.example/steal?token=abc";
let refusal = policy()?.check(Some(forged)).err();
let Some(refusal) = refusal else {
return Err("a foreign origin was answered".into());
};
if refusal != Refusal::ForeignOrigin {
return Err(format!("refused as {refusal:?}").into());
}
let body = refusal.body().to_string();
if body.contains("evil.example") || body.contains("token") {
return Err(format!("the refusal echoed the header: {body}").into());
}
Ok(())
}
#[test]
fn the_refusal_is_a_plain_403() -> Result<(), TestError> {
if Refusal::ForeignOrigin.status() != axum::http::StatusCode::FORBIDDEN {
return Err("the refusal is not a 403".into());
}
Ok(())
}
#[test]
fn a_loopback_origin_is_answered() -> Result<(), TestError> {
let policy = policy()?;
for host in [
"localhost:8080",
"127.0.0.1:8080",
"[::1]:8080",
"localhost",
] {
let origin = format!("http://{host}");
if let Err(refusal) = policy.check(Some(&origin)) {
return Err(format!("{origin} was refused as {refusal:?}").into());
}
}
Ok(())
}
#[test]
fn a_name_that_only_looks_like_loopback_is_refused() -> Result<(), TestError> {
let policy = policy()?;
for origin in [
"http://localhost.evil.example",
"http://127.0.0.1.evil.example",
"http://notlocalhost",
"http://127.0.0.2",
"http://localhost:8080.evil.example",
] {
let refused = policy.check(Some(origin)).err();
if refused.is_none() {
return Err(format!("{origin} was answered").into());
}
}
Ok(())
}
#[test]
fn a_loopback_host_is_answered_and_a_foreign_one_is_not() -> Result<(), TestError> {
let policy = policy()?;
for host in [
"localhost:8080",
"127.0.0.1:8080",
"[::1]:8080",
"localhost",
] {
if let Err(refusal) = policy.check_host(Some(host)) {
return Err(format!("Host {host} was refused as {refusal:?}").into());
}
}
for host in [
"evil.example",
"evil.example:8080",
"localhost.evil.example",
] {
let refused = policy.check_host(Some(host)).err();
if refused.is_none() {
return Err(format!("Host {host} was answered").into());
}
}
Ok(())
}
#[test]
fn an_absent_host_is_refused() -> Result<(), TestError> {
if policy()?.check_host(None).is_ok() {
return Err("a request with no Host was answered".into());
}
Ok(())
}
#[test]
fn a_configured_origin_answers_its_own_host() -> Result<(), TestError> {
let policy = OriginPolicy::strict(None).allowing(Origin::from_str("https://mcp.example")?);
for host in ["mcp.example", "mcp.example:8443"] {
if let Err(refusal) = policy.check_host(Some(host)) {
return Err(format!("Host {host} was refused as {refusal:?}").into());
}
}
if policy.check_host(Some("other.example")).is_ok() {
return Err("an unconfigured host was answered".into());
}
Ok(())
}
}