Skip to main content

soma_http_server/middleware/
cors.rs

1//! Generic CORS configuration.
2//!
3//! This builder takes origins, methods, and headers as parameters — it has
4//! no opinion on which origins or headers a product allows. Product-specific
5//! header lists (e.g. Soma's `mcp-protocol-version`) stay in the owning
6//! product crate and get passed in here.
7
8use axum::http::{HeaderName, HeaderValue, Method};
9pub use tower_http::cors::CorsLayer;
10
11/// Build a `CorsLayer` that allows exactly the given origins, methods, and
12/// request headers. Callers own picking a sane (non-empty, non-wildcard when
13/// credentials matter) set of origins.
14pub fn cors_layer(
15    origins: Vec<HeaderValue>,
16    methods: Vec<Method>,
17    headers: Vec<HeaderName>,
18) -> CorsLayer {
19    CorsLayer::new()
20        .allow_origin(origins)
21        .allow_methods(methods)
22        .allow_headers(headers)
23}
24
25#[cfg(test)]
26mod tests {
27    use super::*;
28    use axum::{
29        body::Body,
30        http::{Request, StatusCode},
31        routing::get,
32        Router,
33    };
34    use tower::ServiceExt;
35
36    #[tokio::test]
37    async fn preflight_from_an_allowed_origin_succeeds() {
38        let origin: HeaderValue = "https://example.test".parse().unwrap();
39        let layer = cors_layer(
40            vec![origin.clone()],
41            vec![Method::GET],
42            vec![axum::http::header::CONTENT_TYPE],
43        );
44        let app = Router::new()
45            .route("/", get(|| async { "ok" }))
46            .layer(layer);
47
48        let request = Request::builder()
49            .method(Method::OPTIONS)
50            .uri("/")
51            .header(axum::http::header::ORIGIN, origin.clone())
52            .header(axum::http::header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
53            .body(Body::empty())
54            .unwrap();
55        let response = app.oneshot(request).await.unwrap();
56        assert_eq!(response.status(), StatusCode::OK);
57        assert_eq!(
58            response
59                .headers()
60                .get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
61                .unwrap(),
62            &origin
63        );
64    }
65
66    #[tokio::test]
67    async fn preflight_from_a_disallowed_origin_omits_the_allow_origin_header() {
68        // The whole point of `cors_layer` is restricting origins — a
69        // misconfiguration that accidentally widens the allow-list is a
70        // security regression, so assert the negative case explicitly
71        // rather than only ever testing the happy path.
72        let allowed: HeaderValue = "https://example.test".parse().unwrap();
73        let disallowed: HeaderValue = "https://not-allowed.test".parse().unwrap();
74        let layer = cors_layer(
75            vec![allowed],
76            vec![Method::GET],
77            vec![axum::http::header::CONTENT_TYPE],
78        );
79        let app = Router::new()
80            .route("/", get(|| async { "ok" }))
81            .layer(layer);
82
83        let request = Request::builder()
84            .method(Method::OPTIONS)
85            .uri("/")
86            .header(axum::http::header::ORIGIN, disallowed)
87            .header(axum::http::header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
88            .body(Body::empty())
89            .unwrap();
90        let response = app.oneshot(request).await.unwrap();
91        assert!(
92            response
93                .headers()
94                .get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
95                .is_none(),
96            "a disallowed origin must not receive an Access-Control-Allow-Origin header"
97        );
98    }
99}