#![cfg_attr(test, allow(clippy::unwrap_used))]
#![doc(html_favicon_url = "https://salvo.rs/favicon-32x32.png")]
#![doc(html_logo_url = "https://salvo.rs/images/logo.svg")]
#![cfg_attr(docsrs, feature(doc_cfg))]
use bytes::{BufMut, BytesMut};
use salvo_core::http::header::{self, HeaderMap, HeaderName, HeaderValue};
use salvo_core::http::{Method, Request, Response, StatusCode};
use salvo_core::{Depot, FlowCtrl, Handler, async_trait};
mod allow_credentials;
mod allow_headers;
mod allow_methods;
mod allow_origin;
mod allow_private_network;
mod expose_headers;
mod inner;
mod max_age;
mod vary;
pub use self::allow_credentials::AllowCredentials;
pub use self::allow_headers::AllowHeaders;
pub use self::allow_methods::AllowMethods;
pub use self::allow_origin::AllowOrigin;
pub use self::allow_private_network::AllowPrivateNetwork;
pub use self::expose_headers::ExposeHeaders;
pub use self::max_age::MaxAge;
pub use self::vary::Vary;
static WILDCARD: HeaderValue = HeaderValue::from_static("*");
#[derive(Debug, Clone, Copy)]
#[must_use]
pub struct Any;
fn separated_by_commas<I>(iter: I) -> Option<HeaderValue>
where
I: Iterator<Item = HeaderValue>,
{
let values: Vec<HeaderValue> = iter.collect();
let (first, rest) = values.split_first()?;
let total = first.len()
+ rest
.iter()
.map(|v| v.len() + 1) .sum::<usize>();
let mut result = BytesMut::with_capacity(total);
result.extend_from_slice(first.as_bytes());
for val in rest {
result.put_u8(b',');
result.extend_from_slice(val.as_bytes());
}
HeaderValue::from_maybe_shared(result.freeze()).ok()
}
#[derive(Clone, Debug)]
pub struct Cors {
allow_credentials: AllowCredentials,
allow_headers: AllowHeaders,
allow_methods: AllowMethods,
allow_origin: AllowOrigin,
allow_private_network: AllowPrivateNetwork,
expose_headers: ExposeHeaders,
max_age: MaxAge,
vary: Vary,
}
impl Default for Cors {
#[inline]
fn default() -> Self {
Self::new()
}
}
impl Cors {
#[inline]
#[must_use]
pub fn new() -> Self {
Self {
allow_credentials: Default::default(),
allow_headers: Default::default(),
allow_methods: Default::default(),
allow_origin: Default::default(),
allow_private_network: Default::default(),
expose_headers: Default::default(),
max_age: Default::default(),
vary: Default::default(),
}
}
#[must_use]
pub fn permissive() -> Self {
Self::new()
.allow_headers(Any)
.allow_methods(Any)
.allow_origin(Any)
.expose_headers(Any)
}
#[must_use]
pub fn very_permissive() -> Self {
tracing::warn!(
"Using Cors::very_permissive() - this disables CORS security and should not be used in production!"
);
Self::new()
.allow_credentials(true)
.allow_headers(AllowHeaders::mirror_request())
.allow_methods(AllowMethods::mirror_request())
.allow_origin(AllowOrigin::mirror_request())
}
#[inline]
#[must_use]
pub fn allow_credentials(mut self, allow_credentials: impl Into<AllowCredentials>) -> Self {
self.allow_credentials = allow_credentials.into();
self
}
#[inline]
#[must_use]
pub fn allow_headers(mut self, headers: impl Into<AllowHeaders>) -> Self {
self.allow_headers = headers.into();
self
}
#[inline]
#[must_use]
pub fn max_age(mut self, max_age: impl Into<MaxAge>) -> Self {
self.max_age = max_age.into();
self
}
#[inline]
#[must_use]
pub fn allow_methods<I>(mut self, methods: I) -> Self
where
I: Into<AllowMethods>,
{
self.allow_methods = methods.into();
self
}
#[inline]
#[must_use]
pub fn allow_origin(mut self, origin: impl Into<AllowOrigin>) -> Self {
self.allow_origin = origin.into();
self
}
#[inline]
#[must_use]
pub fn expose_headers(mut self, headers: impl Into<ExposeHeaders>) -> Self {
self.expose_headers = headers.into();
self
}
#[must_use]
pub fn allow_private_network<T>(mut self, allow_private_network: T) -> Self
where
T: Into<AllowPrivateNetwork>,
{
self.allow_private_network = allow_private_network.into();
self
}
#[must_use]
pub fn vary(mut self, headers: impl Into<Vary>) -> Self {
self.vary = headers.into();
self
}
pub fn into_handler(self) -> CorsHandler {
self.ensure_usable_cors_rules();
CorsHandler::new(self, CallNext::default())
}
fn ensure_usable_cors_rules(&self) {
if self.allow_credentials.is_true() {
assert!(
!self.allow_headers.is_wildcard(),
"Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \
with `Access-Control-Allow-Headers: *`"
);
assert!(
!self.allow_methods.is_wildcard(),
"Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \
with `Access-Control-Allow-Methods: *`"
);
assert!(
!self.allow_origin.is_wildcard(),
"Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \
with `Access-Control-Allow-Origin: *`"
);
assert!(
!self.expose_headers.is_wildcard(),
"Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \
with `Access-Control-Expose-Headers: *`"
);
}
}
}
#[non_exhaustive]
#[derive(Default, Clone, Copy, Eq, PartialEq, Debug)]
pub enum CallNext {
#[default]
Before,
After,
}
#[derive(Clone, Debug)]
pub struct CorsHandler {
cors: Cors,
call_next: CallNext,
}
impl CorsHandler {
pub fn new(cors: Cors, call_next: CallNext) -> Self {
Self { cors, call_next }
}
}
#[async_trait]
impl Handler for CorsHandler {
async fn handle(
&self,
req: &mut Request,
depot: &mut Depot,
res: &mut Response,
ctrl: &mut FlowCtrl,
) {
if self.call_next == CallNext::Before {
ctrl.call_next(req, depot, res).await;
}
let origin = req.headers().get(&header::ORIGIN);
let is_preflight = req.method() == Method::OPTIONS
&& origin.is_some()
&& req
.headers()
.contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
let mut headers = HeaderMap::new();
headers.extend(self.cors.allow_origin.to_header(origin, req, depot).await);
headers.extend(
self.cors
.allow_credentials
.to_header(origin, req, depot)
.await,
);
headers.extend(
self.cors
.allow_private_network
.to_header(origin, req, depot)
.await,
);
for value in self.cors.vary.values() {
let already_varies = res.headers().get_all(header::VARY).iter().any(|existing| {
existing
.as_bytes()
.split(|byte| *byte == b',')
.any(|token| {
let token = token.trim_ascii();
token == b"*" || token.eq_ignore_ascii_case(value.as_bytes())
})
});
if !already_varies {
res.headers_mut().append(header::VARY, value);
}
}
if is_preflight {
headers.extend(self.cors.allow_methods.to_header(origin, req, depot).await);
headers.extend(self.cors.allow_headers.to_header(origin, req, depot).await);
headers.extend(self.cors.max_age.to_header(origin, req, depot).await);
res.status_code = Some(StatusCode::NO_CONTENT);
} else {
headers.extend(self.cors.expose_headers.to_header(origin, req, depot).await);
}
res.headers_mut().extend(headers);
if self.call_next == CallNext::After {
ctrl.call_next(req, depot, res).await;
}
enforce_credentials_safety(res, is_preflight);
}
}
fn enforce_credentials_safety(res: &mut Response, is_preflight: bool) {
let credentials = res
.headers()
.get_all(header::ACCESS_CONTROL_ALLOW_CREDENTIALS)
.iter()
.any(|v| v == "true");
if !credentials {
return;
}
let has_wildcard_token = |name: &HeaderName| {
res.headers().get_all(name).iter().any(|v| {
v.to_str()
.is_ok_and(|s| s.split(',').any(|token| token.trim() == "*"))
})
};
let mut candidates = vec![header::ACCESS_CONTROL_ALLOW_ORIGIN];
if is_preflight {
candidates.push(header::ACCESS_CONTROL_ALLOW_METHODS);
candidates.push(header::ACCESS_CONTROL_ALLOW_HEADERS);
} else {
candidates.push(header::ACCESS_CONTROL_EXPOSE_HEADERS);
}
if let Some(name) = candidates.into_iter().find(|name| has_wildcard_token(name)) {
tracing::error!(
"CORS misconfiguration: `Access-Control-Allow-Credentials: true` cannot be combined \
with `{name}: *`; dropping the credentials header"
);
res.headers_mut()
.remove(header::ACCESS_CONTROL_ALLOW_CREDENTIALS);
}
}
pub fn preflight_request_headers() -> impl Iterator<Item = HeaderName> {
[
header::ORIGIN,
header::ACCESS_CONTROL_REQUEST_METHOD,
header::ACCESS_CONTROL_REQUEST_HEADERS,
]
.into_iter()
}
#[cfg(test)]
mod tests {
use salvo_core::http::header::*;
use salvo_core::prelude::*;
use salvo_core::test::{ResponseExt, TestClient};
use super::*;
#[tokio::test]
async fn test_cors_preserves_downstream_vary() {
#[handler]
async fn varies(res: &mut Response) {
res.headers_mut()
.append(VARY, HeaderValue::from_static("Accept-Encoding, Origin"));
res.headers_mut()
.append(VARY, HeaderValue::from_static("Accept-Language"));
res.render("hello");
}
let service = Service::new(Router::new().goal(varies)).hoop(Cors::new().into_handler());
let res = TestClient::get("http://localhost/").send(&service).await;
let tokens: Vec<_> = res
.headers()
.get_all(VARY)
.iter()
.flat_map(|value| {
value
.to_str()
.unwrap()
.split(',')
.map(|token| token.trim().to_ascii_lowercase())
})
.collect();
assert_eq!(
tokens,
[
"accept-encoding",
"origin",
"accept-language",
"access-control-request-method",
"access-control-request-headers"
]
);
}
#[tokio::test]
async fn test_cors_merges_existing_vary_in_both_call_next_modes() {
#[handler]
async fn existing_vary(req: &mut Request, res: &mut Response) {
let value = if req.uri().path() == "/wildcard" {
"*"
} else {
"Accept-Language, ORIGIN"
};
res.headers_mut()
.insert(VARY, HeaderValue::from_static(value));
}
#[handler]
async fn hello() -> &'static str {
"hello"
}
for mode in [CallNext::Before, CallNext::After] {
let service = Service::new(Router::new().goal(hello))
.hoop(existing_vary)
.hoop(CorsHandler::new(Cors::new(), mode));
for path in ["/", "/wildcard"] {
let res = TestClient::get(format!("http://localhost{path}"))
.send(&service)
.await;
let values: Vec<_> = res
.headers()
.get_all(VARY)
.iter()
.map(|value| value.to_str().unwrap())
.collect();
if path == "/wildcard" {
assert_eq!(values, ["*"]);
} else {
assert_eq!(
values,
[
"Accept-Language, ORIGIN",
"access-control-request-method",
"access-control-request-headers"
]
);
}
}
}
}
#[tokio::test]
async fn test_cors_drops_credentials_with_wildcard_origin() {
let cors_handler = Cors::new()
.allow_origin(AllowOrigin::any())
.allow_credentials(AllowCredentials::dynamic(|_, _, _| true))
.allow_methods(vec![Method::GET, Method::OPTIONS])
.into_handler();
#[handler]
async fn hello() -> &'static str {
"hello"
}
let router = Router::new()
.hoop(cors_handler)
.push(Router::with_path("hello").goal(hello));
let service = Service::new(router);
let res = TestClient::get("http://127.0.0.1/hello")
.add_header("Origin", "https://evil.example.com", true)
.send(&service)
.await;
assert_eq!(res.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), "*");
assert!(
res.headers()
.get(ACCESS_CONTROL_ALLOW_CREDENTIALS)
.is_none(),
"credentials header must be dropped when origin is `*`"
);
}
#[tokio::test]
async fn test_cors_drops_credentials_when_handler_sets_wildcard_origin() {
#[handler]
async fn sets_wildcard(res: &mut Response) {
res.headers_mut()
.insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
res.render("ok");
}
let cors_handler = Cors::new()
.allow_credentials(AllowCredentials::dynamic(|_, _, _| true))
.into_handler();
let router = Router::new()
.hoop(cors_handler)
.push(Router::with_path("hello").goal(sets_wildcard));
let service = Service::new(router);
let res = TestClient::get("http://127.0.0.1/hello")
.add_header("Origin", "https://app.example.com", true)
.send(&service)
.await;
assert!(
res.headers()
.get(ACCESS_CONTROL_ALLOW_CREDENTIALS)
.is_none(),
"credentials must be dropped when the handler set a wildcard origin"
);
}
#[tokio::test]
async fn test_cors_drops_credentials_in_after_mode_with_handler_wildcard() {
#[handler]
async fn sets_wildcard(res: &mut Response) {
res.headers_mut()
.insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
res.render("ok");
}
let cors = Cors::new().allow_credentials(AllowCredentials::dynamic(|_, _, _| true));
let cors_handler = CorsHandler::new(cors, CallNext::After);
let router = Router::new()
.hoop(cors_handler)
.push(Router::with_path("hello").goal(sets_wildcard));
let service = Service::new(router);
let res = TestClient::get("http://127.0.0.1/hello")
.add_header("Origin", "https://app.example.com", true)
.send(&service)
.await;
assert!(
res.headers()
.get(ACCESS_CONTROL_ALLOW_CREDENTIALS)
.is_none(),
"credentials must be dropped even in After mode when the handler sets a wildcard"
);
}
#[tokio::test]
async fn test_cors_drops_credentials_with_wildcard_token_in_list() {
#[handler]
async fn sets_list_wildcard(res: &mut Response) {
res.headers_mut().insert(
ACCESS_CONTROL_ALLOW_HEADERS,
HeaderValue::from_static("*, authorization"),
);
}
let cors_handler = Cors::new()
.allow_origin("https://app.example.com")
.allow_credentials(AllowCredentials::dynamic(|_, _, _| true))
.into_handler();
let router = Router::new()
.hoop(cors_handler)
.push(Router::with_path("hello").goal(sets_list_wildcard));
let service = Service::new(router);
let res = TestClient::options("http://127.0.0.1/hello")
.add_header("Origin", "https://app.example.com", true)
.add_header(ACCESS_CONTROL_REQUEST_METHOD, "GET", true)
.send(&service)
.await;
assert!(
res.headers()
.get(ACCESS_CONTROL_ALLOW_CREDENTIALS)
.is_none(),
"credentials must be dropped when a list header contains a `*` token"
);
}
#[tokio::test]
async fn test_cors_keeps_credentials_on_options_without_request_method() {
#[handler]
async fn sets_wildcard_methods(res: &mut Response) {
res.headers_mut()
.insert(ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("*"));
}
let cors_handler = Cors::new()
.allow_origin("https://app.example.com")
.allow_credentials(AllowCredentials::dynamic(|_, _, _| true))
.into_handler();
let router = Router::new()
.hoop(cors_handler)
.push(Router::with_path("hello").goal(sets_wildcard_methods));
let service = Service::new(router);
let res = TestClient::options("http://127.0.0.1/hello")
.add_header("Origin", "https://app.example.com", true)
.send(&service)
.await;
assert_eq!(
res.headers().get(ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(),
"true",
"credentials must be kept for a non-preflight OPTIONS (no Request-Method)"
);
}
#[tokio::test]
async fn test_cors_keeps_credentials_on_actual_request_with_wildcard_methods() {
#[handler]
async fn sets_wildcard_methods(res: &mut Response) {
res.headers_mut()
.insert(ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("*"));
res.render("ok");
}
let cors_handler = Cors::new()
.allow_origin("https://app.example.com")
.allow_credentials(AllowCredentials::dynamic(|_, _, _| true))
.into_handler();
let router = Router::new()
.hoop(cors_handler)
.push(Router::with_path("hello").goal(sets_wildcard_methods));
let service = Service::new(router);
let res = TestClient::get("http://127.0.0.1/hello")
.add_header("Origin", "https://app.example.com", true)
.send(&service)
.await;
assert_eq!(
res.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
"https://app.example.com"
);
assert_eq!(
res.headers().get(ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(),
"true",
"credentials must be kept: wildcard methods is preflight-only and ignored here"
);
}
#[tokio::test]
async fn test_cors_drops_credentials_with_wildcard_methods() {
let cors_handler = Cors::new()
.allow_origin("https://app.example.com")
.allow_methods(Any)
.allow_credentials(AllowCredentials::dynamic(|_, _, _| true))
.into_handler();
#[handler]
async fn hello() -> &'static str {
"hello"
}
let router = Router::new()
.hoop(cors_handler)
.push(Router::with_path("hello").goal(hello));
let service = Service::new(router);
let res = TestClient::options("http://127.0.0.1/hello")
.add_header("Origin", "https://app.example.com", true)
.add_header(ACCESS_CONTROL_REQUEST_METHOD, "GET", true)
.send(&service)
.await;
assert_eq!(
res.headers().get(ACCESS_CONTROL_ALLOW_METHODS).unwrap(),
"*"
);
assert_eq!(
res.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
"https://app.example.com"
);
assert!(
res.headers()
.get(ACCESS_CONTROL_ALLOW_CREDENTIALS)
.is_none(),
"credentials must be dropped when any CORS header is `*`"
);
}
#[tokio::test]
async fn test_cors() {
let cors_handler = Cors::new()
.allow_origin("https://salvo.rs")
.allow_methods(vec![Method::GET, Method::POST, Method::OPTIONS])
.allow_headers(vec![
"CONTENT-TYPE",
"Access-Control-Request-Method",
"Access-Control-Allow-Origin",
"Access-Control-Allow-Headers",
"Access-Control-Max-Age",
])
.into_handler();
#[handler]
async fn hello() -> &'static str {
"hello"
}
let router = Router::new()
.hoop(cors_handler)
.push(Router::with_path("hello").goal(hello));
let service = Service::new(router);
async fn options_access(service: &Service, origin: &str) -> Response {
TestClient::options("http://127.0.0.1:5801/hello")
.add_header("Origin", origin, true)
.add_header("Access-Control-Request-Method", "POST", true)
.add_header("Access-Control-Request-Headers", "Content-Type", true)
.send(service)
.await
}
let res = TestClient::options("https://salvo.rs").send(&service).await;
assert!(res.headers().get(ACCESS_CONTROL_ALLOW_METHODS).is_none());
let res = options_access(&service, "https://salvo.rs").await;
let headers = res.headers();
assert!(headers.get(ACCESS_CONTROL_ALLOW_METHODS).is_some());
assert!(headers.get(ACCESS_CONTROL_ALLOW_HEADERS).is_some());
let res = TestClient::options("https://google.com")
.send(&service)
.await;
let headers = res.headers();
assert!(
headers.get(ACCESS_CONTROL_ALLOW_METHODS).is_none(),
"POST, GET, DELETE, OPTIONS"
);
assert!(headers.get(ACCESS_CONTROL_ALLOW_HEADERS).is_none());
}
#[tokio::test]
async fn test_cors_preserves_ordinary_options_responses() {
#[handler]
async fn options(req: &mut Request, res: &mut Response) {
res.status_code = Some(if req.uri().path() == "/denied" {
StatusCode::UNAUTHORIZED
} else {
StatusCode::OK
});
res.render("options response");
}
for mode in [CallNext::Before, CallNext::After] {
let cors = Cors::new()
.allow_origin("https://app.example.com")
.allow_methods([Method::GET])
.allow_headers([CONTENT_TYPE])
.expose_headers([ETAG])
.max_age(60);
let router = Router::new()
.options(options)
.push(Router::with_path("denied").options(options));
let service = Service::new(router).hoop(CorsHandler::new(cors, mode));
for (path, expected) in [("/", StatusCode::OK), ("/denied", StatusCode::UNAUTHORIZED)] {
for (origin, method) in [(false, false), (true, false), (false, true)] {
let mut request = TestClient::options(format!("http://localhost{path}"));
if origin {
request = request.add_header(ORIGIN, "https://app.example.com", true);
}
if method {
request = request.add_header(ACCESS_CONTROL_REQUEST_METHOD, "GET", true);
}
let mut res = request.send(&service).await;
assert_eq!(
res.status_code,
Some(expected),
"mode={mode:?}, origin={origin}, method={method}"
);
assert_eq!(res.take_string().await.unwrap(), "options response");
assert!(!res.headers().contains_key(ACCESS_CONTROL_ALLOW_METHODS));
assert!(!res.headers().contains_key(ACCESS_CONTROL_ALLOW_HEADERS));
assert!(!res.headers().contains_key(ACCESS_CONTROL_MAX_AGE));
if origin {
assert_eq!(res.headers()[ACCESS_CONTROL_EXPOSE_HEADERS], "etag");
}
}
}
}
}
#[tokio::test]
async fn test_cors_preflight_without_options_route_in_both_modes() {
#[handler]
async fn hello() -> &'static str {
"hello"
}
for mode in [CallNext::Before, CallNext::After] {
let cors = Cors::new()
.allow_origin("https://app.example.com")
.allow_methods([Method::GET])
.allow_headers([CONTENT_TYPE])
.expose_headers([ETAG])
.max_age(60);
let service = Service::new(Router::new().get(hello)).hoop(CorsHandler::new(cors, mode));
let res = TestClient::options("http://localhost/")
.add_header(ORIGIN, "https://app.example.com", true)
.add_header(ACCESS_CONTROL_REQUEST_METHOD, "GET", true)
.send(&service)
.await;
assert_eq!(res.status_code, Some(StatusCode::NO_CONTENT));
assert_eq!(res.headers()[ACCESS_CONTROL_ALLOW_METHODS], "GET");
assert_eq!(res.headers()[ACCESS_CONTROL_ALLOW_HEADERS], "content-type");
assert_eq!(res.headers()[ACCESS_CONTROL_MAX_AGE], "60");
assert!(!res.headers().contains_key(ACCESS_CONTROL_EXPOSE_HEADERS));
}
}
#[test]
fn test_separated_by_commas_empty_iter_returns_none() {
let result = super::separated_by_commas(std::iter::empty::<HeaderValue>());
assert!(result.is_none());
}
#[test]
fn test_separated_by_commas_joins_values() {
let values = vec![
HeaderValue::from_static("content-type"),
HeaderValue::from_static("authorization"),
HeaderValue::from_static("x-requested-with"),
];
let joined = super::separated_by_commas(values.into_iter()).expect("non-empty");
assert_eq!(joined, "content-type,authorization,x-requested-with");
}
}