use std::sync::OnceLock;
use actix_cors::Cors;
use actix_web::http::header::{self, HeaderName, HeaderValue};
use actix_web::http::Method;
use actix_web::{HttpRequest, HttpResponse};
use crate::config::CorsConfig;
static SETTINGS: OnceLock<CorsConfig> = OnceLock::new();
pub(crate) fn install(cfg: CorsConfig) {
let _ = SETTINGS.set(cfg);
}
pub fn cors() -> Cors {
match SETTINGS.get() {
Some(cfg) => cors_from(cfg),
None => cors_from(&CorsConfig::default()),
}
}
pub fn cors_from(cfg: &CorsConfig) -> Cors {
let mut cors = Cors::default();
let any = any_origin(cfg);
if any {
cors = cors.allow_any_origin();
} else {
for origin in &cfg.origins {
cors = cors.allowed_origin(origin);
}
if cfg.credentials {
cors = cors.supports_credentials();
}
}
let methods: Vec<Method> = cfg.methods.iter().filter_map(|m| m.parse().ok()).collect();
if !methods.is_empty() {
cors = cors.allowed_methods(methods);
}
let headers: Vec<HeaderName> = cfg.headers.iter().filter_map(|h| h.parse().ok()).collect();
if !headers.is_empty() {
cors = cors.allowed_headers(headers);
}
cors.max_age(Some(cfg.max_age as usize))
}
pub(crate) fn apply_cors(req: &HttpRequest, res: HttpResponse) -> HttpResponse {
match SETTINGS.get() {
Some(cfg) => apply_cors_from(cfg, req, res),
None => apply_cors_from(&CorsConfig::default(), req, res),
}
}
pub fn apply_cors_from(cfg: &CorsConfig, req: &HttpRequest, mut res: HttpResponse) -> HttpResponse {
let Some(origin) = req.headers().get(header::ORIGIN) else {
return res;
};
if !origin_is_allowed(cfg, origin) {
return res;
}
res.headers_mut()
.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin.clone());
if cfg.credentials && !any_origin(cfg) {
res.headers_mut().insert(
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
HeaderValue::from_static("true"),
);
}
res
}
fn any_origin(cfg: &CorsConfig) -> bool {
cfg.origins.iter().any(|o| o == "*")
}
fn origin_is_allowed(cfg: &CorsConfig, origin: &HeaderValue) -> bool {
if any_origin(cfg) {
return true;
}
let Ok(origin) = origin.to_str() else {
return false;
};
cfg.origins.iter().any(|allowed| allowed == origin)
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::http::{StatusCode, header};
use actix_web::{App, HttpResponse, test, web};
#[actix_web::test]
async fn configured_origin_is_allowed() {
let cfg = CorsConfig {
origins: vec!["http://localhost:3000".into()],
..CorsConfig::default()
};
let srv = test::init_service(App::new().wrap(cors_from(&cfg)).route(
"/x",
web::get().to(|| async { HttpResponse::Ok().finish() }),
))
.await;
let req = test::TestRequest::get()
.uri("/x")
.insert_header((header::ORIGIN, "http://localhost:3000"))
.to_request();
let resp = test::call_service(&srv, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.unwrap(),
"http://localhost:3000"
);
}
#[actix_web::test]
async fn empty_origins_allow_none() {
let srv = test::init_service(App::new().wrap(cors_from(&CorsConfig::default())).route(
"/x",
web::get().to(|| async { HttpResponse::Ok().finish() }),
))
.await;
let req = test::TestRequest::get()
.uri("/x")
.insert_header((header::ORIGIN, "http://localhost:3000"))
.to_request();
let resp = test::call_service(&srv, req).await;
assert!(
resp.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.is_none()
);
}
#[actix_web::test]
async fn star_echoes_request_origin() {
let cfg = CorsConfig {
origins: vec!["*".into()],
..CorsConfig::default()
};
let srv = test::init_service(App::new().wrap(cors_from(&cfg)).route(
"/x",
web::get().to(|| async { HttpResponse::Ok().finish() }),
))
.await;
let req = test::TestRequest::get()
.uri("/x")
.insert_header((header::ORIGIN, "http://localhost:8080"))
.to_request();
let resp = test::call_service(&srv, req).await;
assert_eq!(
resp.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.unwrap(),
"http://localhost:8080"
);
}
#[actix_web::test]
async fn apply_cors_from_stamps_unauthorized() {
let cfg = CorsConfig {
origins: vec!["*".into()],
..CorsConfig::default()
};
let req = test::TestRequest::default()
.insert_header((header::ORIGIN, "http://localhost:8080"))
.to_http_request();
let res = apply_cors_from(
&cfg,
&req,
HttpResponse::Unauthorized().body("invalid token"),
);
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
res.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.unwrap(),
"http://localhost:8080"
);
}
#[actix_web::test]
async fn apply_cors_from_skips_unknown_origin() {
let cfg = CorsConfig {
origins: vec!["http://localhost:3000".into()],
..CorsConfig::default()
};
let req = test::TestRequest::default()
.insert_header((header::ORIGIN, "http://evil.example"))
.to_http_request();
let res = apply_cors_from(&cfg, &req, HttpResponse::Unauthorized().finish());
assert!(
res.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.is_none()
);
}
}