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