1use 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#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum CorsAllowedOrigins {
26 None,
28 Any,
30 List(Vec<String>),
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RuntimeCorsPolicy {
37 pub enabled: bool,
39 pub allowed_origins: CorsAllowedOrigins,
41 pub allow_credentials: bool,
43 pub max_age_secs: u64,
45}
46
47impl RuntimeCorsPolicy {
48 #[must_use]
50 pub fn enforces_requests(&self) -> bool {
51 self.enabled && !matches!(self.allowed_origins, CorsAllowedOrigins::None)
52 }
53
54 #[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 #[must_use]
66 pub fn sends_wildcard_origin(&self) -> bool {
67 matches!(self.allowed_origins, CorsAllowedOrigins::Any) && !self.allow_credentials
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum CorsMiddlewareErrorKind {
74 InvalidRequest,
76 InvalidResponse,
78}
79
80#[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 #[must_use]
98 pub const fn kind(&self) -> CorsMiddlewareErrorKind {
99 self.kind
100 }
101
102 #[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
113pub 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 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 #[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 #[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 #[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 #[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
192pub struct RuntimeCors {
194 config: RuntimeCorsConfig,
195}
196
197impl RuntimeCors {
198 #[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
224pub 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 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}