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 pub fn enforces_requests(&self) -> bool {
50 self.enabled && !matches!(self.allowed_origins, CorsAllowedOrigins::None)
51 }
52
53 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 pub fn sends_wildcard_origin(&self) -> bool {
64 matches!(self.allowed_origins, CorsAllowedOrigins::Any) && !self.allow_credentials
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum CorsMiddlewareErrorKind {
71 InvalidRequest,
73 InvalidResponse,
75}
76
77#[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 pub const fn kind(&self) -> CorsMiddlewareErrorKind {
95 self.kind
96 }
97
98 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
108pub 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 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 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 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 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 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
183pub struct RuntimeCors {
185 config: RuntimeCorsConfig,
186}
187
188impl RuntimeCors {
189 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
214pub 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 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}