aster_forge_middleware/shared/
cors.rs1#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum CorsAllowedOrigins {
5 None,
7 Any,
9 List(Vec<String>),
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct RuntimeCorsPolicy {
16 pub enabled: bool,
18 pub allowed_origins: CorsAllowedOrigins,
20 pub allow_credentials: bool,
22 pub max_age_secs: u64,
24}
25
26impl RuntimeCorsPolicy {
27 #[must_use]
29 pub fn enforces_requests(&self) -> bool {
30 self.enabled && !matches!(self.allowed_origins, CorsAllowedOrigins::None)
31 }
32
33 #[must_use]
35 pub fn allows_origin(&self, origin: &str) -> bool {
36 match &self.allowed_origins {
37 CorsAllowedOrigins::None => false,
38 CorsAllowedOrigins::Any => true,
39 CorsAllowedOrigins::List(origins) => origins.iter().any(|allowed| allowed == origin),
40 }
41 }
42
43 #[must_use]
45 pub fn sends_wildcard_origin(&self) -> bool {
46 matches!(self.allowed_origins, CorsAllowedOrigins::Any) && !self.allow_credentials
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum CorsMiddlewareErrorKind {
53 InvalidRequest,
55 InvalidResponse,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
61#[error("{message}")]
62pub struct CorsMiddlewareError {
63 kind: CorsMiddlewareErrorKind,
64 message: String,
65}
66
67impl CorsMiddlewareError {
68 #[cfg(any(feature = "actix", feature = "axum"))]
69 pub(crate) fn new(kind: CorsMiddlewareErrorKind, message: impl Into<String>) -> Self {
70 Self {
71 kind,
72 message: message.into(),
73 }
74 }
75
76 #[must_use]
78 pub const fn kind(&self) -> CorsMiddlewareErrorKind {
79 self.kind
80 }
81
82 #[must_use]
84 pub fn message(&self) -> &str {
85 &self.message
86 }
87}
88
89#[cfg(any(feature = "actix", feature = "axum"))]
90mod engine {
91 use super::{CorsMiddlewareError, CorsMiddlewareErrorKind, RuntimeCorsPolicy};
92 use http::{HeaderMap, HeaderValue, header};
93 use std::collections::BTreeSet;
94 #[derive(Clone, Default)]
96 pub(crate) struct CorsSettings {
97 pub allowed_methods: Vec<&'static str>,
98 pub allowed_headers: Vec<&'static str>,
99 pub exposed_headers: Vec<&'static str>,
100 pub additional_origin_schemes: Vec<&'static str>,
101 }
102
103 pub(crate) enum CorsDecision {
104 Pass,
105 Reject,
106 Preflight(HeaderMap),
107 Actual(String),
108 }
109
110 pub(crate) fn evaluate(
111 method: &str,
112 headers: &HeaderMap,
113 request_origin: Option<&str>,
114 policy: &RuntimeCorsPolicy,
115 config: &CorsSettings,
116 ) -> Result<CorsDecision, CorsMiddlewareError> {
117 let Some(origin) = headers.get(header::ORIGIN) else {
118 return Ok(CorsDecision::Pass);
119 };
120 if !policy.enforces_requests() {
121 return Ok(CorsDecision::Pass);
122 }
123 let origin = origin.to_str().map_err(|_| {
124 CorsMiddlewareError::new(
125 CorsMiddlewareErrorKind::InvalidRequest,
126 "invalid Origin header",
127 )
128 })?;
129 let origin = aster_forge_utils::url::normalize_origin_with_additional_schemes(
130 origin,
131 false,
132 &config.additional_origin_schemes,
133 )
134 .map_err(|error| {
135 CorsMiddlewareError::new(CorsMiddlewareErrorKind::InvalidRequest, error.to_string())
136 })?;
137 if request_origin == Some(origin.as_str()) {
138 return Ok(CorsDecision::Pass);
139 }
140 if !policy.allows_origin(&origin) {
141 return Ok(CorsDecision::Reject);
142 }
143 if method == "OPTIONS" && headers.contains_key(header::ACCESS_CONTROL_REQUEST_METHOD) {
144 let allowed_method = headers
145 .get(header::ACCESS_CONTROL_REQUEST_METHOD)
146 .and_then(|value| value.to_str().ok())
147 .is_some_and(|method| config.allowed_methods.contains(&method));
148 if !allowed_method || !requested_headers_are_allowed(headers, config)? {
149 return Ok(CorsDecision::Reject);
150 }
151 let mut response = HeaderMap::new();
152 apply_origin_headers(&mut response, policy, &origin)?;
153 apply_preflight_headers(&mut response, policy, config)?;
154 return Ok(CorsDecision::Preflight(response));
155 }
156 Ok(CorsDecision::Actual(origin))
157 }
158 fn requested_headers_are_allowed(
159 headers: &HeaderMap,
160 config: &CorsSettings,
161 ) -> Result<bool, CorsMiddlewareError> {
162 let Some(request_headers) = headers.get(header::ACCESS_CONTROL_REQUEST_HEADERS) else {
163 return Ok(true);
164 };
165
166 let request_headers = request_headers.to_str().map_err(|_| {
167 CorsMiddlewareError::new(
168 CorsMiddlewareErrorKind::InvalidRequest,
169 "invalid Access-Control-Request-Headers",
170 )
171 })?;
172
173 let allowed_headers = config
177 .allowed_headers
178 .iter()
179 .map(|header| header.to_ascii_lowercase())
180 .collect::<BTreeSet<String>>();
181
182 for requested in request_headers.split(',') {
183 let requested = requested.trim().to_ascii_lowercase();
184 if requested.is_empty() {
185 continue;
186 }
187
188 let parsed: Result<header::HeaderName, _> = requested.parse();
189 if parsed.is_err() {
190 return Err(CorsMiddlewareError::new(
191 CorsMiddlewareErrorKind::InvalidRequest,
192 "invalid Access-Control-Request-Headers",
193 ));
194 }
195
196 if !allowed_headers.contains(requested.as_str()) {
197 return Ok(false);
198 }
199 }
200
201 Ok(true)
202 }
203
204 pub(crate) fn apply_origin_headers(
205 headers: &mut HeaderMap,
206 policy: &RuntimeCorsPolicy,
207 origin: &str,
208 ) -> Result<(), CorsMiddlewareError> {
209 if !headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
210 let value = if policy.sends_wildcard_origin() {
211 HeaderValue::from_static("*")
212 } else {
213 header_value(origin, "failed to serialize Access-Control-Allow-Origin")?
214 };
215
216 headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, value);
217 }
218
219 if policy.allow_credentials
220 && !headers.contains_key(header::ACCESS_CONTROL_ALLOW_CREDENTIALS)
221 {
222 headers.insert(
223 header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
224 HeaderValue::from_static("true"),
225 );
226 }
227
228 ensure_vary(headers, "Origin")?;
229 Ok(())
230 }
231
232 fn apply_preflight_headers(
233 headers: &mut HeaderMap,
234 policy: &RuntimeCorsPolicy,
235 config: &CorsSettings,
236 ) -> Result<(), CorsMiddlewareError> {
237 headers.insert(
238 header::ACCESS_CONTROL_ALLOW_METHODS,
239 header_value(
240 &config.allowed_methods.join(", "),
241 "failed to serialize Access-Control-Allow-Methods",
242 )?,
243 );
244 headers.insert(
245 header::ACCESS_CONTROL_ALLOW_HEADERS,
246 header_value(
247 &config.allowed_headers.join(", "),
248 "failed to serialize Access-Control-Allow-Headers",
249 )?,
250 );
251 headers.insert(
252 header::ACCESS_CONTROL_MAX_AGE,
253 header_value(
254 &policy.max_age_secs.to_string(),
255 "failed to serialize Access-Control-Max-Age",
256 )?,
257 );
258 ensure_vary(headers, "Access-Control-Request-Method")?;
259 ensure_vary(headers, "Access-Control-Request-Headers")?;
260 Ok(())
261 }
262
263 pub(crate) fn apply_actual_headers(
264 headers: &mut HeaderMap,
265 config: &CorsSettings,
266 ) -> Result<(), CorsMiddlewareError> {
267 headers.insert(
268 header::ACCESS_CONTROL_EXPOSE_HEADERS,
269 header_value(
270 &config.exposed_headers.join(", "),
271 "failed to serialize Access-Control-Expose-Headers",
272 )?,
273 );
274 Ok(())
275 }
276
277 fn ensure_vary(headers: &mut HeaderMap, value: &str) -> Result<(), CorsMiddlewareError> {
278 let mut values = std::collections::BTreeMap::new();
279 for existing in headers.get_all(header::VARY) {
280 let existing = existing.to_str().map_err(|_| {
281 CorsMiddlewareError::new(
282 CorsMiddlewareErrorKind::InvalidResponse,
283 "invalid Vary header",
284 )
285 })?;
286 for item in existing
287 .split(',')
288 .map(str::trim)
289 .filter(|item| !item.is_empty())
290 {
291 values
292 .entry(item.to_ascii_lowercase())
293 .or_insert_with(|| item.to_string());
294 }
295 }
296 if values.contains_key("*") {
297 return Ok(());
298 }
299 values
300 .entry(value.to_ascii_lowercase())
301 .or_insert_with(|| value.to_string());
302 let joined = values.into_values().collect::<Vec<_>>().join(", ");
303 headers.insert(
304 header::VARY,
305 header_value(&joined, "failed to serialize Vary header")?,
306 );
307 Ok(())
308 }
309
310 fn header_value(
311 value: &str,
312 error_message: &'static str,
313 ) -> Result<HeaderValue, CorsMiddlewareError> {
314 HeaderValue::from_str(value).map_err(|_| {
315 CorsMiddlewareError::new(CorsMiddlewareErrorKind::InvalidResponse, error_message)
316 })
317 }
318}
319
320#[cfg(any(feature = "actix", feature = "axum"))]
321pub(crate) use engine::{
322 CorsDecision, CorsSettings, apply_actual_headers, apply_origin_headers, evaluate,
323};