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#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn an_allowed_preflight_answers_the_full_header_set() {
155 let cors = CorsConfig {
156 enabled: true,
157 allow_origins: vec!["https://app.example.com".to_owned()],
158 allow_methods: vec!["GET".to_owned(), "POST".to_owned()],
159 allow_headers: vec!["content-type".to_owned(), "x-custom".to_owned()],
160 allow_credentials: true,
161 max_age: 900,
162 };
163 let answer = build_preflight_response(
164 &cors,
165 PreflightRequest {
166 origin: Some("https://app.example.com"),
167 request_method: Some("post"),
168 request_headers_raw: Some("Content-Type, X-Custom"),
169 },
170 );
171 assert_eq!(answer.status_code, 200);
172 assert_eq!(answer.body, "OK");
173 assert_eq!(
174 answer
175 .headers
176 .get("Access-Control-Allow-Origin")
177 .map(String::as_str),
178 Some("https://app.example.com"),
179 "credentials keep the exact origin (no wildcard echo)"
180 );
181 assert_eq!(
182 answer
183 .headers
184 .get("Access-Control-Allow-Methods")
185 .map(String::as_str),
186 Some("GET, POST")
187 );
188 assert_eq!(
189 answer
190 .headers
191 .get("Access-Control-Max-Age")
192 .map(String::as_str),
193 Some("900")
194 );
195 assert_eq!(
196 answer
197 .headers
198 .get("Access-Control-Allow-Credentials")
199 .map(String::as_str),
200 Some("true")
201 );
202 assert_eq!(
203 answer.headers.get("Vary").map(String::as_str),
204 Some("Origin")
205 );
206 }
207
208 #[test]
209 fn a_disallowed_preflight_answers_the_failure_list() {
210 let cors = CorsConfig {
211 enabled: true,
212 allow_origins: vec!["https://app.example.com".to_owned()],
213 allow_methods: vec!["GET".to_owned()],
214 allow_headers: vec!["content-type".to_owned()],
215 allow_credentials: false,
216 max_age: 600,
217 };
218 let answer = build_preflight_response(
219 &cors,
220 PreflightRequest {
221 origin: Some("https://evil.example.com"),
222 request_method: Some("DELETE"),
223 request_headers_raw: Some("X-Injected"),
224 },
225 );
226 assert_eq!(answer.status_code, 400);
227 assert_eq!(answer.body, "Disallowed CORS: origin, method, headers");
228 assert_eq!(
230 answer
231 .headers
232 .get("Access-Control-Allow-Methods")
233 .map(String::as_str),
234 Some("GET")
235 );
236 assert_eq!(
237 answer
238 .headers
239 .get("Access-Control-Max-Age")
240 .map(String::as_str),
241 Some("600")
242 );
243 assert!(!answer.headers.contains_key("Access-Control-Allow-Origin"));
244 }
245
246 #[test]
247 fn the_wildcard_config_downgrades_the_credentials_echo() {
248 let cors = CorsConfig {
249 enabled: true,
250 allow_origins: vec!["*".to_owned()],
251 allow_methods: vec!["PATCH".to_owned()],
252 allow_headers: vec!["*".to_owned()],
253 allow_credentials: false,
254 max_age: 600,
255 };
256 let answer = build_preflight_response(
257 &cors,
258 PreflightRequest {
259 origin: Some("https://any.example.com"),
260 request_method: Some("PATCH"),
261 request_headers_raw: Some("X-Anything"),
262 },
263 );
264 assert_eq!(answer.status_code, 200);
265 assert_eq!(
266 answer
267 .headers
268 .get("Access-Control-Allow-Origin")
269 .map(String::as_str),
270 Some("*")
271 );
272 assert_eq!(
273 answer
274 .headers
275 .get("Access-Control-Allow-Headers")
276 .map(String::as_str),
277 Some("X-Anything"),
278 "the wildcard header policy echoes the requested list"
279 );
280 }
281
282 #[test]
283 fn is_preflight_needs_options_and_the_request_method_header() {
284 let headers = vec![
285 (
286 String::from("Origin"),
287 String::from("https://app.example.com"),
288 ),
289 (
290 String::from("Access-Control-Request-Method"),
291 String::from("POST"),
292 ),
293 ];
294 assert!(is_preflight("OPTIONS", &headers));
295 assert!(is_preflight("options", &headers));
296 let plain = vec![(
298 String::from("Origin"),
299 String::from("https://app.example.com"),
300 )];
301 assert!(!is_preflight("OPTIONS", &plain));
302 assert!(!is_preflight("GET", &headers));
303 }
304
305 #[test]
306 fn wildcard_policy_with_downgraded_credentials_answers_the_wildcard() {
307 let mut cors = CorsConfig {
312 enabled: true,
313 allow_origins: vec!["*".to_owned()],
314 allow_credentials: true,
315 ..CorsConfig::default()
316 };
317 downgrade_wildcard_credentials(&mut cors);
318 let headers = cors_response_headers(&cors, Some("https://app.example.com"));
319 assert_eq!(
320 headers
321 .get("Access-Control-Allow-Origin")
322 .map(String::as_str),
323 Some("*")
324 );
325 assert!(!headers.contains_key("Access-Control-Allow-Credentials"));
326 assert_eq!(
327 headers
328 .get("Access-Control-Allow-Methods")
329 .map(String::as_str),
330 Some("GET, POST, PUT, PATCH, DELETE, OPTIONS")
331 );
332 assert_eq!(
333 headers
334 .get("Access-Control-Allow-Headers")
335 .map(String::as_str),
336 Some("*")
337 );
338 assert_eq!(
339 headers.get("Access-Control-Max-Age").map(String::as_str),
340 Some("3600")
341 );
342 }
343
344 #[test]
345 fn allowed_origin_is_echoed() {
346 let cors = CorsConfig {
347 enabled: true,
348 allow_origins: vec!["https://app.example.com".to_owned()],
349 ..CorsConfig::default()
350 };
351 let headers = cors_response_headers(&cors, Some("https://app.example.com"));
352 assert_eq!(
353 headers
354 .get("Access-Control-Allow-Origin")
355 .map(String::as_str),
356 Some("https://app.example.com")
357 );
358 }
359
360 #[test]
361 fn disallowed_origin_and_disabled_cors_get_nothing() {
362 let cors = CorsConfig {
363 enabled: true,
364 allow_origins: vec!["https://app.example.com".to_owned()],
365 ..CorsConfig::default()
366 };
367 assert!(cors_response_headers(&cors, Some("https://evil.example.com")).is_empty());
368 assert!(
369 cors_response_headers(
370 &CorsConfig {
371 enabled: false,
372 ..CorsConfig::default()
373 },
374 Some("https://app.example.com")
375 )
376 .is_empty()
377 );
378 assert!(cors_response_headers(&cors, None).is_empty());
380 }
381
382 #[test]
383 fn credentials_survive_for_an_exact_origin_policy() {
384 let cors = CorsConfig {
385 enabled: true,
386 allow_origins: vec!["https://app.example.com".to_owned()],
387 allow_credentials: true,
388 ..CorsConfig::default()
389 };
390 let headers = cors_response_headers(&cors, Some("https://app.example.com"));
391 assert_eq!(
392 headers
393 .get("Access-Control-Allow-Credentials")
394 .map(String::as_str),
395 Some("true")
396 );
397 }
398}
399
400#[cfg(test)]
401mod coverage_tests {
402 use super::*;
403
404 #[test]
405 fn cors_headers_require_an_origin_and_a_nonempty_allowlist() {
406 let cors = CorsConfig {
407 enabled: true,
408 allow_origins: vec!["https://good".to_owned()],
409 ..CorsConfig::default()
410 };
411 assert!(cors_response_headers(&cors, None).is_empty());
413 assert!(cors_response_headers(&cors, Some("")).is_empty());
415 let empty = CorsConfig {
417 enabled: true,
418 allow_origins: Vec::new(),
419 allow_methods: Vec::new(),
420 allow_headers: Vec::new(),
421 allow_credentials: false,
422 max_age: 600,
423 };
424 assert!(cors_response_headers(&empty, Some("https://good")).is_empty());
425 }
426
427 #[test]
428 fn cors_headers_block_undowngraded_wildcard_credentials() {
429 let cors = CorsConfig {
432 enabled: true,
433 allow_origins: vec!["*".to_owned()],
434 allow_credentials: true,
435 ..CorsConfig::default()
436 };
437 assert!(cors_response_headers(&cors, Some("https://good")).is_empty());
438 }
439}
440
441pub const ALLOWED_PREFLIGHT_REQUEST_HEADER: &str = "access-control-request-method";
444
445#[must_use]
448pub fn is_preflight(method: &str, request_headers: &[(String, String)]) -> bool {
449 method.eq_ignore_ascii_case("OPTIONS")
450 && request_headers
451 .iter()
452 .any(|(name, _)| name.eq_ignore_ascii_case(ALLOWED_PREFLIGHT_REQUEST_HEADER))
453}
454
455#[derive(Debug, Clone, Copy, Default)]
459pub struct PreflightRequest<'a> {
460 pub origin: Option<&'a str>,
462 pub request_method: Option<&'a str>,
464 pub request_headers_raw: Option<&'a str>,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq)]
471pub struct CorsPreflightResponse {
472 pub status_code: u16,
474 pub headers: BTreeMap<String, String>,
477 pub body: String,
479}
480
481#[must_use]
488pub fn build_preflight_response(
489 cors: &CorsConfig,
490 request: PreflightRequest<'_>,
491) -> CorsPreflightResponse {
492 let mut failures: Vec<&'static str> = Vec::new();
493 let mut response_headers = BTreeMap::from([(String::from("Vary"), String::from("Origin"))]);
494
495 let origin = request.origin.unwrap_or_default();
498 if is_origin_allowed(origin, &cors.allow_origins) {
499 let wildcard = cors.allow_origins.iter().any(|o| o == "*");
500 let allow_origin = if wildcard && !cors.allow_credentials {
501 "*"
502 } else {
503 origin
504 };
505 response_headers.insert(
506 String::from("Access-Control-Allow-Origin"),
507 allow_origin.to_owned(),
508 );
509 } else {
510 failures.push("origin");
511 }
512
513 let requested_method = request
515 .request_method
516 .unwrap_or_default()
517 .to_ascii_uppercase();
518 if !cors
519 .allow_methods
520 .iter()
521 .any(|method| method.to_ascii_uppercase() == requested_method)
522 {
523 failures.push("method");
524 }
525
526 let requested_headers: Vec<String> = request
530 .request_headers_raw
531 .unwrap_or_default()
532 .split(',')
533 .map(str::trim)
534 .filter(|name| !name.is_empty())
535 .map(str::to_ascii_lowercase)
536 .collect();
537 let requested_headers_raw = request.request_headers_raw.unwrap_or_default();
538 let allow_all_headers = cors.allow_headers.iter().any(|h| h == "*");
539 if allow_all_headers {
540 if !requested_headers_raw.is_empty() {
541 response_headers.insert(
542 String::from("Access-Control-Allow-Headers"),
543 requested_headers_raw.to_owned(),
544 );
545 }
546 } else if requested_headers.iter().any(|name| {
547 !cors
548 .allow_headers
549 .iter()
550 .any(|h| h.eq_ignore_ascii_case(name))
551 }) {
552 failures.push("headers");
553 }
554
555 response_headers.insert(
556 String::from("Access-Control-Allow-Methods"),
557 cors.allow_methods.join(", "),
558 );
559 response_headers.insert(
560 String::from("Access-Control-Max-Age"),
561 cors.max_age.to_string(),
562 );
563
564 if cors.allow_credentials {
565 response_headers.insert(
566 String::from("Access-Control-Allow-Credentials"),
567 String::from("true"),
568 );
569 }
570
571 if failures.is_empty() {
572 CorsPreflightResponse {
573 status_code: 200,
574 headers: response_headers,
575 body: String::from("OK"),
576 }
577 } else {
578 CorsPreflightResponse {
579 status_code: 400,
580 headers: response_headers,
581 body: format!("Disallowed CORS: {}", failures.join(", ")),
582 }
583 }
584}