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