Skip to main content

aster_forge_actix_middleware/
cors.rs

1//! Runtime CORS middleware for Actix Web services.
2//!
3//! Aster products often store CORS settings in runtime configuration rather than in a static Actix
4//! builder. This module owns the reusable middleware mechanics: reading `Origin`, rejecting
5//! disallowed cross-origin requests, handling preflight requests, applying CORS response headers,
6//! and maintaining `Vary`. Product crates provide a policy resolver, exempt-path predicate,
7//! allowed/exposed header lists, and error mapping.
8
9use std::collections::BTreeSet;
10use std::rc::Rc;
11
12use actix_web::{
13    Error, HttpResponse,
14    body::{EitherBody, MessageBody},
15    dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready},
16    http::{
17        Method, header,
18        header::{HeaderMap, HeaderValue},
19    },
20};
21use futures::future::{LocalBoxFuture, Ready, ok};
22
23/// Origin list accepted by a runtime CORS policy.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum CorsAllowedOrigins {
26    /// Cross-origin requests are denied.
27    None,
28    /// Every origin is accepted.
29    Any,
30    /// Only the listed normalized origins are accepted.
31    List(Vec<String>),
32}
33
34/// Product-neutral runtime CORS policy.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RuntimeCorsPolicy {
37    /// Whether CORS processing is enabled.
38    pub enabled: bool,
39    /// Origins accepted when CORS processing is enabled.
40    pub allowed_origins: CorsAllowedOrigins,
41    /// Whether credentials are allowed.
42    pub allow_credentials: bool,
43    /// Browser preflight cache duration.
44    pub max_age_secs: u64,
45}
46
47impl RuntimeCorsPolicy {
48    /// Returns whether requests should be actively checked.
49    pub fn enforces_requests(&self) -> bool {
50        self.enabled && !matches!(self.allowed_origins, CorsAllowedOrigins::None)
51    }
52
53    /// Returns whether a normalized origin is allowed.
54    pub fn allows_origin(&self, origin: &str) -> bool {
55        match &self.allowed_origins {
56            CorsAllowedOrigins::None => false,
57            CorsAllowedOrigins::Any => true,
58            CorsAllowedOrigins::List(origins) => origins.iter().any(|allowed| allowed == origin),
59        }
60    }
61
62    /// Returns whether responses should use `Access-Control-Allow-Origin: *`.
63    pub fn sends_wildcard_origin(&self) -> bool {
64        matches!(self.allowed_origins, CorsAllowedOrigins::Any) && !self.allow_credentials
65    }
66}
67
68/// CORS middleware failure category for product error mapping.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum CorsMiddlewareErrorKind {
71    /// The incoming request contains an invalid origin or preflight header.
72    InvalidRequest,
73    /// A response header produced or inherited by the middleware is invalid.
74    InvalidResponse,
75}
76
77/// Product-neutral CORS middleware error.
78#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
79#[error("{message}")]
80pub struct CorsMiddlewareError {
81    kind: CorsMiddlewareErrorKind,
82    message: String,
83}
84
85impl CorsMiddlewareError {
86    fn new(kind: CorsMiddlewareErrorKind, message: impl Into<String>) -> Self {
87        Self {
88            kind,
89            message: message.into(),
90        }
91    }
92
93    /// Returns the failure category.
94    pub const fn kind(&self) -> CorsMiddlewareErrorKind {
95        self.kind
96    }
97
98    /// Returns the diagnostic message.
99    pub fn message(&self) -> &str {
100        &self.message
101    }
102}
103
104type PolicyResolver = dyn Fn(&ServiceRequest) -> Result<RuntimeCorsPolicy, Error>;
105type ExemptPathPredicate = dyn Fn(&str) -> bool;
106type ErrorMapper = dyn Fn(CorsMiddlewareError) -> Error;
107
108/// Runtime CORS middleware configuration.
109pub struct RuntimeCorsConfig {
110    allowed_methods: Vec<&'static str>,
111    allowed_headers: Vec<&'static str>,
112    exposed_headers: Vec<&'static str>,
113    additional_origin_schemes: Vec<&'static str>,
114    policy: Rc<PolicyResolver>,
115    exempt_path: Rc<ExemptPathPredicate>,
116    map_error: Rc<ErrorMapper>,
117}
118
119impl RuntimeCorsConfig {
120    /// Builds a configuration from product-provided callbacks.
121    pub fn new<P, X, M>(policy: P, exempt_path: X, map_error: M) -> Self
122    where
123        P: Fn(&ServiceRequest) -> Result<RuntimeCorsPolicy, Error> + 'static,
124        X: Fn(&str) -> bool + 'static,
125        M: Fn(CorsMiddlewareError) -> Error + 'static,
126    {
127        Self {
128            allowed_methods: Vec::new(),
129            allowed_headers: Vec::new(),
130            exposed_headers: Vec::new(),
131            additional_origin_schemes: Vec::new(),
132            policy: Rc::new(policy),
133            exempt_path: Rc::new(exempt_path),
134            map_error: Rc::new(map_error),
135        }
136    }
137
138    /// Sets preflight-allowed methods.
139    pub fn allowed_methods(mut self, methods: impl IntoIterator<Item = &'static str>) -> Self {
140        self.allowed_methods = methods.into_iter().collect();
141        self
142    }
143
144    /// Sets preflight-allowed request headers.
145    pub fn allowed_headers(mut self, headers: impl IntoIterator<Item = &'static str>) -> Self {
146        self.allowed_headers = headers.into_iter().collect();
147        self
148    }
149
150    /// Sets response headers exposed to browser JavaScript.
151    pub fn exposed_headers(mut self, headers: impl IntoIterator<Item = &'static str>) -> Self {
152        self.exposed_headers = headers.into_iter().collect();
153        self
154    }
155
156    /// Accepts selected non-HTTP schemes while parsing request origins.
157    ///
158    /// This does not authorize a scheme by itself. The normalized full origin must still match
159    /// [`RuntimeCorsPolicy::allowed_origins`].
160    pub fn additional_origin_schemes(
161        mut self,
162        schemes: impl IntoIterator<Item = &'static str>,
163    ) -> Self {
164        self.additional_origin_schemes = schemes.into_iter().collect();
165        self
166    }
167}
168
169impl Clone for RuntimeCorsConfig {
170    fn clone(&self) -> Self {
171        Self {
172            allowed_methods: self.allowed_methods.clone(),
173            allowed_headers: self.allowed_headers.clone(),
174            exposed_headers: self.exposed_headers.clone(),
175            additional_origin_schemes: self.additional_origin_schemes.clone(),
176            policy: Rc::clone(&self.policy),
177            exempt_path: Rc::clone(&self.exempt_path),
178            map_error: Rc::clone(&self.map_error),
179        }
180    }
181}
182
183/// Actix runtime CORS middleware.
184pub struct RuntimeCors {
185    config: RuntimeCorsConfig,
186}
187
188impl RuntimeCors {
189    /// Creates runtime CORS middleware from a product configuration.
190    pub fn new(config: RuntimeCorsConfig) -> Self {
191        Self { config }
192    }
193}
194
195impl<S, B> Transform<S, ServiceRequest> for RuntimeCors
196where
197    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
198    B: MessageBody + 'static,
199{
200    type Response = ServiceResponse<EitherBody<B>>;
201    type Error = Error;
202    type InitError = ();
203    type Transform = RuntimeCorsMiddleware<S>;
204    type Future = Ready<Result<Self::Transform, Self::InitError>>;
205
206    fn new_transform(&self, service: S) -> Self::Future {
207        ok(RuntimeCorsMiddleware {
208            service: Rc::new(service),
209            config: self.config.clone(),
210        })
211    }
212}
213
214/// Service wrapper installed by [`RuntimeCors`].
215pub struct RuntimeCorsMiddleware<S> {
216    service: Rc<S>,
217    config: RuntimeCorsConfig,
218}
219
220impl<S, B> Service<ServiceRequest> for RuntimeCorsMiddleware<S>
221where
222    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
223    B: MessageBody + 'static,
224{
225    type Response = ServiceResponse<EitherBody<B>>;
226    type Error = Error;
227    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
228
229    forward_ready!(service);
230
231    fn call(&self, req: ServiceRequest) -> Self::Future {
232        let svc = Rc::clone(&self.service);
233        let config = self.config.clone();
234
235        Box::pin(async move {
236            if (config.exempt_path)(req.path()) {
237                return Ok(svc.call(req).await?.map_into_left_body());
238            }
239
240            let Some(origin_header) = req.headers().get(header::ORIGIN).cloned() else {
241                return Ok(svc.call(req).await?.map_into_left_body());
242            };
243
244            let policy = (config.policy)(&req)?;
245
246            if !policy.enforces_requests() {
247                return Ok(svc.call(req).await?.map_into_left_body());
248            }
249
250            let origin = origin_header
251                .to_str()
252                .map_err(|_| {
253                    (config.map_error)(CorsMiddlewareError::new(
254                        CorsMiddlewareErrorKind::InvalidRequest,
255                        "invalid Origin header",
256                    ))
257                })
258                .and_then(|origin| {
259                    aster_forge_utils::url::normalize_origin_with_additional_schemes(
260                        origin,
261                        false,
262                        &config.additional_origin_schemes,
263                    )
264                    .map_err(|error| {
265                        (config.map_error)(CorsMiddlewareError::new(
266                            CorsMiddlewareErrorKind::InvalidRequest,
267                            error.to_string(),
268                        ))
269                    })
270                })?;
271
272            if request_is_same_origin(&req, &origin) {
273                return Ok(svc.call(req).await?.map_into_left_body());
274            }
275
276            if !policy.allows_origin(&origin) {
277                return Ok(forbidden(req).map_into_right_body());
278            }
279
280            if is_preflight_request(&req) {
281                if !requested_method_is_allowed(&req, &config)
282                    || !requested_headers_are_allowed(&req, &config, &config.map_error)?
283                {
284                    return Ok(forbidden(req).map_into_right_body());
285                }
286
287                let mut response = HttpResponse::NoContent().finish();
288                apply_origin_headers(response.headers_mut(), &policy, &origin, &config.map_error)?;
289                apply_preflight_headers(
290                    response.headers_mut(),
291                    &policy,
292                    &config,
293                    &config.map_error,
294                )?;
295                return Ok(req.into_response(response).map_into_right_body());
296            }
297
298            let mut response = svc.call(req).await?.map_into_left_body();
299            apply_origin_headers(response.headers_mut(), &policy, &origin, &config.map_error)?;
300            apply_actual_headers(response.headers_mut(), &config, &config.map_error)?;
301            Ok(response)
302        })
303    }
304}
305
306fn is_preflight_request(req: &ServiceRequest) -> bool {
307    req.method() == Method::OPTIONS
308        && req
309            .headers()
310            .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD)
311}
312
313fn request_is_same_origin(req: &ServiceRequest, origin: &str) -> bool {
314    let conn = req.connection_info();
315    let request_origin = format!(
316        "{}://{}",
317        conn.scheme().to_ascii_lowercase(),
318        conn.host().to_ascii_lowercase()
319    );
320    request_origin == origin
321}
322
323fn requested_method_is_allowed(req: &ServiceRequest, config: &RuntimeCorsConfig) -> bool {
324    let Some(method) = req.headers().get(header::ACCESS_CONTROL_REQUEST_METHOD) else {
325        return false;
326    };
327
328    let Ok(method) = method.to_str() else {
329        return false;
330    };
331
332    config.allowed_methods.contains(&method)
333}
334
335fn requested_headers_are_allowed(
336    req: &ServiceRequest,
337    config: &RuntimeCorsConfig,
338    map_error: &Rc<dyn Fn(CorsMiddlewareError) -> Error>,
339) -> Result<bool, Error> {
340    let Some(request_headers) = req.headers().get(header::ACCESS_CONTROL_REQUEST_HEADERS) else {
341        return Ok(true);
342    };
343
344    let request_headers = request_headers.to_str().map_err(|_| {
345        map_error(CorsMiddlewareError::new(
346            CorsMiddlewareErrorKind::InvalidRequest,
347            "invalid Access-Control-Request-Headers",
348        ))
349    })?;
350
351    // Requested header names are lowercased before comparison (browsers send
352    // them that way), so the configured names must be normalized too —
353    // otherwise a configured "Content-Type" would never match a preflight.
354    let allowed_headers = config
355        .allowed_headers
356        .iter()
357        .map(|header| header.to_ascii_lowercase())
358        .collect::<BTreeSet<String>>();
359
360    for requested in request_headers.split(',') {
361        let requested = requested.trim().to_ascii_lowercase();
362        if requested.is_empty() {
363            continue;
364        }
365
366        let parsed: Result<header::HeaderName, _> = requested.parse();
367        if parsed.is_err() {
368            return Err(map_error(CorsMiddlewareError::new(
369                CorsMiddlewareErrorKind::InvalidRequest,
370                "invalid Access-Control-Request-Headers",
371            )));
372        }
373
374        if !allowed_headers.contains(requested.as_str()) {
375            return Ok(false);
376        }
377    }
378
379    Ok(true)
380}
381
382fn apply_origin_headers(
383    headers: &mut HeaderMap,
384    policy: &RuntimeCorsPolicy,
385    origin: &str,
386    map_error: &Rc<dyn Fn(CorsMiddlewareError) -> Error>,
387) -> Result<(), Error> {
388    if !headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
389        let value = if policy.sends_wildcard_origin() {
390            HeaderValue::from_static("*")
391        } else {
392            header_value(
393                origin,
394                "failed to serialize Access-Control-Allow-Origin",
395                map_error,
396            )?
397        };
398
399        headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, value);
400    }
401
402    if policy.allow_credentials && !headers.contains_key(header::ACCESS_CONTROL_ALLOW_CREDENTIALS) {
403        headers.insert(
404            header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
405            HeaderValue::from_static("true"),
406        );
407    }
408
409    ensure_vary(headers, "Origin", map_error)?;
410    Ok(())
411}
412
413fn apply_preflight_headers(
414    headers: &mut HeaderMap,
415    policy: &RuntimeCorsPolicy,
416    config: &RuntimeCorsConfig,
417    map_error: &Rc<dyn Fn(CorsMiddlewareError) -> Error>,
418) -> Result<(), Error> {
419    headers.insert(
420        header::ACCESS_CONTROL_ALLOW_METHODS,
421        header_value(
422            &config.allowed_methods.join(", "),
423            "failed to serialize Access-Control-Allow-Methods",
424            map_error,
425        )?,
426    );
427    headers.insert(
428        header::ACCESS_CONTROL_ALLOW_HEADERS,
429        header_value(
430            &config.allowed_headers.join(", "),
431            "failed to serialize Access-Control-Allow-Headers",
432            map_error,
433        )?,
434    );
435    headers.insert(
436        header::ACCESS_CONTROL_MAX_AGE,
437        header_value(
438            &policy.max_age_secs.to_string(),
439            "failed to serialize Access-Control-Max-Age",
440            map_error,
441        )?,
442    );
443    ensure_vary(headers, "Access-Control-Request-Method", map_error)?;
444    ensure_vary(headers, "Access-Control-Request-Headers", map_error)?;
445    Ok(())
446}
447
448fn apply_actual_headers(
449    headers: &mut HeaderMap,
450    config: &RuntimeCorsConfig,
451    map_error: &Rc<dyn Fn(CorsMiddlewareError) -> Error>,
452) -> Result<(), Error> {
453    headers.insert(
454        header::ACCESS_CONTROL_EXPOSE_HEADERS,
455        header_value(
456            &config.exposed_headers.join(", "),
457            "failed to serialize Access-Control-Expose-Headers",
458            map_error,
459        )?,
460    );
461    Ok(())
462}
463
464fn ensure_vary(
465    headers: &mut HeaderMap,
466    value: &str,
467    map_error: &Rc<dyn Fn(CorsMiddlewareError) -> Error>,
468) -> Result<(), Error> {
469    let mut vary_values = BTreeSet::new();
470
471    if let Some(existing) = headers.get(header::VARY) {
472        let existing = existing.to_str().map_err(|_| {
473            map_error(CorsMiddlewareError::new(
474                CorsMiddlewareErrorKind::InvalidResponse,
475                "invalid Vary header",
476            ))
477        })?;
478        for item in existing.split(',') {
479            let item = item.trim();
480            if !item.is_empty() {
481                vary_values.insert(item.to_string());
482            }
483        }
484    }
485
486    vary_values.insert(value.to_string());
487    let joined = vary_values.into_iter().collect::<Vec<_>>().join(", ");
488    let header_value = header_value(&joined, "failed to serialize Vary header", map_error)?;
489    headers.insert(header::VARY, header_value);
490    Ok(())
491}
492
493fn header_value(
494    value: &str,
495    error_message: &'static str,
496    map_error: &Rc<dyn Fn(CorsMiddlewareError) -> Error>,
497) -> Result<HeaderValue, Error> {
498    HeaderValue::from_str(value).map_err(|_| {
499        map_error(CorsMiddlewareError::new(
500            CorsMiddlewareErrorKind::InvalidResponse,
501            error_message,
502        ))
503    })
504}
505
506fn forbidden(req: ServiceRequest) -> ServiceResponse {
507    let mut response = HttpResponse::Forbidden().finish();
508    response.headers_mut().insert(
509        header::VARY,
510        HeaderValue::from_static(
511            "Access-Control-Request-Headers, Access-Control-Request-Method, Origin",
512        ),
513    );
514    req.into_response(response)
515}
516
517#[cfg(test)]
518mod tests {
519    use std::sync::{Arc, Mutex};
520
521    use actix_web::{
522        App, HttpResponse,
523        http::{
524            StatusCode,
525            header::{self, HeaderValue},
526        },
527        test, web,
528    };
529
530    use super::{
531        CorsAllowedOrigins, CorsMiddlewareErrorKind, RuntimeCors, RuntimeCorsConfig,
532        RuntimeCorsPolicy,
533    };
534
535    fn test_config() -> RuntimeCorsConfig {
536        RuntimeCorsConfig::new(
537            |_req| {
538                Ok(RuntimeCorsPolicy {
539                    enabled: true,
540                    allowed_origins: CorsAllowedOrigins::List(vec![
541                        "https://panel.example.com".to_string(),
542                    ]),
543                    allow_credentials: true,
544                    max_age_secs: 600,
545                })
546            },
547            |path| path == "/",
548            |error| actix_web::error::ErrorBadRequest(error.to_string()),
549        )
550        .allowed_methods(["GET", "POST", "OPTIONS"])
551        .allowed_headers([
552            "authorization",
553            "content-type",
554            "x-csrf-token",
555            "x-request-id",
556        ])
557        .exposed_headers(["content-length", "x-request-id"])
558    }
559
560    #[actix_web::test]
561    async fn cors_middleware_allows_configured_preflight() {
562        let app = test::init_service(
563            App::new()
564                .wrap(RuntimeCors::new(test_config()))
565                .route("/api/demo", web::post().to(HttpResponse::Ok)),
566        )
567        .await;
568
569        let req = test::TestRequest::default()
570            .method(actix_web::http::Method::OPTIONS)
571            .uri("/api/demo")
572            .insert_header((header::ORIGIN, "https://panel.example.com"))
573            .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
574            .insert_header((
575                header::ACCESS_CONTROL_REQUEST_HEADERS,
576                "content-type, x-csrf-token",
577            ))
578            .to_request();
579        let response = test::call_service(&app, req).await;
580
581        assert_eq!(response.status(), 204);
582        assert_eq!(
583            response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
584            Some(&HeaderValue::from_static("https://panel.example.com"))
585        );
586        assert_eq!(
587            response
588                .headers()
589                .get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS),
590            Some(&HeaderValue::from_static("true"))
591        );
592    }
593
594    #[actix_web::test]
595    async fn cors_middleware_matches_configured_allowed_headers_case_insensitively() {
596        let config = RuntimeCorsConfig::new(
597            |_req| {
598                Ok(RuntimeCorsPolicy {
599                    enabled: true,
600                    allowed_origins: CorsAllowedOrigins::List(vec![
601                        "https://panel.example.com".to_string(),
602                    ]),
603                    allow_credentials: true,
604                    max_age_secs: 600,
605                })
606            },
607            |path| path == "/",
608            |error| actix_web::error::ErrorBadRequest(error.to_string()),
609        )
610        .allowed_methods(["GET", "POST", "OPTIONS"])
611        .allowed_headers(["Content-Type", "X-CSRF-Token"]);
612        let app = test::init_service(
613            App::new()
614                .wrap(RuntimeCors::new(config))
615                .route("/api/demo", web::post().to(HttpResponse::Ok)),
616        )
617        .await;
618
619        let req = test::TestRequest::default()
620            .method(actix_web::http::Method::OPTIONS)
621            .uri("/api/demo")
622            .insert_header((header::ORIGIN, "https://panel.example.com"))
623            .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
624            .insert_header((
625                header::ACCESS_CONTROL_REQUEST_HEADERS,
626                "content-type, x-csrf-token",
627            ))
628            .to_request();
629        let response = test::call_service(&app, req).await;
630
631        assert_eq!(response.status(), 204);
632    }
633
634    #[actix_web::test]
635    async fn cors_middleware_rejects_disallowed_origin() {
636        let app = test::init_service(
637            App::new()
638                .wrap(RuntimeCors::new(test_config()))
639                .route("/api/demo", web::post().to(HttpResponse::Ok)),
640        )
641        .await;
642
643        let req = test::TestRequest::post()
644            .uri("/api/demo")
645            .insert_header((header::ORIGIN, "https://evil.example.com"))
646            .to_request();
647        let response = test::call_service(&app, req).await;
648
649        assert_eq!(response.status(), 403);
650        assert!(response.headers().contains_key(header::VARY));
651    }
652
653    #[actix_web::test]
654    async fn cors_middleware_does_not_parse_origins_when_policy_is_inactive() {
655        let config = RuntimeCorsConfig::new(
656            |_req| {
657                Ok(RuntimeCorsPolicy {
658                    enabled: false,
659                    allowed_origins: CorsAllowedOrigins::None,
660                    allow_credentials: false,
661                    max_age_secs: 60,
662                })
663            },
664            |_| false,
665            |error| actix_web::error::ErrorBadRequest(error.to_string()),
666        );
667        let app = test::init_service(
668            App::new()
669                .wrap(RuntimeCors::new(config))
670                .route("/api/demo", web::get().to(HttpResponse::Ok)),
671        )
672        .await;
673
674        let req = test::TestRequest::get()
675            .uri("/api/demo")
676            .insert_header((
677                header::ORIGIN,
678                "chrome-extension://iikmkjmpaadaobahmlepeloendndfphd",
679            ))
680            .to_request();
681        let response = test::call_service(&app, req).await;
682
683        assert_eq!(response.status(), 200);
684        assert!(
685            response
686                .headers()
687                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
688                .is_none()
689        );
690    }
691
692    #[actix_web::test]
693    async fn cors_middleware_accepts_configured_additional_origin_scheme() {
694        const EXTENSION_ORIGIN: &str = "chrome-extension://iikmkjmpaadaobahmlepeloendndfphd";
695
696        let config = RuntimeCorsConfig::new(
697            |_req| {
698                Ok(RuntimeCorsPolicy {
699                    enabled: true,
700                    allowed_origins: CorsAllowedOrigins::List(vec![EXTENSION_ORIGIN.to_string()]),
701                    allow_credentials: true,
702                    max_age_secs: 60,
703                })
704            },
705            |_| false,
706            |error| actix_web::error::ErrorBadRequest(error.to_string()),
707        )
708        .additional_origin_schemes(["chrome-extension"])
709        .allowed_methods(["GET", "OPTIONS"])
710        .allowed_headers(["authorization"]);
711        let app = test::init_service(
712            App::new()
713                .wrap(RuntimeCors::new(config))
714                .route("/api/demo", web::get().to(HttpResponse::Ok)),
715        )
716        .await;
717
718        let req = test::TestRequest::default()
719            .method(actix_web::http::Method::OPTIONS)
720            .uri("/api/demo")
721            .insert_header((header::ORIGIN, EXTENSION_ORIGIN))
722            .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "GET"))
723            .insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization"))
724            .to_request();
725        let response = test::call_service(&app, req).await;
726
727        assert_eq!(response.status(), 204);
728        assert_eq!(
729            response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
730            Some(&HeaderValue::from_static(EXTENSION_ORIGIN))
731        );
732    }
733
734    #[actix_web::test]
735    async fn cors_middleware_uses_runtime_policy_resolver() {
736        let origins = Arc::new(Mutex::new(vec!["https://one.example.com".to_string()]));
737        let config = RuntimeCorsConfig::new(
738            {
739                let origins = Arc::clone(&origins);
740                move |_req| {
741                    Ok(RuntimeCorsPolicy {
742                        enabled: true,
743                        allowed_origins: CorsAllowedOrigins::List(
744                            origins.lock().expect("origins lock").clone(),
745                        ),
746                        allow_credentials: false,
747                        max_age_secs: 60,
748                    })
749                }
750            },
751            |_| false,
752            |error| actix_web::error::ErrorBadRequest(error.to_string()),
753        )
754        .allowed_methods(["GET"])
755        .allowed_headers(["authorization"])
756        .exposed_headers(["x-request-id"]);
757
758        let app = test::init_service(
759            App::new()
760                .wrap(RuntimeCors::new(config))
761                .route("/api/demo", web::get().to(HttpResponse::Ok)),
762        )
763        .await;
764
765        *origins.lock().expect("origins lock") = vec!["https://two.example.com".to_string()];
766        let req = test::TestRequest::get()
767            .uri("/api/demo")
768            .insert_header((header::ORIGIN, "https://two.example.com"))
769            .to_request();
770        let response = test::call_service(&app, req).await;
771
772        assert_eq!(response.status(), 200);
773        assert_eq!(
774            response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
775            Some(&HeaderValue::from_static("https://two.example.com"))
776        );
777    }
778
779    #[actix_web::test]
780    async fn cors_middleware_classifies_invalid_request_headers() {
781        let kinds = Arc::new(Mutex::new(Vec::new()));
782        let config = RuntimeCorsConfig::new(
783            |_req| {
784                Ok(RuntimeCorsPolicy {
785                    enabled: true,
786                    allowed_origins: CorsAllowedOrigins::Any,
787                    allow_credentials: false,
788                    max_age_secs: 60,
789                })
790            },
791            |_| false,
792            {
793                let kinds = Arc::clone(&kinds);
794                move |error| {
795                    kinds.lock().expect("kinds lock").push(error.kind());
796                    actix_web::error::ErrorBadRequest(error.to_string())
797                }
798            },
799        );
800        let app = test::init_service(
801            App::new()
802                .wrap(RuntimeCors::new(config))
803                .route("/api/demo", web::get().to(HttpResponse::Ok)),
804        )
805        .await;
806
807        let invalid_origin = HeaderValue::from_bytes(&[0xff]).expect("opaque header value");
808        let req = test::TestRequest::get()
809            .uri("/api/demo")
810            .insert_header((header::ORIGIN, invalid_origin))
811            .to_request();
812        let error = test::try_call_service(&app, req)
813            .await
814            .expect_err("invalid request header should return a service error");
815
816        assert_eq!(
817            error.as_response_error().status_code(),
818            StatusCode::BAD_REQUEST
819        );
820        assert_eq!(
821            *kinds.lock().expect("kinds lock"),
822            vec![CorsMiddlewareErrorKind::InvalidRequest]
823        );
824    }
825
826    #[actix_web::test]
827    async fn cors_middleware_classifies_invalid_response_headers() {
828        let kinds = Arc::new(Mutex::new(Vec::new()));
829        let config = RuntimeCorsConfig::new(
830            |_req| {
831                Ok(RuntimeCorsPolicy {
832                    enabled: true,
833                    allowed_origins: CorsAllowedOrigins::Any,
834                    allow_credentials: false,
835                    max_age_secs: 60,
836                })
837            },
838            |_| false,
839            {
840                let kinds = Arc::clone(&kinds);
841                move |error| {
842                    kinds.lock().expect("kinds lock").push(error.kind());
843                    actix_web::error::ErrorInternalServerError(error.to_string())
844                }
845            },
846        );
847        let app = test::init_service(App::new().wrap(RuntimeCors::new(config)).route(
848            "/api/demo",
849            web::get().to(|| async {
850                HttpResponse::Ok()
851                    .insert_header((
852                        header::VARY,
853                        HeaderValue::from_bytes(&[0xff]).expect("opaque header value"),
854                    ))
855                    .finish()
856            }),
857        ))
858        .await;
859
860        let req = test::TestRequest::get()
861            .uri("/api/demo")
862            .insert_header((header::ORIGIN, "https://panel.example.com"))
863            .to_request();
864        let error = test::try_call_service(&app, req)
865            .await
866            .expect_err("invalid response header should return a service error");
867
868        assert_eq!(
869            error.as_response_error().status_code(),
870            StatusCode::INTERNAL_SERVER_ERROR
871        );
872        assert_eq!(
873            *kinds.lock().expect("kinds lock"),
874            vec![CorsMiddlewareErrorKind::InvalidResponse]
875        );
876    }
877}