use crate::axum::http::HeaderValue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedOrigin(String);
impl VerifiedOrigin {
#[must_use]
pub fn from_header(value: &HeaderValue) -> Option<Self> {
let bytes = value.as_bytes();
if !bytes.is_ascii() {
return None;
}
let lower = bytes
.iter()
.map(|b| b.to_ascii_lowercase())
.collect::<Vec<u8>>();
let s = String::from_utf8(lower).ok()?;
Some(Self(s))
}
#[must_use]
pub fn from_trusted(value: impl Into<String>) -> Self {
Self(value.into().to_ascii_lowercase())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub enum OriginPolicy {
DenyAll,
AllowExact { origin: VerifiedOrigin },
AllowSet { origins: Vec<VerifiedOrigin> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OriginDecision {
Allowed,
Denied,
}
impl OriginPolicy {
#[must_use]
pub fn deny_all() -> Self {
Self::DenyAll
}
#[must_use]
pub fn allow_exact(origin: VerifiedOrigin) -> Self {
Self::AllowExact { origin }
}
#[must_use]
pub fn allow_set(origins: Vec<VerifiedOrigin>) -> Self {
Self::AllowSet { origins }
}
#[must_use]
pub fn authorize(&self, present: Option<&HeaderValue>) -> OriginDecision {
let value = match present {
Some(v) => v,
None => return OriginDecision::Denied,
};
let candidate = match VerifiedOrigin::from_header(value) {
Some(o) => o,
None => return OriginDecision::Denied,
};
match self {
Self::DenyAll => OriginDecision::Denied,
Self::AllowExact { origin } => {
if origin == &candidate {
OriginDecision::Allowed
} else {
OriginDecision::Denied
}
}
Self::AllowSet { origins } => {
if origins.iter().any(|o| o == &candidate) {
OriginDecision::Allowed
} else {
OriginDecision::Denied
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hv(s: &str) -> HeaderValue {
HeaderValue::from_str(s).expect("valid header value")
}
#[test]
fn deny_all_rejects_everything() {
let p = OriginPolicy::deny_all();
assert_eq!(p.authorize(None), OriginDecision::Denied);
assert_eq!(
p.authorize(Some(&hv("http://localhost:3000"))),
OriginDecision::Denied
);
}
#[test]
fn allow_exact_matches_only_that_origin() {
let p = OriginPolicy::allow_exact(VerifiedOrigin::from_trusted("http://localhost:3000"));
assert_eq!(
p.authorize(Some(&hv("http://localhost:3000"))),
OriginDecision::Allowed
);
assert_eq!(
p.authorize(Some(&hv("HTTP://Localhost:3000"))),
OriginDecision::Allowed
);
assert_eq!(
p.authorize(Some(&hv("http://evil.example"))),
OriginDecision::Denied
);
}
#[test]
fn allow_set_matches_any_listed_origin() {
let p = OriginPolicy::allow_set(vec![
VerifiedOrigin::from_trusted("http://localhost:3000"),
VerifiedOrigin::from_trusted("https://app.example"),
]);
assert_eq!(
p.authorize(Some(&hv("http://localhost:3000"))),
OriginDecision::Allowed
);
assert_eq!(
p.authorize(Some(&hv("https://app.example"))),
OriginDecision::Allowed
);
assert_eq!(
p.authorize(Some(&hv("https://evil.example"))),
OriginDecision::Denied
);
}
#[test]
fn missing_origin_is_rejected_by_non_denyall_policies() {
let p = OriginPolicy::allow_exact(VerifiedOrigin::from_trusted("http://localhost:3000"));
assert_eq!(
p.authorize(None),
OriginDecision::Denied,
"absent origin must be rejected"
);
}
#[test]
fn non_ascii_origin_is_rejected() {
let p = OriginPolicy::allow_exact(VerifiedOrigin::from_trusted("http://localhost:3000"));
let bad = HeaderValue::from_bytes(&[0xFF, 0xFE]).expect("bytes ok as header");
assert_eq!(p.authorize(Some(&bad)), OriginDecision::Denied);
}
}