1use 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
31pub struct RuntimeCorsConfig {
33 settings: CorsSettings,
34 policy: Rc<PolicyResolver>,
35 exempt_path: Rc<ExemptPathPredicate>,
36 map_error: Rc<ErrorMapper>,
37}
38
39impl RuntimeCorsConfig {
40 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 #[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 #[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 #[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 #[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
101pub struct RuntimeCors {
103 config: RuntimeCorsConfig,
104}
105
106impl RuntimeCors {
107 #[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
133pub 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 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
218fn 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}