1use crate::handler::BoxFuture;
29use crate::method::Method;
30use crate::middleware::{Middleware, Next};
31use crate::request::Request;
32use crate::response::Response;
33use rustlavel_core::Config;
34use std::sync::Arc;
35use std::time::Duration;
36
37#[derive(Clone)]
39enum Origins {
40 Any,
41 List(Vec<String>),
43 Predicate(Arc<dyn Fn(&str) -> bool + Send + Sync>),
44}
45
46#[derive(Clone)]
48enum AllowedHeaders {
49 Any,
51 List(Vec<String>),
52}
53
54#[derive(Clone)]
55pub struct Cors {
56 paths: Vec<String>,
59 origins: Origins,
60 methods: Vec<Method>,
61 allowed_headers: AllowedHeaders,
62 exposed_headers: Vec<String>,
63 credentials: bool,
64 max_age: Option<Duration>,
65}
66
67impl Default for Cors {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl Cors {
74 pub fn new() -> Self {
80 Cors {
81 paths: vec!["*".to_string()],
82 origins: Origins::List(Vec::new()),
83 methods: vec![
84 Method::Get,
85 Method::Head,
86 Method::Post,
87 Method::Put,
88 Method::Patch,
89 Method::Delete,
90 ],
91 allowed_headers: AllowedHeaders::Any,
92 exposed_headers: Vec::new(),
93 credentials: false,
94 max_age: None,
95 }
96 }
97
98 pub fn permissive() -> Self {
104 Cors { origins: Origins::Any, ..Cors::new() }
105 }
106
107 pub fn from_config(config: &Config) -> Self {
125 let mut cors = Cors::new();
126
127 let paths = config.list("cors.paths");
128 if !paths.is_empty() {
129 cors.paths = paths;
130 }
131
132 let origins = config.list("cors.allowed_origins");
133 cors.origins =
134 if origins.iter().any(|o| o == "*") { Origins::Any } else { Origins::List(origins) };
135
136 let methods = config.list("cors.allowed_methods");
137 if !methods.is_empty() && !methods.iter().any(|m| m == "*") {
138 cors.methods = methods.iter().filter_map(|m| Method::parse(m)).collect();
139 }
140
141 let headers = config.list("cors.allowed_headers");
142 if !headers.is_empty() && !headers.iter().any(|h| h == "*") {
143 cors.allowed_headers = AllowedHeaders::List(headers);
144 }
145
146 cors.exposed_headers = config.list("cors.exposed_headers");
147 cors.credentials = config.bool("cors.supports_credentials", false);
148
149 let max_age = config.int("cors.max_age", 0);
150 if max_age > 0 {
151 cors.max_age = Some(Duration::from_secs(max_age as u64));
152 }
153
154 cors
155 }
156
157 pub fn paths<I, S>(mut self, paths: I) -> Self
159 where
160 I: IntoIterator<Item = S>,
161 S: Into<String>,
162 {
163 self.paths = paths.into_iter().map(Into::into).collect();
164 self
165 }
166
167 pub fn allow_origins<I, S>(mut self, origins: I) -> Self
171 where
172 I: IntoIterator<Item = S>,
173 S: Into<String>,
174 {
175 self.origins = Origins::List(origins.into_iter().map(Into::into).collect());
176 self
177 }
178
179 pub fn allow_any_origin(mut self) -> Self {
180 self.origins = Origins::Any;
181 self
182 }
183
184 pub fn allow_origin_if(mut self, allow: impl Fn(&str) -> bool + Send + Sync + 'static) -> Self {
186 self.origins = Origins::Predicate(Arc::new(allow));
187 self
188 }
189
190 pub fn allow_methods(mut self, methods: impl IntoIterator<Item = Method>) -> Self {
191 self.methods = methods.into_iter().collect();
192 self
193 }
194
195 pub fn allow_headers<I, S>(mut self, headers: I) -> Self
196 where
197 I: IntoIterator<Item = S>,
198 S: Into<String>,
199 {
200 self.allowed_headers = AllowedHeaders::List(headers.into_iter().map(Into::into).collect());
201 self
202 }
203
204 pub fn expose_headers<I, S>(mut self, headers: I) -> Self
208 where
209 I: IntoIterator<Item = S>,
210 S: Into<String>,
211 {
212 self.exposed_headers = headers.into_iter().map(Into::into).collect();
213 self
214 }
215
216 pub fn allow_credentials(mut self) -> Self {
218 self.credentials = true;
219 self
220 }
221
222 pub fn max_age(mut self, duration: Duration) -> Self {
224 self.max_age = Some(duration);
225 self
226 }
227
228 fn applies_to(&self, path: &str) -> bool {
229 let path = path.trim_start_matches('/');
230 self.paths.iter().any(|pattern| {
231 let pattern = pattern.trim_start_matches('/');
232 match pattern.strip_suffix('*') {
233 Some(prefix) => pattern == "*" || path.starts_with(prefix),
234 None => path == pattern,
235 }
236 })
237 }
238
239 fn allows_origin(&self, origin: &str) -> bool {
240 match &self.origins {
241 Origins::Any => true,
242 Origins::List(list) => list.iter().any(|pattern| origin_matches(pattern, origin)),
243 Origins::Predicate(allow) => allow(origin),
244 }
245 }
246
247 fn allow_origin_value(&self, origin: &str) -> Option<String> {
250 if !self.allows_origin(origin) {
251 return None;
252 }
253 let wildcard = matches!(self.origins, Origins::Any) && !self.credentials;
256 Some(if wildcard { "*".to_string() } else { origin.to_string() })
257 }
258
259 fn preflight(&self, request: &Request, origin: &str) -> Response {
260 let mut response = Response::no_content();
265 vary(&mut response, "origin");
266 vary(&mut response, "access-control-request-method");
267 vary(&mut response, "access-control-request-headers");
268
269 let Some(allow_origin) = self.allow_origin_value(origin) else { return response };
270 response.headers.set("access-control-allow-origin", allow_origin);
271
272 let methods = self.methods.iter().map(|m| m.as_str()).collect::<Vec<_>>().join(", ");
273 response.headers.set("access-control-allow-methods", methods);
274
275 let allow_headers = match &self.allowed_headers {
276 AllowedHeaders::List(list) => Some(list.join(", ")),
277 AllowedHeaders::Any => {
280 request.header("access-control-request-headers").map(str::to_string)
281 }
282 };
283 if let Some(headers) = allow_headers.filter(|h| !h.is_empty()) {
284 response.headers.set("access-control-allow-headers", headers);
285 }
286
287 if self.credentials {
288 response.headers.set("access-control-allow-credentials", "true");
289 }
290 if let Some(max_age) = self.max_age {
291 response.headers.set("access-control-max-age", max_age.as_secs().to_string());
292 }
293 response
294 }
295
296 fn decorate(&self, mut response: Response, origin: &str) -> Response {
297 vary(&mut response, "origin");
298 let Some(allow_origin) = self.allow_origin_value(origin) else { return response };
299
300 response.headers.set("access-control-allow-origin", allow_origin);
301 if self.credentials {
302 response.headers.set("access-control-allow-credentials", "true");
303 }
304 if !self.exposed_headers.is_empty() {
305 response.headers.set("access-control-expose-headers", self.exposed_headers.join(", "));
306 }
307 response
308 }
309}
310
311impl Middleware for Cors {
312 fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
313 let origin = match request.header("origin") {
316 Some(origin) if self.applies_to(request.path()) => origin.to_string(),
317 _ => return next.run(request),
318 };
319
320 let is_preflight = request.method() == Method::Options
321 && request.header("access-control-request-method").is_some();
322 if is_preflight {
323 let response = self.preflight(&request, &origin);
324 return Box::pin(async move { response });
325 }
326
327 let cors = self.clone();
328 Box::pin(async move {
329 let response = next.run(request).await;
330 cors.decorate(response, &origin)
331 })
332 }
333}
334
335fn vary(response: &mut Response, name: &str) {
337 let existing = response.headers.get("vary").unwrap_or("").to_string();
338 if existing.split(',').any(|v| v.trim().eq_ignore_ascii_case(name)) {
339 return;
340 }
341 let value = if existing.is_empty() { name.to_string() } else { format!("{existing}, {name}") };
342 response.headers.set("vary", value);
343}
344
345fn origin_matches(pattern: &str, origin: &str) -> bool {
352 let Some((prefix, suffix)) = pattern.split_once('*') else {
353 return pattern.eq_ignore_ascii_case(origin);
354 };
355 let origin_lower = origin.to_ascii_lowercase();
356 let (prefix, suffix) = (prefix.to_ascii_lowercase(), suffix.to_ascii_lowercase());
357
358 let Some(rest) = origin_lower.strip_prefix(prefix.as_str()) else { return false };
359 let Some(label) = rest.strip_suffix(suffix.as_str()) else { return false };
360 !label.is_empty() && !label.contains(['.', '/', ':'])
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::router::Router;
367 use rustlavel_core::Json;
368 use crate::testing::TestClient;
369
370 fn client(cors: Cors) -> TestClient {
371 let mut router = Router::new();
372 router.middleware(cors);
373 router.get("/api/users", |_req: Request| async { Response::text("users") });
374 router.post("/api/users", |_req: Request| async { Response::text("created") });
375 router.get("/web/page", |_req: Request| async {
376 Response::text("page").with_header("vary", "Accept-Encoding")
377 });
378 TestClient::new(router)
379 }
380
381 fn preflight(path: &str, origin: &str) -> Request {
382 Request::new(Method::Options, path)
383 .with_header("origin", origin)
384 .with_header("access-control-request-method", "POST")
385 .with_header("access-control-request-headers", "content-type, x-custom")
386 }
387
388 #[tokio::test]
389 async fn a_request_without_an_origin_is_left_alone() {
390 let response = client(Cors::permissive()).get("/api/users").await;
391 let response = response.assert_ok();
392 assert_eq!(response.header("access-control-allow-origin"), None);
393 }
394
395 #[tokio::test]
396 async fn a_listed_origin_is_allowed_and_echoed() {
397 let cors = Cors::new().allow_origins(["https://app.example.com"]);
398 let request = Request::new(Method::Get, "/api/users").with_header("origin", "https://app.example.com");
399 let response = client(cors).send(request).await;
400
401 let response = response.assert_ok();
402 assert_eq!(response.header("access-control-allow-origin"), Some("https://app.example.com"));
403 assert_eq!(response.header("vary"), Some("origin"));
404 }
405
406 #[tokio::test]
407 async fn an_unlisted_origin_gets_no_cors_headers_at_all() {
408 let cors = Cors::new().allow_origins(["https://app.example.com"]);
409 let request = Request::new(Method::Get, "/api/users").with_header("origin", "https://evil.example");
410 let response = client(cors).send(request).await;
411
412 let response = response.assert_ok();
415 assert_eq!(response.header("access-control-allow-origin"), None);
416 assert_eq!(response.header("vary"), Some("origin"), "caches must still key on origin");
417 }
418
419 #[tokio::test]
420 async fn permissive_answers_with_a_wildcard() {
421 let request = Request::new(Method::Get, "/api/users").with_header("origin", "https://anyone.example");
422 let response = client(Cors::permissive()).send(request).await;
423 assert_eq!(response.header("access-control-allow-origin"), Some("*"));
424 assert_eq!(response.header("access-control-allow-credentials"), None);
425 }
426
427 #[tokio::test]
428 async fn credentials_forbid_the_wildcard_so_the_origin_is_echoed() {
429 let cors = Cors::permissive().allow_credentials();
430 let request = Request::new(Method::Get, "/api/users").with_header("origin", "https://anyone.example");
431 let response = client(cors).send(request).await;
432
433 assert_eq!(response.header("access-control-allow-origin"), Some("https://anyone.example"));
434 assert_eq!(response.header("access-control-allow-credentials"), Some("true"));
435 }
436
437 #[tokio::test]
438 async fn a_preflight_is_answered_without_reaching_the_handler() {
439 let cors = Cors::new()
440 .allow_origins(["https://app.example.com"])
441 .allow_methods([Method::Get, Method::Post])
442 .max_age(Duration::from_secs(600));
443 let response = client(cors).send(preflight("/api/users", "https://app.example.com")).await;
444
445 let response = response.assert_status(204);
446 assert_eq!(response.body(), "", "a preflight has no body and never ran the handler");
447 assert_eq!(response.header("access-control-allow-origin"), Some("https://app.example.com"));
448 assert_eq!(response.header("access-control-allow-methods"), Some("GET, POST"));
449 assert_eq!(
450 response.header("access-control-allow-headers"),
451 Some("content-type, x-custom"),
452 "with no list configured, the requested headers are echoed"
453 );
454 assert_eq!(response.header("access-control-max-age"), Some("600"));
455 assert_eq!(
456 response.header("vary"),
457 Some("origin, access-control-request-method, access-control-request-headers")
458 );
459 }
460
461 #[tokio::test]
462 async fn a_preflight_for_a_path_with_no_options_route_still_succeeds() {
463 client(Cors::permissive()).send(preflight("/api/users", "https://x.example")).await.assert_status(204);
466 }
467
468 #[tokio::test]
469 async fn a_preflight_from_a_forbidden_origin_is_a_bare_204() {
470 let cors = Cors::new().allow_origins(["https://app.example.com"]);
471 let response = client(cors).send(preflight("/api/users", "https://evil.example")).await;
472
473 let response = response.assert_status(204);
474 assert_eq!(response.header("access-control-allow-origin"), None);
475 assert_eq!(response.header("access-control-allow-methods"), None);
476 }
477
478 #[tokio::test]
479 async fn configured_headers_are_listed_rather_than_echoed() {
480 let cors = Cors::permissive().allow_headers(["Content-Type", "Authorization"]);
481 let response = client(cors).send(preflight("/api/users", "https://x.example")).await;
482 assert_eq!(response.header("access-control-allow-headers"), Some("Content-Type, Authorization"));
483 }
484
485 #[tokio::test]
486 async fn plain_options_without_a_request_method_is_not_a_preflight() {
487 let request = Request::new(Method::Options, "/api/users").with_header("origin", "https://x.example");
488 let response = client(Cors::permissive()).send(request).await;
489 let response = response.assert_status(405);
491 assert_eq!(response.header("access-control-allow-origin"), Some("*"));
492 }
493
494 #[tokio::test]
495 async fn exposed_headers_are_named_on_real_responses() {
496 let cors = Cors::permissive().expose_headers(["X-Request-Id", "X-RateLimit-Remaining"]);
497 let request = Request::new(Method::Get, "/api/users").with_header("origin", "https://x.example");
498 let response = client(cors).send(request).await;
499 assert_eq!(
500 response.header("access-control-expose-headers"),
501 Some("X-Request-Id, X-RateLimit-Remaining")
502 );
503 }
504
505 #[tokio::test]
506 async fn vary_is_appended_not_replaced() {
507 let request = Request::new(Method::Get, "/web/page").with_header("origin", "https://x.example");
508 let response = client(Cors::permissive()).send(request).await;
509 assert_eq!(response.header("vary"), Some("Accept-Encoding, origin"));
510 }
511
512 #[tokio::test]
513 async fn paths_restrict_where_cors_applies() {
514 let cors = Cors::permissive().paths(["api/*"]);
515 let client = client(cors);
516
517 let api = Request::new(Method::Get, "/api/users").with_header("origin", "https://x.example");
518 assert_eq!(client.send(api).await.header("access-control-allow-origin"), Some("*"));
519
520 let web = Request::new(Method::Get, "/web/page").with_header("origin", "https://x.example");
521 assert_eq!(client.send(web).await.header("access-control-allow-origin"), None);
522 }
523
524 #[test]
525 fn a_wildcard_matches_exactly_one_label() {
526 assert!(origin_matches("https://*.example.com", "https://app.example.com"));
527 assert!(origin_matches("https://*.example.com", "HTTPS://APP.example.com"));
528 assert!(!origin_matches("https://*.example.com", "https://example.com"));
529 assert!(!origin_matches("https://*.example.com", "https://a.b.example.com"));
530 assert!(!origin_matches("https://*.example.com", "https://example.com.evil.net"));
531 assert!(!origin_matches("https://*.example.com", "http://app.example.com"));
532 assert!(origin_matches("https://app.example.com", "https://app.example.com"));
533 assert!(!origin_matches("https://app.example.com", "https://app.example.com.evil"));
534 }
535
536 #[tokio::test]
537 async fn a_predicate_decides_per_origin() {
538 let cors = Cors::new().allow_origin_if(|origin| origin.ends_with(".trusted.example"));
539 let client = client(cors);
540
541 let yes = Request::new(Method::Get, "/api/users").with_header("origin", "https://a.trusted.example");
542 assert_eq!(
543 client.send(yes).await.header("access-control-allow-origin"),
544 Some("https://a.trusted.example")
545 );
546 let no = Request::new(Method::Get, "/api/users").with_header("origin", "https://a.other.example");
547 assert_eq!(client.send(no).await.header("access-control-allow-origin"), None);
548 }
549
550 #[test]
551 fn from_config_reads_laravels_keys_and_env_friendly_strings() {
552 let config = Config::new();
553 config.set("cors.paths", Json::from(vec!["api/*"]));
554 config.set("cors.allowed_origins", "https://a.example, https://b.example");
556 config.set("cors.allowed_methods", Json::from(vec!["GET", "POST"]));
557 config.set("cors.allowed_headers", Json::from(vec!["*"]));
558 config.set("cors.exposed_headers", "X-Request-Id");
559 config.set("cors.max_age", Json::from(3600_i64));
560 config.set("cors.supports_credentials", true);
561
562 let cors = Cors::from_config(&config);
563 assert!(cors.allows_origin("https://b.example"));
564 assert!(!cors.allows_origin("https://c.example"));
565 assert_eq!(cors.methods, vec![Method::Get, Method::Post]);
566 assert!(matches!(cors.allowed_headers, AllowedHeaders::Any));
567 assert_eq!(cors.exposed_headers, vec!["X-Request-Id"]);
568 assert_eq!(cors.max_age, Some(Duration::from_secs(3600)));
569 assert!(cors.credentials);
570 assert!(cors.applies_to("/api/users"));
571 assert!(!cors.applies_to("/web"));
572 }
573
574 #[test]
575 fn from_config_with_nothing_set_denies_every_origin() {
576 let cors = Cors::from_config(&Config::new());
577 assert!(!cors.allows_origin("https://anyone.example"));
578 }
579
580 #[test]
581 fn a_star_origin_in_config_means_any() {
582 let config = Config::new();
583 config.set("cors.allowed_origins", "*");
584 assert!(Cors::from_config(&config).allows_origin("https://anyone.example"));
585 }
586}