use axum::http::HeaderName;
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
pub const DEFAULT_ALLOW_METHODS: &str = "GET, POST, PATCH, PUT, DELETE, OPTIONS";
pub const DEFAULT_ALLOW_HEADERS: &str =
"Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, X-CSRF-TOKEN, X-Requested-With";
pub const DEFAULT_MAX_AGE: u64 = 1800;
pub fn cors_layer() -> CorsLayer {
CorsLayer::new()
.allow_origin(AllowOrigin::any())
.allow_methods(parse_methods(DEFAULT_ALLOW_METHODS))
.allow_headers(parse_headers(DEFAULT_ALLOW_HEADERS))
.max_age(std::time::Duration::from_secs(DEFAULT_MAX_AGE))
}
pub fn origin_matches_domain(origin: &str, domain: &str) -> bool {
let host = origin.split("://").nth(1).unwrap_or(origin);
let host = if let Some(stripped) = host.strip_prefix('[') {
stripped.split(']').next().unwrap_or(stripped)
} else {
host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host)
};
host == domain || host.ends_with(&format!(".{domain}"))
}
pub fn cors_layer_with_origin(cookie_domain: &str) -> CorsLayer {
let cookie_domain = cookie_domain.to_string();
let allow_origin = AllowOrigin::predicate(move |origin, _| {
if cookie_domain.is_empty() {
return false; }
match origin.to_str() {
Ok(origin_str) => origin_matches_domain(origin_str, &cookie_domain),
Err(_) => false,
}
});
CorsLayer::new()
.allow_origin(allow_origin)
.allow_credentials(true)
.allow_methods(parse_methods(DEFAULT_ALLOW_METHODS))
.allow_headers(parse_headers(DEFAULT_ALLOW_HEADERS))
.max_age(std::time::Duration::from_secs(DEFAULT_MAX_AGE))
}
pub fn cors_layer_with_config(
allow_origin: AllowOrigin,
allow_credentials: bool,
allow_methods: &str,
allow_headers: &str,
max_age_secs: u64,
) -> CorsLayer {
let mut layer = CorsLayer::new()
.allow_origin(allow_origin)
.allow_methods(parse_methods(allow_methods))
.allow_headers(parse_headers(allow_headers))
.max_age(std::time::Duration::from_secs(max_age_secs));
if allow_credentials {
layer = layer.allow_credentials(true);
}
layer
}
fn parse_methods(methods: &str) -> AllowMethods {
let mut list = Vec::new();
for m in methods.split(',') {
let m = m.trim();
if let Ok(method) = m.parse::<axum::http::Method>() {
list.push(method);
}
}
AllowMethods::list(list)
}
fn parse_headers(headers: &str) -> AllowHeaders {
let mut list = Vec::new();
for h in headers.split(',') {
let h = h.trim();
if let Ok(name) = HeaderName::from_bytes(h.as_bytes()) {
list.push(name);
}
}
AllowHeaders::list(list)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{HeaderName, Method, Request};
use axum::Router;
use http_body_util::BodyExt;
use tower::ServiceExt;
fn make_router(layer: CorsLayer) -> Router {
Router::new()
.route(
"/api",
axum::routing::get(|| async { "hello" }).post(|| async { "created" }),
)
.layer(layer)
}
async fn send_request(
router: Router,
method: &str,
uri: &str,
origin: Option<&str>,
) -> axum::response::Response {
let mut builder = Request::builder().method(method).uri(uri);
if let Some(o) = origin {
builder = builder.header("origin", o);
}
let req = builder.body(Body::empty()).unwrap();
router.oneshot(req).await.unwrap()
}
async fn send_preflight(
router: Router,
uri: &str,
origin: &str,
request_method: &str,
request_headers: &str,
) -> axum::response::Response {
let req = Request::builder()
.method("OPTIONS")
.uri(uri)
.header("origin", origin)
.header("access-control-request-method", request_method)
.header("access-control-request-headers", request_headers)
.body(Body::empty())
.unwrap();
router.oneshot(req).await.unwrap()
}
#[tokio::test]
async fn test_cors_layer_sets_allow_origin_wildcard() {
let router = make_router(cors_layer());
let resp = send_request(router, "GET", "/api", Some("https://example.com")).await;
let allow_origin = resp
.headers()
.get("access-control-allow-origin")
.expect("missing Access-Control-Allow-Origin");
assert_eq!(allow_origin, "*");
}
#[tokio::test]
async fn test_cors_layer_no_credentials_by_default() {
let router = make_router(cors_layer());
let resp = send_request(router, "GET", "/api", Some("https://example.com")).await;
assert!(
resp.headers()
.get("access-control-allow-credentials")
.is_none(),
"default cors_layer() must NOT set Allow-Credentials"
);
}
#[tokio::test]
async fn test_cors_layer_preflight_sets_methods() {
let router = make_router(cors_layer());
let resp = send_request(router, "OPTIONS", "/api", Some("https://example.com")).await;
let methods = resp
.headers()
.get("access-control-allow-methods")
.expect("missing Access-Control-Allow-Methods");
let methods_str = methods.to_str().unwrap();
assert!(methods_str.contains("GET"));
assert!(methods_str.contains("POST"));
assert!(methods_str.contains("PATCH"));
assert!(methods_str.contains("PUT"));
assert!(methods_str.contains("DELETE"));
assert!(methods_str.contains("OPTIONS"));
}
#[tokio::test]
async fn test_cors_layer_preflight_sets_headers() {
let router = make_router(cors_layer());
let resp = send_preflight(
router,
"/api",
"https://example.com",
"POST",
"Authorization, Content-Type, X-Requested-With, X-CSRF-TOKEN",
)
.await;
let headers = resp
.headers()
.get("access-control-allow-headers")
.expect("missing Access-Control-Allow-Headers");
let headers_str = headers.to_str().unwrap().to_lowercase();
assert!(headers_str.contains("authorization"));
assert!(headers_str.contains("content-type"));
assert!(headers_str.contains("x-requested-with"));
assert!(headers_str.contains("x-csrf-token"));
}
#[tokio::test]
async fn test_cors_layer_preflight_sets_max_age() {
let router = make_router(cors_layer());
let resp = send_request(router, "OPTIONS", "/api", Some("https://example.com")).await;
let max_age = resp
.headers()
.get("access-control-max-age")
.expect("missing Access-Control-Max-Age");
assert_eq!(max_age, "1800");
}
#[tokio::test]
async fn test_cors_layer_normal_request_passes_through() {
let router = make_router(cors_layer());
let resp = send_request(router, "GET", "/api", Some("https://example.com")).await;
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"hello");
}
#[tokio::test]
async fn test_cors_with_origin_empty_domain_allows_all() {
let router = make_router(cors_layer_with_origin(""));
let resp = send_request(router, "GET", "/api", Some("https://anything.com")).await;
let allow_origin = resp
.headers()
.get("access-control-allow-origin");
assert!(
allow_origin.is_none(),
"P1-SEC-11: 空 cookie_domain 应拒绝所有 origin(不再回显)"
);
}
#[tokio::test]
async fn test_cors_with_origin_matching_domain_allows() {
let router = make_router(cors_layer_with_origin("example.com"));
let resp = send_request(router, "GET", "/api", Some("https://app.example.com")).await;
let allow_origin = resp
.headers()
.get("access-control-allow-origin")
.expect("missing Access-Control-Allow-Origin");
assert_eq!(allow_origin, "https://app.example.com");
}
#[tokio::test]
async fn test_cors_with_origin_non_matching_domain_blocks() {
let router = make_router(cors_layer_with_origin("example.com"));
let resp = send_request(router, "GET", "/api", Some("https://evil.com")).await;
assert!(resp.headers().get("access-control-allow-origin").is_none());
}
#[test]
fn test_origin_matches_domain_exact() {
assert!(origin_matches_domain("https://example.com", "example.com"));
assert!(origin_matches_domain("http://example.com", "example.com"));
assert!(origin_matches_domain("example.com", "example.com"));
}
#[test]
fn test_origin_matches_domain_subdomain() {
assert!(origin_matches_domain(
"https://app.example.com",
"example.com"
));
assert!(origin_matches_domain(
"https://a.b.example.com",
"example.com"
));
}
#[test]
fn test_origin_matches_domain_evil_substring_blocked() {
assert!(!origin_matches_domain(
"https://evil-example.com",
"example.com"
));
assert!(!origin_matches_domain(
"https://example.com.evil.com",
"example.com"
));
assert!(!origin_matches_domain(
"https://notexample.com",
"example.com"
));
}
#[test]
fn test_origin_matches_domain_with_port() {
assert!(origin_matches_domain(
"https://example.com:8443",
"example.com"
));
assert!(origin_matches_domain(
"https://app.example.com:8443",
"example.com"
));
assert!(!origin_matches_domain(
"https://evil-example.com:8443",
"example.com"
));
}
#[test]
fn test_origin_matches_domain_ipv6() {
assert!(origin_matches_domain("http://[::1]:8080", "::1"));
assert!(!origin_matches_domain("http://[::2]:8080", "::1"));
}
#[test]
fn test_origin_matches_domain_scheme_less() {
assert!(origin_matches_domain("example.com", "example.com"));
assert!(origin_matches_domain("app.example.com", "example.com"));
assert!(!origin_matches_domain("evil-example.com", "example.com"));
}
#[tokio::test]
async fn test_cors_with_origin_evil_substring_blocked() {
let router = make_router(cors_layer_with_origin("example.com"));
let resp = send_request(router, "GET", "/api", Some("https://evil-example.com")).await;
assert!(
resp.headers().get("access-control-allow-origin").is_none(),
"evil-example.com must NOT match cookie_domain=example.com"
);
}
#[tokio::test]
async fn test_cors_with_origin_subdomain_allowed() {
let router = make_router(cors_layer_with_origin("example.com"));
let resp = send_request(router, "GET", "/api", Some("https://app.example.com")).await;
let allow_origin = resp
.headers()
.get("access-control-allow-origin")
.expect("subdomain app.example.com should match cookie_domain=example.com");
assert_eq!(allow_origin, "https://app.example.com");
}
#[tokio::test]
async fn test_cors_with_config_wildcard_no_credentials() {
let layer =
cors_layer_with_config(AllowOrigin::any(), false, "GET, POST", "Content-Type", 600);
let router = make_router(layer);
let resp = send_preflight(
router,
"/api",
"https://example.com",
"POST",
"Content-Type",
)
.await;
let allow_origin = resp
.headers()
.get("access-control-allow-origin")
.expect("missing Access-Control-Allow-Origin");
assert_eq!(allow_origin, "*");
assert!(resp
.headers()
.get("access-control-allow-credentials")
.is_none());
let max_age = resp
.headers()
.get("access-control-max-age")
.expect("missing Access-Control-Max-Age");
assert_eq!(max_age, "600");
}
#[tokio::test]
async fn test_cors_with_config_custom_methods_headers() {
let layer = cors_layer_with_config(
AllowOrigin::any(),
false,
"GET, POST, OPTIONS",
"Authorization, Content-Type, X-Custom",
3600,
);
let router = make_router(layer);
let resp = send_preflight(
router,
"/api",
"https://example.com",
"POST",
"Authorization, Content-Type, X-Custom",
)
.await;
let methods = resp
.headers()
.get("access-control-allow-methods")
.expect("missing methods");
let methods_str = methods.to_str().unwrap();
assert!(methods_str.contains("GET"));
assert!(methods_str.contains("POST"));
assert!(methods_str.contains("OPTIONS"));
let headers = resp
.headers()
.get("access-control-allow-headers")
.expect("missing headers");
let headers_str = headers.to_str().unwrap().to_lowercase();
assert!(headers_str.contains("authorization"));
assert!(headers_str.contains("x-custom"));
}
#[test]
fn test_parse_methods_default() {
let methods = parse_methods(DEFAULT_ALLOW_METHODS);
let _ = methods;
}
#[test]
fn test_parse_methods_empty() {
let methods = parse_methods("");
let _ = methods;
}
#[test]
fn test_parse_methods_with_whitespace() {
let methods = parse_methods("GET, POST , PATCH");
let _ = methods;
}
#[test]
fn test_parse_headers_default() {
let headers = parse_headers(DEFAULT_ALLOW_HEADERS);
let _ = headers;
}
#[test]
fn test_parse_headers_empty() {
let headers = parse_headers("");
let _ = headers;
}
#[test]
fn test_parse_headers_with_whitespace() {
let headers = parse_headers("Authorization, Content-Type , X-Requested-With");
let _ = headers;
}
#[test]
fn test_default_allow_methods_constant() {
assert!(DEFAULT_ALLOW_METHODS.contains("GET"));
assert!(DEFAULT_ALLOW_METHODS.contains("POST"));
assert!(DEFAULT_ALLOW_METHODS.contains("PATCH"));
assert!(DEFAULT_ALLOW_METHODS.contains("PUT"));
assert!(DEFAULT_ALLOW_METHODS.contains("DELETE"));
assert!(DEFAULT_ALLOW_METHODS.contains("OPTIONS"));
}
#[test]
fn test_default_allow_headers_constant() {
assert!(DEFAULT_ALLOW_HEADERS.contains("Authorization"));
assert!(DEFAULT_ALLOW_HEADERS.contains("Content-Type"));
assert!(DEFAULT_ALLOW_HEADERS.contains("If-Match"));
assert!(DEFAULT_ALLOW_HEADERS.contains("If-Modified-Since"));
assert!(DEFAULT_ALLOW_HEADERS.contains("If-None-Match"));
assert!(DEFAULT_ALLOW_HEADERS.contains("If-Unmodified-Since"));
assert!(DEFAULT_ALLOW_HEADERS.contains("X-CSRF-TOKEN"));
assert!(DEFAULT_ALLOW_HEADERS.contains("X-Requested-With"));
}
#[test]
fn test_default_max_age_constant() {
assert_eq!(DEFAULT_MAX_AGE, 1800);
}
#[tokio::test]
async fn test_php_aligned_default_cors_headers() {
let router = make_router(cors_layer());
let resp = send_request(router, "OPTIONS", "/api", Some("https://example.com")).await;
let headers = resp.headers();
assert!(headers.contains_key("access-control-allow-origin"));
assert!(headers.contains_key("access-control-allow-methods"));
assert!(headers.contains_key("access-control-allow-headers"));
assert!(headers.contains_key("access-control-max-age"));
assert!(
!headers.contains_key("access-control-allow-credentials"),
"CORS 不应再返回 allow-credentials 头(安全修复)"
);
}
#[tokio::test]
async fn test_cors_layer_clonable() {
let layer = cors_layer();
let _cloned = layer.clone();
fn assert_send_sync<T: Send + Sync + Clone + 'static>(_: T) {}
assert_send_sync(layer);
}
#[tokio::test]
async fn test_cors_no_origin_header_still_works() {
let router = make_router(cors_layer());
let resp = send_request(router, "GET", "/api", None).await;
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn test_cors_post_request_allowed() {
let router = make_router(cors_layer());
let resp = send_request(router, "POST", "/api", Some("https://example.com")).await;
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"created");
}
#[tokio::test]
async fn test_cors_unknown_method_returns_405() {
let router = make_router(cors_layer());
let builder = Request::builder()
.method(Method::DELETE)
.uri("/api")
.header("origin", "https://example.com");
let req = builder.body(Body::empty()).unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::METHOD_NOT_ALLOWED);
}
#[test]
fn test_header_name_constants_match_php() {
let names = [
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Headers",
"Access-Control-Max-Age",
];
for name in &names {
assert!(
HeaderName::from_bytes(name.as_bytes()).is_ok(),
"invalid header name: {name}"
);
}
}
#[tokio::test]
async fn test_p1_sec_11_empty_cookie_domain_rejects_all_origins() {
let layer = cors_layer_with_origin(""); let router = make_router(layer);
let resp = send_request(router, "GET", "/api", Some("https://evil.com")).await;
let allow_origin = resp.headers().get("access-control-allow-origin");
assert!(
allow_origin.is_none(),
"P1-SEC-11: 空 cookie_domain 时不应回显任何 origin(否则 credentials 可被恶意站点利用)\n\
实际返回: {:?}",
allow_origin
);
}
#[tokio::test]
async fn test_p1_sec_11_whitelisted_origin_allowed() {
let layer = cors_layer_with_origin("example.com");
let router = make_router(layer);
let resp = send_request(router, "GET", "/api", Some("https://example.com")).await;
let allow_origin = resp.headers().get("access-control-allow-origin");
assert!(
allow_origin.is_some(),
"P1-SEC-11: 白名单内的 origin 应被允许"
);
assert_eq!(
allow_origin.unwrap().to_str().unwrap(),
"https://example.com"
);
}
#[tokio::test]
async fn test_p1_sec_11_subdomain_match_allowed() {
let layer = cors_layer_with_origin("example.com");
let router = make_router(layer);
let resp = send_request(router, "GET", "/api", Some("https://api.example.com")).await;
let allow_origin = resp.headers().get("access-control-allow-origin");
assert!(allow_origin.is_some(), "子域名应匹配白名单");
assert_eq!(allow_origin.unwrap().to_str().unwrap(), "https://api.example.com");
}
#[tokio::test]
async fn test_p1_sec_11_evil_subdomain_rejected() {
let layer = cors_layer_with_origin("example.com");
let router = make_router(layer);
let resp = send_request(router, "GET", "/api", Some("https://evil-example.com")).await;
let allow_origin = resp.headers().get("access-control-allow-origin");
assert!(
allow_origin.is_none(),
"P1-SEC-11: evil-example.com 不应匹配 example.com 白名单(后缀匹配防护)"
);
}
}