1use std::collections::BTreeMap;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct CorsConfig {
47 pub enabled: bool,
50 pub allow_origins: Vec<String>,
52 pub allow_methods: Vec<String>,
53 pub allow_headers: Vec<String>,
54 pub allow_credentials: bool,
55 pub max_age: u64,
58}
59
60impl Default for CorsConfig {
61 fn default() -> Self {
62 Self {
63 enabled: false,
64 allow_origins: vec!["*".to_owned()],
65 allow_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
66 .iter()
67 .map(|method| (*method).to_owned())
68 .collect(),
69 allow_headers: vec!["*".to_owned()],
70 allow_credentials: false,
71 max_age: 600,
72 }
73 }
74}
75
76pub fn downgrade_wildcard_credentials(cors: &mut CorsConfig) {
82 if cors.allow_origins.iter().any(|origin| origin == "*") && cors.allow_credentials {
83 cors.allow_credentials = false;
84 }
85}
86
87#[must_use]
89pub fn is_origin_allowed(origin: &str, allowed_origins: &[String]) -> bool {
90 allowed_origins.iter().any(|allowed| allowed == "*")
91 || allowed_origins.iter().any(|allowed| allowed == origin)
92}
93
94#[must_use]
104pub fn cors_response_headers(cors: &CorsConfig, origin: Option<&str>) -> BTreeMap<String, String> {
105 let mut headers = BTreeMap::new();
106 let origin = match origin.filter(|origin| !origin.is_empty()) {
107 Some(origin) if cors.enabled => origin,
108 _ => return headers,
109 };
110 if cors.allow_origins.is_empty() {
111 return headers;
112 }
113 if cors.allow_origins.iter().any(|allowed| allowed == "*") && cors.allow_credentials {
119 return headers;
120 }
121 if !is_origin_allowed(origin, &cors.allow_origins) {
122 return headers;
123 }
124 let allow_origin =
125 if cors.allow_origins.iter().any(|allowed| allowed == "*") && !cors.allow_credentials {
126 "*".to_owned()
127 } else {
128 origin.to_owned()
129 };
130 headers.insert("Access-Control-Allow-Origin".to_owned(), allow_origin);
131 headers.insert(
132 "Access-Control-Allow-Methods".to_owned(),
133 cors.allow_methods.join(", "),
134 );
135 headers.insert(
136 "Access-Control-Allow-Headers".to_owned(),
137 cors.allow_headers.join(", "),
138 );
139 headers.insert("Access-Control-Max-Age".to_owned(), "3600".to_owned());
140 if cors.allow_credentials {
141 headers.insert(
142 "Access-Control-Allow-Credentials".to_owned(),
143 "true".to_owned(),
144 );
145 }
146 headers
147}
148
149#[must_use]
156pub fn cors_handler_headers(
157 cors: &CorsConfig,
158 expose_headers: &[String],
159 origin: Option<&str>,
160) -> BTreeMap<String, String> {
161 let mut headers = BTreeMap::new();
162 if cors_response_headers(cors, origin).is_empty() {
163 return headers;
164 }
165 headers.insert("Vary".to_owned(), "Origin".to_owned());
166 if !expose_headers.is_empty() {
167 headers.insert(
168 "Access-Control-Expose-Headers".to_owned(),
169 expose_headers.join(", "),
170 );
171 }
172 headers
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn an_allowed_preflight_answers_the_full_header_set() {
181 let cors = CorsConfig {
182 enabled: true,
183 allow_origins: vec!["https://app.example.com".to_owned()],
184 allow_methods: vec!["GET".to_owned(), "POST".to_owned()],
185 allow_headers: vec!["content-type".to_owned(), "x-custom".to_owned()],
186 allow_credentials: true,
187 max_age: 900,
188 };
189 let answer = build_preflight_response(
190 &cors,
191 PreflightRequest {
192 origin: Some("https://app.example.com"),
193 request_method: Some("post"),
194 request_headers_raw: Some("Content-Type, X-Custom"),
195 },
196 );
197 assert_eq!(answer.status_code, 200);
198 assert_eq!(answer.body, "OK");
199 assert_eq!(
200 answer
201 .headers
202 .get("Access-Control-Allow-Origin")
203 .map(String::as_str),
204 Some("https://app.example.com"),
205 "credentials keep the exact origin (no wildcard echo)"
206 );
207 assert_eq!(
208 answer
209 .headers
210 .get("Access-Control-Allow-Methods")
211 .map(String::as_str),
212 Some("GET, POST")
213 );
214 assert_eq!(
215 answer
216 .headers
217 .get("Access-Control-Max-Age")
218 .map(String::as_str),
219 Some("900")
220 );
221 assert_eq!(
222 answer
223 .headers
224 .get("Access-Control-Allow-Credentials")
225 .map(String::as_str),
226 Some("true")
227 );
228 assert_eq!(
229 answer.headers.get("Vary").map(String::as_str),
230 Some("Origin")
231 );
232 }
233
234 #[test]
235 fn a_disallowed_preflight_answers_the_failure_list() {
236 let cors = CorsConfig {
237 enabled: true,
238 allow_origins: vec!["https://app.example.com".to_owned()],
239 allow_methods: vec!["GET".to_owned()],
240 allow_headers: vec!["content-type".to_owned()],
241 allow_credentials: false,
242 max_age: 600,
243 };
244 let answer = build_preflight_response(
245 &cors,
246 PreflightRequest {
247 origin: Some("https://evil.example.com"),
248 request_method: Some("DELETE"),
249 request_headers_raw: Some("X-Injected"),
250 },
251 );
252 assert_eq!(answer.status_code, 400);
253 assert_eq!(answer.body, "Disallowed CORS: origin, method, headers");
254 assert_eq!(
256 answer
257 .headers
258 .get("Access-Control-Allow-Methods")
259 .map(String::as_str),
260 Some("GET")
261 );
262 assert_eq!(
263 answer
264 .headers
265 .get("Access-Control-Max-Age")
266 .map(String::as_str),
267 Some("600")
268 );
269 assert!(!answer.headers.contains_key("Access-Control-Allow-Origin"));
270 }
271
272 #[test]
273 fn the_wildcard_config_downgrades_the_credentials_echo() {
274 let cors = CorsConfig {
275 enabled: true,
276 allow_origins: vec!["*".to_owned()],
277 allow_methods: vec!["PATCH".to_owned()],
278 allow_headers: vec!["*".to_owned()],
279 allow_credentials: false,
280 max_age: 600,
281 };
282 let answer = build_preflight_response(
283 &cors,
284 PreflightRequest {
285 origin: Some("https://any.example.com"),
286 request_method: Some("PATCH"),
287 request_headers_raw: Some("X-Anything"),
288 },
289 );
290 assert_eq!(answer.status_code, 200);
291 assert_eq!(
292 answer
293 .headers
294 .get("Access-Control-Allow-Origin")
295 .map(String::as_str),
296 Some("*")
297 );
298 assert_eq!(
299 answer
300 .headers
301 .get("Access-Control-Allow-Headers")
302 .map(String::as_str),
303 Some("X-Anything"),
304 "the wildcard header policy echoes the requested list"
305 );
306 }
307
308 #[test]
309 fn is_preflight_needs_options_and_the_request_method_header() {
310 let headers = vec![
311 (
312 String::from("Origin"),
313 String::from("https://app.example.com"),
314 ),
315 (
316 String::from("Access-Control-Request-Method"),
317 String::from("POST"),
318 ),
319 ];
320 assert!(is_preflight("OPTIONS", &headers));
321 assert!(is_preflight("options", &headers));
322 let plain = vec![(
324 String::from("Origin"),
325 String::from("https://app.example.com"),
326 )];
327 assert!(!is_preflight("OPTIONS", &plain));
328 assert!(!is_preflight("GET", &headers));
329 }
330
331 #[test]
332 fn wildcard_policy_with_downgraded_credentials_answers_the_wildcard() {
333 let mut cors = CorsConfig {
338 enabled: true,
339 allow_origins: vec!["*".to_owned()],
340 allow_credentials: true,
341 ..CorsConfig::default()
342 };
343 downgrade_wildcard_credentials(&mut cors);
344 let headers = cors_response_headers(&cors, Some("https://app.example.com"));
345 assert_eq!(
346 headers
347 .get("Access-Control-Allow-Origin")
348 .map(String::as_str),
349 Some("*")
350 );
351 assert!(!headers.contains_key("Access-Control-Allow-Credentials"));
352 assert_eq!(
353 headers
354 .get("Access-Control-Allow-Methods")
355 .map(String::as_str),
356 Some("GET, POST, PUT, PATCH, DELETE, OPTIONS")
357 );
358 assert_eq!(
359 headers
360 .get("Access-Control-Allow-Headers")
361 .map(String::as_str),
362 Some("*")
363 );
364 assert_eq!(
365 headers.get("Access-Control-Max-Age").map(String::as_str),
366 Some("3600")
367 );
368 }
369
370 #[test]
371 fn allowed_origin_is_echoed() {
372 let cors = CorsConfig {
373 enabled: true,
374 allow_origins: vec!["https://app.example.com".to_owned()],
375 ..CorsConfig::default()
376 };
377 let headers = cors_response_headers(&cors, Some("https://app.example.com"));
378 assert_eq!(
379 headers
380 .get("Access-Control-Allow-Origin")
381 .map(String::as_str),
382 Some("https://app.example.com")
383 );
384 }
385
386 #[test]
387 fn disallowed_origin_and_disabled_cors_get_nothing() {
388 let cors = CorsConfig {
389 enabled: true,
390 allow_origins: vec!["https://app.example.com".to_owned()],
391 ..CorsConfig::default()
392 };
393 assert!(cors_response_headers(&cors, Some("https://evil.example.com")).is_empty());
394 assert!(
395 cors_response_headers(
396 &CorsConfig {
397 enabled: false,
398 ..CorsConfig::default()
399 },
400 Some("https://app.example.com")
401 )
402 .is_empty()
403 );
404 assert!(cors_response_headers(&cors, None).is_empty());
406 }
407
408 #[test]
409 fn credentials_survive_for_an_exact_origin_policy() {
410 let cors = CorsConfig {
411 enabled: true,
412 allow_origins: vec!["https://app.example.com".to_owned()],
413 allow_credentials: true,
414 ..CorsConfig::default()
415 };
416 let headers = cors_response_headers(&cors, Some("https://app.example.com"));
417 assert_eq!(
418 headers
419 .get("Access-Control-Allow-Credentials")
420 .map(String::as_str),
421 Some("true")
422 );
423 }
424 #[test]
425 fn the_handler_headers_add_vary_and_the_exposed_headers() {
426 let mut cors = CorsConfig {
427 enabled: true,
428 allow_origins: vec!["https://app.example.com".to_owned()],
429 ..CorsConfig::default()
430 };
431 let expose = vec!["X-Request-Id".to_owned(), "X-Trace".to_owned()];
432 let headers = cors_handler_headers(&cors, &expose, Some("https://app.example.com"));
433 assert_eq!(headers.get("Vary").map(String::as_str), Some("Origin"));
434 assert_eq!(
435 headers
436 .get("Access-Control-Expose-Headers")
437 .map(String::as_str),
438 Some("X-Request-Id, X-Trace")
439 );
440 assert!(cors_handler_headers(&cors, &expose, Some("https://evil.example.com")).is_empty());
442 assert!(cors_handler_headers(&cors, &expose, None).is_empty());
443 let headers = cors_handler_headers(&cors, &[], Some("https://app.example.com"));
444 assert_eq!(headers.len(), 1, "only Vary without exposed headers");
445 cors.enabled = false;
446 assert!(cors_handler_headers(&cors, &expose, Some("https://app.example.com")).is_empty());
447 }
448}
449
450#[cfg(test)]
451mod coverage_tests {
452 use super::*;
453
454 #[test]
455 fn cors_headers_require_an_origin_and_a_nonempty_allowlist() {
456 let cors = CorsConfig {
457 enabled: true,
458 allow_origins: vec!["https://good".to_owned()],
459 ..CorsConfig::default()
460 };
461 assert!(cors_response_headers(&cors, None).is_empty());
463 assert!(cors_response_headers(&cors, Some("")).is_empty());
465 let empty = CorsConfig {
467 enabled: true,
468 allow_origins: Vec::new(),
469 allow_methods: Vec::new(),
470 allow_headers: Vec::new(),
471 allow_credentials: false,
472 max_age: 600,
473 };
474 assert!(cors_response_headers(&empty, Some("https://good")).is_empty());
475 }
476
477 #[test]
478 fn cors_headers_block_undowngraded_wildcard_credentials() {
479 let cors = CorsConfig {
482 enabled: true,
483 allow_origins: vec!["*".to_owned()],
484 allow_credentials: true,
485 ..CorsConfig::default()
486 };
487 assert!(cors_response_headers(&cors, Some("https://good")).is_empty());
488 }
489}
490
491pub const ALLOWED_PREFLIGHT_REQUEST_HEADER: &str = "access-control-request-method";
494
495#[must_use]
498pub fn is_preflight(method: &str, request_headers: &[(String, String)]) -> bool {
499 method.eq_ignore_ascii_case("OPTIONS")
500 && request_headers
501 .iter()
502 .any(|(name, _)| name.eq_ignore_ascii_case(ALLOWED_PREFLIGHT_REQUEST_HEADER))
503}
504
505#[derive(Debug, Clone, Copy, Default)]
509pub struct PreflightRequest<'a> {
510 pub origin: Option<&'a str>,
512 pub request_method: Option<&'a str>,
514 pub request_headers_raw: Option<&'a str>,
516}
517
518#[derive(Debug, Clone, PartialEq, Eq)]
521pub struct CorsPreflightResponse {
522 pub status_code: u16,
524 pub headers: BTreeMap<String, String>,
527 pub body: String,
529}
530
531#[must_use]
538pub fn build_preflight_response(
539 cors: &CorsConfig,
540 request: PreflightRequest<'_>,
541) -> CorsPreflightResponse {
542 let mut failures: Vec<&'static str> = Vec::new();
543 let mut response_headers = BTreeMap::from([(String::from("Vary"), String::from("Origin"))]);
544
545 let origin = request.origin.unwrap_or_default();
548 if is_origin_allowed(origin, &cors.allow_origins) {
549 let wildcard = cors.allow_origins.iter().any(|o| o == "*");
550 let allow_origin = if wildcard && !cors.allow_credentials {
551 "*"
552 } else {
553 origin
554 };
555 response_headers.insert(
556 String::from("Access-Control-Allow-Origin"),
557 allow_origin.to_owned(),
558 );
559 } else {
560 failures.push("origin");
561 }
562
563 let requested_method = request
565 .request_method
566 .unwrap_or_default()
567 .to_ascii_uppercase();
568 if !cors
569 .allow_methods
570 .iter()
571 .any(|method| method.to_ascii_uppercase() == requested_method)
572 {
573 failures.push("method");
574 }
575
576 let requested_headers: Vec<String> = request
580 .request_headers_raw
581 .unwrap_or_default()
582 .split(',')
583 .map(str::trim)
584 .filter(|name| !name.is_empty())
585 .map(str::to_ascii_lowercase)
586 .collect();
587 let requested_headers_raw = request.request_headers_raw.unwrap_or_default();
588 let allow_all_headers = cors.allow_headers.iter().any(|h| h == "*");
589 if allow_all_headers {
590 if !requested_headers_raw.is_empty() {
591 response_headers.insert(
592 String::from("Access-Control-Allow-Headers"),
593 requested_headers_raw.to_owned(),
594 );
595 }
596 } else if requested_headers.iter().any(|name| {
597 !cors
598 .allow_headers
599 .iter()
600 .any(|h| h.eq_ignore_ascii_case(name))
601 }) {
602 failures.push("headers");
603 }
604
605 response_headers.insert(
606 String::from("Access-Control-Allow-Methods"),
607 cors.allow_methods.join(", "),
608 );
609 response_headers.insert(
610 String::from("Access-Control-Max-Age"),
611 cors.max_age.to_string(),
612 );
613
614 if cors.allow_credentials {
615 response_headers.insert(
616 String::from("Access-Control-Allow-Credentials"),
617 String::from("true"),
618 );
619 }
620
621 if failures.is_empty() {
622 CorsPreflightResponse {
623 status_code: 200,
624 headers: response_headers,
625 body: String::from("OK"),
626 }
627 } else {
628 CorsPreflightResponse {
629 status_code: 400,
630 headers: response_headers,
631 body: format!("Disallowed CORS: {}", failures.join(", ")),
632 }
633 }
634}