Skip to main content

aster_forge_middleware/shared/
cors.rs

1//! Shared runtime CORS policy, preflight validation and response headers.
2/// Origin list accepted by a runtime CORS policy.
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum CorsAllowedOrigins {
5    /// No active CORS policy: requests pass through without adding CORS headers.
6    None,
7    /// Every origin is accepted.
8    Any,
9    /// Only the listed normalized origins are accepted.
10    List(Vec<String>),
11}
12
13/// Product-neutral runtime CORS policy.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct RuntimeCorsPolicy {
16    /// Whether CORS processing is enabled.
17    pub enabled: bool,
18    /// Origins accepted when CORS processing is enabled.
19    pub allowed_origins: CorsAllowedOrigins,
20    /// Whether credentials are allowed.
21    pub allow_credentials: bool,
22    /// Browser preflight cache duration.
23    pub max_age_secs: u64,
24}
25
26impl RuntimeCorsPolicy {
27    /// Returns whether requests should be actively checked.
28    #[must_use]
29    pub fn enforces_requests(&self) -> bool {
30        self.enabled && !matches!(self.allowed_origins, CorsAllowedOrigins::None)
31    }
32
33    /// Returns whether a normalized origin is allowed.
34    #[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    /// Returns whether responses should use `Access-Control-Allow-Origin: *`.
44    #[must_use]
45    pub fn sends_wildcard_origin(&self) -> bool {
46        matches!(self.allowed_origins, CorsAllowedOrigins::Any) && !self.allow_credentials
47    }
48}
49
50/// CORS middleware failure category for product error mapping.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum CorsMiddlewareErrorKind {
53    /// The incoming request contains an invalid origin or preflight header.
54    InvalidRequest,
55    /// A response header produced or inherited by the middleware is invalid.
56    InvalidResponse,
57}
58
59/// Product-neutral CORS middleware error.
60#[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    /// Returns the failure category.
77    #[must_use]
78    pub const fn kind(&self) -> CorsMiddlewareErrorKind {
79        self.kind
80    }
81
82    /// Returns the diagnostic message.
83    #[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    /// Static CORS method/header lists; runtime origin policy is resolved per request.
95    #[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        // Requested header names are lowercased before comparison (browsers send
174        // them that way), so the configured names must be normalized too —
175        // otherwise a configured "Content-Type" would never match a preflight.
176        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};