#![deny(missing_docs)]
use async_trait::async_trait;
use churust_core::{AppBuilder, Call, Middleware, Next, Phase, Plugin, Response};
use http::header::{
ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_MAX_AGE, ACCESS_CONTROL_REQUEST_METHOD, VARY,
};
use http::{HeaderValue, Method, StatusCode};
use std::sync::Arc;
#[derive(Debug, Clone)]
enum AllowOrigin {
Any,
List(Vec<String>),
}
#[derive(Debug, Clone)]
pub struct Cors {
origin: AllowOrigin,
methods: Vec<Method>,
headers: Vec<String>,
credentials: bool,
max_age: Option<u64>,
}
impl Cors {
pub fn permissive() -> Self {
Self {
origin: AllowOrigin::Any,
methods: vec![
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::PATCH,
Method::OPTIONS,
],
headers: vec!["*".to_string()],
credentials: false,
max_age: Some(86_400),
}
}
pub fn new() -> Self {
Self {
origin: AllowOrigin::List(Vec::new()),
methods: vec![Method::GET, Method::POST],
headers: Vec::new(),
credentials: false,
max_age: None,
}
}
pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
match &mut self.origin {
AllowOrigin::List(v) => v.push(origin.into()),
AllowOrigin::Any => {
self.origin = AllowOrigin::List(vec![origin.into()]);
}
}
self
}
pub fn allow_methods(mut self, methods: Vec<Method>) -> Self {
self.methods = methods;
self
}
pub fn allow_headers(mut self, headers: Vec<String>) -> Self {
self.headers = headers;
self
}
pub fn allow_credentials(mut self, yes: bool) -> Self {
self.credentials = yes;
self
}
pub fn max_age(mut self, seconds: u64) -> Self {
self.max_age = Some(seconds);
self
}
fn origin_allowed(&self, origin: &str) -> Option<String> {
match &self.origin {
AllowOrigin::Any => Some("*".to_string()),
AllowOrigin::List(list) => {
if list.iter().any(|o| o == origin) {
Some(origin.to_string())
} else {
None
}
}
}
}
fn apply_common(&self, res: &mut Response, allow_origin: &str) {
res.headers.insert(
ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_str(allow_origin).unwrap_or(HeaderValue::from_static("*")),
);
if self.credentials {
res.headers.insert(
ACCESS_CONTROL_ALLOW_CREDENTIALS,
HeaderValue::from_static("true"),
);
}
res.headers.insert(VARY, HeaderValue::from_static("Origin"));
}
}
impl Default for Cors {
fn default() -> Self {
Self::new()
}
}
impl Plugin for Cors {
fn install(self: Box<Self>, app: &mut AppBuilder) {
app.add_middleware_in(Phase::Plugins, Arc::new(CorsMiddleware { cfg: *self }));
}
}
struct CorsMiddleware {
cfg: Cors,
}
#[async_trait]
impl Middleware for CorsMiddleware {
async fn handle(&self, call: Call, next: Next) -> Response {
let origin = call.header("origin").map(|s| s.to_string());
let is_preflight = *call.method() == Method::OPTIONS
&& call
.header(ACCESS_CONTROL_REQUEST_METHOD.as_str())
.is_some();
if is_preflight {
let mut res = Response::new(StatusCode::NO_CONTENT);
if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
self.cfg.apply_common(&mut res, &o);
let methods = self
.cfg
.methods
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>()
.join(", ");
if let Ok(v) = HeaderValue::from_str(&methods) {
res.headers.insert(ACCESS_CONTROL_ALLOW_METHODS, v);
}
if !self.cfg.headers.is_empty() {
let hs = self.cfg.headers.join(", ");
if let Ok(v) = HeaderValue::from_str(&hs) {
res.headers.insert(ACCESS_CONTROL_ALLOW_HEADERS, v);
}
}
if let Some(age) = self.cfg.max_age {
if let Ok(v) = HeaderValue::from_str(&age.to_string()) {
res.headers.insert(ACCESS_CONTROL_MAX_AGE, v);
}
}
}
return res;
}
let mut res = next.run(call).await;
if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
self.cfg.apply_common(&mut res, &o);
}
res
}
}
#[cfg(test)]
mod tests {
use super::*;
use churust_core::{App, Churust, TestClient};
fn app() -> App {
Churust::server()
.install(Cors::permissive())
.routing(|r| {
r.get("/", |_c: Call| async { "ok" });
})
.build()
}
#[tokio::test]
async fn actual_request_gets_allow_origin() {
let client = TestClient::new(app());
let res = client
.get("/")
.header("origin", "https://example.com")
.send()
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(res.header("access-control-allow-origin"), Some("*"));
assert_eq!(res.header("vary"), Some("Origin"));
}
#[tokio::test]
async fn preflight_returns_204_with_methods() {
let client = TestClient::new(app());
let res = client
.request(Method::OPTIONS, "/")
.header("origin", "https://example.com")
.header("access-control-request-method", "POST")
.send()
.await;
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let methods = res.header("access-control-allow-methods").unwrap();
assert!(methods.contains("POST"));
}
#[tokio::test]
async fn disallowed_origin_gets_no_cors_header() {
let app = Churust::server()
.install(Cors::new().allow_origin("https://allowed.com"))
.routing(|r| {
r.get("/", |_c: Call| async { "ok" });
})
.build();
let client = TestClient::new(app);
let res = client
.get("/")
.header("origin", "https://evil.com")
.send()
.await;
assert_eq!(res.header("access-control-allow-origin"), None);
}
}