Skip to main content

aster_forge_middleware/actix/
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::rc::Rc;
10
11use actix_web::{
12    Error, HttpResponse,
13    body::{EitherBody, MessageBody},
14    dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready},
15    http::{
16        header,
17        header::{HeaderMap, HeaderValue},
18    },
19};
20use futures::future::{LocalBoxFuture, Ready, ok};
21
22pub use crate::shared::cors::{
23    CorsAllowedOrigins, CorsMiddlewareError, CorsMiddlewareErrorKind, RuntimeCorsPolicy,
24};
25use crate::shared::cors::{CorsDecision, CorsSettings};
26
27type PolicyResolver = dyn Fn(&ServiceRequest) -> Result<RuntimeCorsPolicy, Error>;
28type ExemptPathPredicate = dyn Fn(&str) -> bool;
29type ErrorMapper = dyn Fn(CorsMiddlewareError) -> Error;
30
31/// Runtime CORS middleware configuration.
32pub struct RuntimeCorsConfig {
33    settings: CorsSettings,
34    policy: Rc<PolicyResolver>,
35    exempt_path: Rc<ExemptPathPredicate>,
36    map_error: Rc<ErrorMapper>,
37}
38
39impl RuntimeCorsConfig {
40    /// Builds a configuration from product-provided callbacks.
41    pub fn new<P, X, M>(policy: P, exempt_path: X, map_error: M) -> Self
42    where
43        P: Fn(&ServiceRequest) -> Result<RuntimeCorsPolicy, Error> + 'static,
44        X: Fn(&str) -> bool + 'static,
45        M: Fn(CorsMiddlewareError) -> Error + 'static,
46    {
47        Self {
48            settings: CorsSettings::default(),
49            policy: Rc::new(policy),
50            exempt_path: Rc::new(exempt_path),
51            map_error: Rc::new(map_error),
52        }
53    }
54
55    /// Sets preflight-allowed methods.
56    #[must_use]
57    pub fn allowed_methods(mut self, methods: impl IntoIterator<Item = &'static str>) -> Self {
58        self.settings.allowed_methods = methods.into_iter().collect();
59        self
60    }
61
62    /// Sets preflight-allowed request headers.
63    #[must_use]
64    pub fn allowed_headers(mut self, headers: impl IntoIterator<Item = &'static str>) -> Self {
65        self.settings.allowed_headers = headers.into_iter().collect();
66        self
67    }
68
69    /// Sets response headers exposed to browser JavaScript.
70    #[must_use]
71    pub fn exposed_headers(mut self, headers: impl IntoIterator<Item = &'static str>) -> Self {
72        self.settings.exposed_headers = headers.into_iter().collect();
73        self
74    }
75
76    /// Accepts selected non-HTTP schemes while parsing request origins.
77    ///
78    /// This does not authorize a scheme by itself. The normalized full origin must still match
79    /// [`RuntimeCorsPolicy::allowed_origins`].
80    #[must_use]
81    pub fn additional_origin_schemes(
82        mut self,
83        schemes: impl IntoIterator<Item = &'static str>,
84    ) -> Self {
85        self.settings.additional_origin_schemes = schemes.into_iter().collect();
86        self
87    }
88}
89
90impl Clone for RuntimeCorsConfig {
91    fn clone(&self) -> Self {
92        Self {
93            settings: self.settings.clone(),
94            policy: Rc::clone(&self.policy),
95            exempt_path: Rc::clone(&self.exempt_path),
96            map_error: Rc::clone(&self.map_error),
97        }
98    }
99}
100
101/// Actix runtime CORS middleware.
102pub struct RuntimeCors {
103    config: RuntimeCorsConfig,
104}
105
106impl RuntimeCors {
107    /// Creates runtime CORS middleware from a product configuration.
108    #[must_use]
109    pub fn new(config: RuntimeCorsConfig) -> Self {
110        Self { config }
111    }
112}
113
114impl<S, B> Transform<S, ServiceRequest> for RuntimeCors
115where
116    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
117    B: MessageBody + 'static,
118{
119    type Response = ServiceResponse<EitherBody<B>>;
120    type Error = Error;
121    type InitError = ();
122    type Transform = RuntimeCorsMiddleware<S>;
123    type Future = Ready<Result<Self::Transform, Self::InitError>>;
124
125    fn new_transform(&self, service: S) -> Self::Future {
126        ok(RuntimeCorsMiddleware {
127            service: Rc::new(service),
128            config: self.config.clone(),
129        })
130    }
131}
132
133/// Service wrapper installed by [`RuntimeCors`].
134pub struct RuntimeCorsMiddleware<S> {
135    service: Rc<S>,
136    config: RuntimeCorsConfig,
137}
138
139impl<S, B> Service<ServiceRequest> for RuntimeCorsMiddleware<S>
140where
141    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
142    B: MessageBody + 'static,
143{
144    type Response = ServiceResponse<EitherBody<B>>;
145    type Error = Error;
146    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
147
148    forward_ready!(service);
149
150    fn call(&self, req: ServiceRequest) -> Self::Future {
151        let svc = Rc::clone(&self.service);
152        let config = self.config.clone();
153
154        Box::pin(async move {
155            if (config.exempt_path)(req.path()) {
156                return Ok(svc.call(req).await?.map_into_left_body());
157            }
158
159            if !req.headers().contains_key(header::ORIGIN) {
160                return Ok(svc.call(req).await?.map_into_left_body());
161            }
162            let policy = (config.policy)(&req)?;
163            if !policy.enforces_requests() {
164                return Ok(svc.call(req).await?.map_into_left_body());
165            }
166            // Actix's connection_info contract is retained. Products must sanitize proxy headers.
167            let request_origin = {
168                let conn = req.connection_info();
169                format!(
170                    "{}://{}",
171                    conn.scheme().to_ascii_lowercase(),
172                    conn.host().to_ascii_lowercase()
173                )
174            };
175            let headers = to_http_headers(req.headers(), CorsMiddlewareErrorKind::InvalidRequest)
176                .map_err(|error| (config.map_error)(error))?;
177            let decision = crate::shared::cors::evaluate(
178                req.method().as_str(),
179                &headers,
180                Some(&request_origin),
181                &policy,
182                &config.settings,
183            )
184            .map_err(|error| (config.map_error)(error))?;
185            match decision {
186                CorsDecision::Pass => Ok(svc.call(req).await?.map_into_left_body()),
187                CorsDecision::Reject => Ok(forbidden(req).map_into_right_body()),
188                CorsDecision::Preflight(headers) => {
189                    let mut response = HttpResponse::NoContent().finish();
190                    replace_headers(response.headers_mut(), &headers)
191                        .map_err(|error| (config.map_error)(error))?;
192                    Ok(req.into_response(response).map_into_right_body())
193                }
194                CorsDecision::Actual(origin) => {
195                    let mut response = svc.call(req).await?.map_into_left_body();
196                    let mut headers = to_http_headers(
197                        response.headers(),
198                        CorsMiddlewareErrorKind::InvalidResponse,
199                    )
200                    .map_err(|error| (config.map_error)(error))?;
201                    crate::shared::cors::apply_origin_headers(&mut headers, &policy, &origin)
202                        .and_then(|()| {
203                            crate::shared::cors::apply_actual_headers(
204                                &mut headers,
205                                &config.settings,
206                            )
207                        })
208                        .map_err(|error| (config.map_error)(error))?;
209                    replace_headers(response.headers_mut(), &headers)
210                        .map_err(|error| (config.map_error)(error))?;
211                    Ok(response)
212                }
213            }
214        })
215    }
216}
217
218// Actix uses http 0.2; the shared engine and Axum use http 1.
219fn to_http_headers(
220    headers: &HeaderMap,
221    kind: CorsMiddlewareErrorKind,
222) -> Result<http::HeaderMap, CorsMiddlewareError> {
223    let mut result = http::HeaderMap::new();
224    for (name, value) in headers {
225        let name = http::HeaderName::from_bytes(name.as_str().as_bytes())
226            .map_err(|_| CorsMiddlewareError::new(kind, "invalid header name"))?;
227        let value = http::HeaderValue::from_bytes(value.as_bytes())
228            .map_err(|_| CorsMiddlewareError::new(kind, "invalid header value"))?;
229        result.append(name, value);
230    }
231    Ok(result)
232}
233
234fn replace_headers(
235    headers: &mut HeaderMap,
236    values: &http::HeaderMap,
237) -> Result<(), CorsMiddlewareError> {
238    let mut result = HeaderMap::new();
239    for (name, value) in values {
240        let name = header::HeaderName::from_bytes(name.as_str().as_bytes()).map_err(|_| {
241            CorsMiddlewareError::new(
242                CorsMiddlewareErrorKind::InvalidResponse,
243                "invalid header name",
244            )
245        })?;
246        let value = HeaderValue::from_bytes(value.as_bytes()).map_err(|_| {
247            CorsMiddlewareError::new(
248                CorsMiddlewareErrorKind::InvalidResponse,
249                "invalid header value",
250            )
251        })?;
252        result.append(name, value);
253    }
254    *headers = result;
255    Ok(())
256}
257
258fn forbidden(req: ServiceRequest) -> ServiceResponse {
259    let mut response = HttpResponse::Forbidden().finish();
260    response.headers_mut().insert(
261        header::VARY,
262        HeaderValue::from_static(
263            "Access-Control-Request-Headers, Access-Control-Request-Method, Origin",
264        ),
265    );
266    req.into_response(response)
267}
268
269#[cfg(test)]
270mod tests {
271    use std::sync::{Arc, Mutex};
272
273    use actix_web::{
274        App, HttpResponse,
275        http::{
276            StatusCode,
277            header::{self, HeaderValue},
278        },
279        test, web,
280    };
281
282    use super::{
283        CorsAllowedOrigins, CorsMiddlewareErrorKind, RuntimeCors, RuntimeCorsConfig,
284        RuntimeCorsPolicy,
285    };
286
287    fn test_config() -> RuntimeCorsConfig {
288        RuntimeCorsConfig::new(
289            |_req| {
290                Ok(RuntimeCorsPolicy {
291                    enabled: true,
292                    allowed_origins: CorsAllowedOrigins::List(vec![
293                        "https://panel.example.com".to_string(),
294                    ]),
295                    allow_credentials: true,
296                    max_age_secs: 600,
297                })
298            },
299            |path| path == "/",
300            |error| actix_web::error::ErrorBadRequest(error.to_string()),
301        )
302        .allowed_methods(["GET", "POST", "OPTIONS"])
303        .allowed_headers([
304            "authorization",
305            "content-type",
306            "x-csrf-token",
307            "x-request-id",
308        ])
309        .exposed_headers(["content-length", "x-request-id"])
310    }
311
312    #[actix_web::test]
313    async fn shared_header_adapter_preserves_multiple_vary_and_cookie_values() {
314        let app = test::init_service(App::new().wrap(RuntimeCors::new(test_config())).route(
315            "/api/demo",
316            web::get().to(|| async {
317                HttpResponse::Ok()
318                    .append_header((header::VARY, "Accept-Encoding, origin"))
319                    .append_header((header::VARY, "Accept-Language"))
320                    .append_header((header::SET_COOKIE, "a=1"))
321                    .append_header((header::SET_COOKIE, "b=2"))
322                    .finish()
323            }),
324        ))
325        .await;
326        let response = test::call_service(
327            &app,
328            test::TestRequest::get()
329                .uri("/api/demo")
330                .insert_header((header::ORIGIN, "https://panel.example.com"))
331                .to_request(),
332        )
333        .await;
334        let vary = response
335            .headers()
336            .get(header::VARY)
337            .unwrap()
338            .to_str()
339            .unwrap();
340        assert!(vary.contains("Accept-Encoding"));
341        assert!(vary.contains("Accept-Language"));
342        assert_eq!(vary.to_ascii_lowercase().matches("origin").count(), 1);
343        assert_eq!(
344            response
345                .headers()
346                .get_all(header::SET_COOKIE)
347                .map(|value| value.to_str().unwrap())
348                .collect::<Vec<_>>(),
349            vec!["a=1", "b=2"]
350        );
351    }
352
353    #[actix_web::test]
354    async fn cors_middleware_allows_configured_preflight() {
355        let app = test::init_service(
356            App::new()
357                .wrap(RuntimeCors::new(test_config()))
358                .route("/api/demo", web::post().to(HttpResponse::Ok)),
359        )
360        .await;
361
362        let req = test::TestRequest::default()
363            .method(actix_web::http::Method::OPTIONS)
364            .uri("/api/demo")
365            .insert_header((header::ORIGIN, "https://panel.example.com"))
366            .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
367            .insert_header((
368                header::ACCESS_CONTROL_REQUEST_HEADERS,
369                "content-type, x-csrf-token",
370            ))
371            .to_request();
372        let response = test::call_service(&app, req).await;
373
374        assert_eq!(response.status(), 204);
375        assert_eq!(
376            response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
377            Some(&HeaderValue::from_static("https://panel.example.com"))
378        );
379        assert_eq!(
380            response
381                .headers()
382                .get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS),
383            Some(&HeaderValue::from_static("true"))
384        );
385    }
386
387    #[actix_web::test]
388    async fn cors_middleware_matches_configured_allowed_headers_case_insensitively() {
389        let config = RuntimeCorsConfig::new(
390            |_req| {
391                Ok(RuntimeCorsPolicy {
392                    enabled: true,
393                    allowed_origins: CorsAllowedOrigins::List(vec![
394                        "https://panel.example.com".to_string(),
395                    ]),
396                    allow_credentials: true,
397                    max_age_secs: 600,
398                })
399            },
400            |path| path == "/",
401            |error| actix_web::error::ErrorBadRequest(error.to_string()),
402        )
403        .allowed_methods(["GET", "POST", "OPTIONS"])
404        .allowed_headers(["Content-Type", "X-CSRF-Token"]);
405        let app = test::init_service(
406            App::new()
407                .wrap(RuntimeCors::new(config))
408                .route("/api/demo", web::post().to(HttpResponse::Ok)),
409        )
410        .await;
411
412        let req = test::TestRequest::default()
413            .method(actix_web::http::Method::OPTIONS)
414            .uri("/api/demo")
415            .insert_header((header::ORIGIN, "https://panel.example.com"))
416            .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "POST"))
417            .insert_header((
418                header::ACCESS_CONTROL_REQUEST_HEADERS,
419                "content-type, x-csrf-token",
420            ))
421            .to_request();
422        let response = test::call_service(&app, req).await;
423
424        assert_eq!(response.status(), 204);
425    }
426
427    #[actix_web::test]
428    async fn cors_middleware_rejects_disallowed_origin() {
429        let app = test::init_service(
430            App::new()
431                .wrap(RuntimeCors::new(test_config()))
432                .route("/api/demo", web::post().to(HttpResponse::Ok)),
433        )
434        .await;
435
436        let req = test::TestRequest::post()
437            .uri("/api/demo")
438            .insert_header((header::ORIGIN, "https://evil.example.com"))
439            .to_request();
440        let response = test::call_service(&app, req).await;
441
442        assert_eq!(response.status(), 403);
443        assert!(response.headers().contains_key(header::VARY));
444    }
445
446    #[actix_web::test]
447    async fn cors_middleware_does_not_parse_origins_when_policy_is_inactive() {
448        let config = RuntimeCorsConfig::new(
449            |_req| {
450                Ok(RuntimeCorsPolicy {
451                    enabled: false,
452                    allowed_origins: CorsAllowedOrigins::None,
453                    allow_credentials: false,
454                    max_age_secs: 60,
455                })
456            },
457            |_| false,
458            |error| actix_web::error::ErrorBadRequest(error.to_string()),
459        );
460        let app = test::init_service(
461            App::new()
462                .wrap(RuntimeCors::new(config))
463                .route("/api/demo", web::get().to(HttpResponse::Ok)),
464        )
465        .await;
466
467        let req = test::TestRequest::get()
468            .uri("/api/demo")
469            .insert_header((
470                header::ORIGIN,
471                "chrome-extension://iikmkjmpaadaobahmlepeloendndfphd",
472            ))
473            .to_request();
474        let response = test::call_service(&app, req).await;
475
476        assert_eq!(response.status(), 200);
477        assert!(
478            response
479                .headers()
480                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
481                .is_none()
482        );
483    }
484
485    #[actix_web::test]
486    async fn cors_middleware_accepts_configured_additional_origin_scheme() {
487        const EXTENSION_ORIGIN: &str = "chrome-extension://iikmkjmpaadaobahmlepeloendndfphd";
488
489        let config = RuntimeCorsConfig::new(
490            |_req| {
491                Ok(RuntimeCorsPolicy {
492                    enabled: true,
493                    allowed_origins: CorsAllowedOrigins::List(vec![EXTENSION_ORIGIN.to_string()]),
494                    allow_credentials: true,
495                    max_age_secs: 60,
496                })
497            },
498            |_| false,
499            |error| actix_web::error::ErrorBadRequest(error.to_string()),
500        )
501        .additional_origin_schemes(["chrome-extension"])
502        .allowed_methods(["GET", "OPTIONS"])
503        .allowed_headers(["authorization"]);
504        let app = test::init_service(
505            App::new()
506                .wrap(RuntimeCors::new(config))
507                .route("/api/demo", web::get().to(HttpResponse::Ok)),
508        )
509        .await;
510
511        let req = test::TestRequest::default()
512            .method(actix_web::http::Method::OPTIONS)
513            .uri("/api/demo")
514            .insert_header((header::ORIGIN, EXTENSION_ORIGIN))
515            .insert_header((header::ACCESS_CONTROL_REQUEST_METHOD, "GET"))
516            .insert_header((header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization"))
517            .to_request();
518        let response = test::call_service(&app, req).await;
519
520        assert_eq!(response.status(), 204);
521        assert_eq!(
522            response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
523            Some(&HeaderValue::from_static(EXTENSION_ORIGIN))
524        );
525    }
526
527    #[actix_web::test]
528    async fn cors_middleware_uses_runtime_policy_resolver() {
529        let origins = Arc::new(Mutex::new(vec!["https://one.example.com".to_string()]));
530        let config = RuntimeCorsConfig::new(
531            {
532                let origins = Arc::clone(&origins);
533                move |_req| {
534                    Ok(RuntimeCorsPolicy {
535                        enabled: true,
536                        allowed_origins: CorsAllowedOrigins::List(
537                            origins.lock().expect("origins lock").clone(),
538                        ),
539                        allow_credentials: false,
540                        max_age_secs: 60,
541                    })
542                }
543            },
544            |_| false,
545            |error| actix_web::error::ErrorBadRequest(error.to_string()),
546        )
547        .allowed_methods(["GET"])
548        .allowed_headers(["authorization"])
549        .exposed_headers(["x-request-id"]);
550
551        let app = test::init_service(
552            App::new()
553                .wrap(RuntimeCors::new(config))
554                .route("/api/demo", web::get().to(HttpResponse::Ok)),
555        )
556        .await;
557
558        *origins.lock().expect("origins lock") = vec!["https://two.example.com".to_string()];
559        let req = test::TestRequest::get()
560            .uri("/api/demo")
561            .insert_header((header::ORIGIN, "https://two.example.com"))
562            .to_request();
563        let response = test::call_service(&app, req).await;
564
565        assert_eq!(response.status(), 200);
566        assert_eq!(
567            response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
568            Some(&HeaderValue::from_static("https://two.example.com"))
569        );
570    }
571
572    #[actix_web::test]
573    async fn cors_middleware_classifies_invalid_request_headers() {
574        let kinds = Arc::new(Mutex::new(Vec::new()));
575        let config = RuntimeCorsConfig::new(
576            |_req| {
577                Ok(RuntimeCorsPolicy {
578                    enabled: true,
579                    allowed_origins: CorsAllowedOrigins::Any,
580                    allow_credentials: false,
581                    max_age_secs: 60,
582                })
583            },
584            |_| false,
585            {
586                let kinds = Arc::clone(&kinds);
587                move |error| {
588                    kinds.lock().expect("kinds lock").push(error.kind());
589                    actix_web::error::ErrorBadRequest(error.to_string())
590                }
591            },
592        );
593        let app = test::init_service(
594            App::new()
595                .wrap(RuntimeCors::new(config))
596                .route("/api/demo", web::get().to(HttpResponse::Ok)),
597        )
598        .await;
599
600        let invalid_origin = HeaderValue::from_bytes(&[0xff]).expect("opaque header value");
601        let req = test::TestRequest::get()
602            .uri("/api/demo")
603            .insert_header((header::ORIGIN, invalid_origin))
604            .to_request();
605        let error = test::try_call_service(&app, req)
606            .await
607            .expect_err("invalid request header should return a service error");
608
609        assert_eq!(
610            error.as_response_error().status_code(),
611            StatusCode::BAD_REQUEST
612        );
613        assert_eq!(
614            *kinds.lock().expect("kinds lock"),
615            vec![CorsMiddlewareErrorKind::InvalidRequest]
616        );
617    }
618
619    #[actix_web::test]
620    async fn cors_middleware_classifies_invalid_response_headers() {
621        let kinds = Arc::new(Mutex::new(Vec::new()));
622        let config = RuntimeCorsConfig::new(
623            |_req| {
624                Ok(RuntimeCorsPolicy {
625                    enabled: true,
626                    allowed_origins: CorsAllowedOrigins::Any,
627                    allow_credentials: false,
628                    max_age_secs: 60,
629                })
630            },
631            |_| false,
632            {
633                let kinds = Arc::clone(&kinds);
634                move |error| {
635                    kinds.lock().expect("kinds lock").push(error.kind());
636                    actix_web::error::ErrorInternalServerError(error.to_string())
637                }
638            },
639        );
640        let app = test::init_service(App::new().wrap(RuntimeCors::new(config)).route(
641            "/api/demo",
642            web::get().to(|| async {
643                HttpResponse::Ok()
644                    .insert_header((
645                        header::VARY,
646                        HeaderValue::from_bytes(&[0xff]).expect("opaque header value"),
647                    ))
648                    .finish()
649            }),
650        ))
651        .await;
652
653        let req = test::TestRequest::get()
654            .uri("/api/demo")
655            .insert_header((header::ORIGIN, "https://panel.example.com"))
656            .to_request();
657        let error = test::try_call_service(&app, req)
658            .await
659            .expect_err("invalid response header should return a service error");
660
661        assert_eq!(
662            error.as_response_error().status_code(),
663            StatusCode::INTERNAL_SERVER_ERROR
664        );
665        assert_eq!(
666            *kinds.lock().expect("kinds lock"),
667            vec![CorsMiddlewareErrorKind::InvalidResponse]
668        );
669    }
670}