use std::fmt::Write as _;
use serve_net::http_net::MethodSet;
use serve_net::{HttpReply, Method};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Cors {
pub(crate) origins: Vec<Box<str>>,
pub(crate) any: bool,
pub(crate) headers: Vec<Box<str>>,
pub(crate) expose: Vec<Box<str>>,
pub(crate) credentials: bool,
pub(crate) max_age: u32,
}
impl Cors {
#[must_use]
pub fn new() -> Self {
Self {
max_age: 300,
..Self::default()
}
}
#[must_use]
pub fn origin(mut self, origin: &str) -> Self {
self.origins.push(origin.to_ascii_lowercase().into());
self
}
#[must_use]
pub fn any_origin(mut self) -> Self {
self.any = true;
self
}
#[must_use]
pub fn headers(mut self, names: &[&str]) -> Self {
self.headers.extend(
names
.iter()
.map(|n| n.to_ascii_lowercase().into_boxed_str()),
);
self
}
#[must_use]
pub fn expose(mut self, names: &[&str]) -> Self {
self.expose.extend(
names
.iter()
.map(|n| n.to_ascii_lowercase().into_boxed_str()),
);
self
}
#[must_use]
pub fn credentials(mut self) -> Self {
self.credentials = true;
self
}
#[must_use]
pub fn max_age(mut self, seconds: u32) -> Self {
self.max_age = seconds;
self
}
pub(crate) fn problem(&self) -> Option<&'static str> {
if self.any && self.credentials {
return Some("a CORS policy cannot allow any origin and credentials together");
}
if !self.any && self.origins.is_empty() {
return Some("a CORS policy needs at least one origin");
}
let token = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b))
};
if !self.headers.iter().chain(&self.expose).all(|h| token(h)) {
return Some("a CORS field name is not a token");
}
let origin_ok = |o: &str| {
(o.starts_with("https://") || o.starts_with("http://"))
&& !o.ends_with('/')
&& o.bytes().all(|b| (0x21..0x7f).contains(&b))
};
if !self.origins.iter().all(|o| origin_ok(o)) {
return Some("a CORS origin must be scheme://host[:port] with no path");
}
None
}
pub(crate) fn allow<'o>(&self, origin: &'o [u8]) -> Option<&'o [u8]> {
if self.any {
return Some(b"*");
}
let o = core::str::from_utf8(origin).ok()?;
self.origins
.iter()
.any(|a| a.eq_ignore_ascii_case(o))
.then_some(origin)
}
pub(crate) fn actual(&self, origin: Option<&[u8]>, reply: &mut HttpReply) {
reply.header("vary", "origin");
let Some(allowed) = origin.and_then(|o| self.allow(o)) else {
return;
};
reply.header("access-control-allow-origin", allowed);
if self.credentials {
reply.header("access-control-allow-credentials", "true");
}
if !self.expose.is_empty() {
reply.header("access-control-expose-headers", self.expose.join(", "));
}
}
pub(crate) fn preflight(
&self,
origin: &[u8],
method: Method,
requested_headers: Option<&[u8]>,
methods: MethodSet,
reply: &mut HttpReply,
) -> bool {
reply.status(204).header("vary", "origin");
let Some(allowed) = self.allow(origin) else {
return false;
};
if !methods.contains(method) {
return false;
}
if let Some(req) = requested_headers {
let Ok(req) = core::str::from_utf8(req) else {
return false;
};
for name in req.split(',').map(str::trim).filter(|n| !n.is_empty()) {
if !self.headers.iter().any(|h| h.eq_ignore_ascii_case(name)) {
return false;
}
}
}
let mut allow = Vec::new();
methods.write_allow(&mut allow);
reply
.header("access-control-allow-origin", allowed)
.header("access-control-allow-methods", allow);
if !self.headers.is_empty() {
reply.header("access-control-allow-headers", self.headers.join(", "));
}
if self.credentials {
reply.header("access-control-allow-credentials", "true");
}
let mut age = String::new();
let _ = write!(age, "{}", self.max_age);
reply.header("access-control-max-age", age);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_problems() {
assert!(Cors::new().problem().is_some());
assert!(Cors::new().any_origin().credentials().problem().is_some());
assert!(Cors::new().origin("https://a.example/").problem().is_some());
assert!(Cors::new().origin("a.example").problem().is_some());
assert!(
Cors::new()
.origin("https://a.example")
.headers(&["bad name"])
.problem()
.is_some()
);
assert!(
Cors::new()
.origin("https://a.example:8443")
.problem()
.is_none()
);
assert!(Cors::new().any_origin().problem().is_none());
}
#[test]
fn test_allow() {
let c = Cors::new().origin("https://a.example");
assert_eq!(
c.allow(b"https://A.example"),
Some(&b"https://A.example"[..])
);
assert_eq!(c.allow(b"https://b.example"), None);
assert_eq!(c.allow(b"null"), None);
assert_eq!(
Cors::new().any_origin().allow(b"https://x"),
Some(&b"*"[..])
);
}
}