Skip to main content

webx_host/
cors.rs

1//! Cross-Origin Resource Sharing (CORS) middleware.
2//!
3//! Automatically configured from `appsettings.json` `Cors` section.
4//! Handles preflight OPTIONS requests and sets CORS response headers.
5
6use std::ops::ControlFlow;
7use webx_core::error::Result;
8use webx_core::http::IHttpContext;
9use webx_core::middleware::IMiddleware;
10
11/// CORS configuration loaded from appsettings.json.
12#[derive(Debug, Clone)]
13pub struct CorsConfig {
14    /// Allowed origins. Use `"*"` to allow any origin.
15    pub origins: Vec<String>,
16    /// Allowed HTTP methods (default: GET, POST, PUT, DELETE, OPTIONS).
17    pub methods: Vec<String>,
18    /// Allowed request headers.
19    pub headers: Vec<String>,
20    /// Response headers exposed to the client (via access-control-expose-headers).
21    pub expose_headers: Vec<String>,
22    /// Whether credentials (cookies) are allowed.
23    pub allow_credentials: bool,
24    /// Max age for preflight cache (seconds).
25    pub max_age: u32,
26}
27
28impl Default for CorsConfig {
29    fn default() -> Self {
30        Self {
31            origins: vec!["*".to_string()],
32            methods: vec![
33                "GET".to_string(),
34                "POST".to_string(),
35                "PUT".to_string(),
36                "DELETE".to_string(),
37                "PATCH".to_string(),
38                "OPTIONS".to_string(),
39            ],
40            headers: vec!["Content-Type".to_string(), "Authorization".to_string()],
41            expose_headers: Vec::new(),
42            allow_credentials: false,
43            max_age: 86400,
44        }
45    }
46}
47
48/// Built-in CORS middleware.
49///
50/// Reads config from `appsettings.json` →`Cors` section at build time
51/// and applies CORS headers to every response. Handles preflight OPTIONS.
52pub struct CorsMiddleware {
53    config: CorsConfig,
54}
55
56impl CorsMiddleware {
57    /// Create a new CORS middleware with the given configuration.
58    pub fn new(config: CorsConfig) -> Self {
59        Self { config }
60    }
61}
62
63#[async_trait::async_trait]
64impl IMiddleware for CorsMiddleware {
65    async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
66        // Owned, because the response is mutated below while the Origin value
67        // is still needed for the preflight decision.
68        let origin = ctx.request().header("origin").map(str::to_string);
69
70        // Determine allowed origin
71        let allowed = self
72            .config
73            .origins
74            .iter()
75            .find(|o| *o == "*" || origin.as_deref().is_some_and(|orig| *o == orig))
76            .cloned();
77
78        if let Some(ref origin_value) = allowed {
79            // When allow_credentials is true, "*" cannot be used per CORS spec;
80            // echo the request's Origin header instead.
81            let header_value = if origin_value == "*" && self.config.allow_credentials {
82                origin.as_deref().unwrap_or("*").to_string()
83            } else if origin_value == "*" {
84                "*".to_string()
85            } else if let Some(o) = origin.as_deref() {
86                o.to_string()
87            } else {
88                origin_value.clone()
89            };
90
91            ctx.response_mut()
92                .set_header("access-control-allow-origin", &header_value);
93
94            if self.config.allow_credentials {
95                ctx.response_mut()
96                    .set_header("access-control-allow-credentials", "true");
97            }
98        }
99
100        // Only a real CORS preflight is short-circuited here: it must be an
101        // OPTIONS request that carries both `Origin` and
102        // `Access-Control-Request-Method`. A plain `OPTIONS` (curl, method
103        // discovery) belongs to the router, which answers with `Allow` or 404 —
104        // answering 204 for any OPTIONS made nonexistent paths look like
105        // existing ones.
106        let is_preflight = ctx.request().method().eq_ignore_ascii_case("OPTIONS")
107            && origin.is_some()
108            && ctx
109                .request()
110                .header("access-control-request-method")
111                .is_some();
112        if is_preflight {
113            ctx.response_mut().set_status(204);
114            ctx.response_mut().set_header(
115                "access-control-allow-methods",
116                &self.config.methods.join(", "),
117            );
118            ctx.response_mut().set_header(
119                "access-control-allow-headers",
120                &self.config.headers.join(", "),
121            );
122            ctx.response_mut()
123                .set_header("access-control-max-age", &self.config.max_age.to_string());
124            // Short-circuit: preflight response is complete, no need to
125            // continue to router or final handler.
126            return Ok(ControlFlow::Break(()));
127        }
128
129        // For normal requests, expose specified response headers to the client
130        if !self.config.expose_headers.is_empty() {
131            ctx.response_mut().set_header(
132                "access-control-expose-headers",
133                &self.config.expose_headers.join(", "),
134            );
135        }
136
137        Ok(ControlFlow::Continue(()))
138    }
139}