churust_cors/lib.rs
1//! Cross-Origin Resource Sharing (CORS) plugin for the [Churust] web framework.
2//!
3//! This crate provides a [`Cors`] plugin that intercepts every incoming HTTP
4//! request and attaches the appropriate `Access-Control-*` response headers.
5//! Preflight `OPTIONS` requests are short-circuited with an HTTP 204 response
6//! so they never reach your route handlers.
7//!
8//! # Quick start
9//!
10//! Install the plugin via [`churust_core::Churust::server`] before calling
11//! `.build()`. Use [`Cors::permissive`] for development or use [`Cors::new`]
12//! to build a precise policy for production.
13//!
14//! ```
15//! use churust_core::{Churust, Call, TestClient};
16//! use churust_cors::Cors;
17//!
18//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
19//! let app = Churust::server()
20//! .install(Cors::allow_any_origin_insecure())
21//! .routing(|r| {
22//! r.get("/api/data", |_c: Call| async { "hello" });
23//! })
24//! .build();
25//!
26//! // Actual cross-origin GET: response carries the CORS header.
27//! let res = TestClient::new(app)
28//! .get("/api/data")
29//! .header("origin", "https://example.com")
30//! .send()
31//! .await;
32//!
33//! assert_eq!(res.status().as_u16(), 200);
34//! assert_eq!(res.header("access-control-allow-origin"), Some("*"));
35//! # });
36//! ```
37//!
38//! [Churust]: churust_core::Churust
39
40#![deny(missing_docs)]
41
42use async_trait::async_trait;
43use churust_core::{AppBuilder, Call, Middleware, Next, Phase, Plugin, Response};
44use http::header::{
45 ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
46 ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_MAX_AGE, ACCESS_CONTROL_REQUEST_METHOD, VARY,
47};
48use http::{HeaderValue, Method, StatusCode};
49use std::sync::Arc;
50
51/// Which origins are allowed.
52#[derive(Debug, Clone)]
53enum AllowOrigin {
54 Any,
55 List(Vec<String>),
56}
57
58/// CORS configuration and plugin entry point.
59///
60/// `Cors` holds the policy that governs which origins, HTTP methods, and
61/// request headers are permitted for cross-origin requests. It implements
62/// [`Plugin`], so you pass it directly to [`AppBuilder::install`] — the plugin
63/// system registers a [`Middleware`] that runs on every request.
64///
65/// # Choosing a constructor
66///
67/// | Situation | Constructor |
68/// |-----------|-------------|
69/// | Local development, all origins OK | [`Cors::permissive`] |
70/// | Staging / production, specific origins | [`Cors::new`] + builder methods |
71///
72/// # Example
73///
74/// ```
75/// use churust_core::{Churust, Call, TestClient};
76/// use churust_cors::Cors;
77/// use http::Method;
78///
79/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
80/// let app = Churust::server()
81/// .install(
82/// Cors::new()
83/// .allow_origin("https://app.example.com")
84/// .allow_methods(vec![Method::GET, Method::POST])
85/// .allow_headers(vec!["Content-Type".into(), "Authorization".into()])
86/// .allow_credentials(true)
87/// .max_age(3600),
88/// )
89/// .routing(|r| {
90/// r.get("/", |_c: Call| async { "ok" });
91/// })
92/// .build();
93///
94/// let res = TestClient::new(app)
95/// .get("/")
96/// .header("origin", "https://app.example.com")
97/// .send()
98/// .await;
99///
100/// assert_eq!(res.status().as_u16(), 200);
101/// assert_eq!(
102/// res.header("access-control-allow-origin"),
103/// Some("https://app.example.com")
104/// );
105/// # });
106/// ```
107#[derive(Debug, Clone)]
108pub struct Cors {
109 origin: AllowOrigin,
110 methods: Vec<Method>,
111 headers: Vec<String>,
112 credentials: bool,
113 max_age: Option<u64>,
114}
115
116impl Cors {
117 /// Creates a permissive CORS policy suitable for development and public APIs.
118 ///
119 /// The policy allows **any** origin (`*`), the six most common HTTP methods
120 /// (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`), any request header
121 /// (`*`), and caches the preflight response for 24 hours (86 400 seconds).
122 ///
123 /// > **Note:** Per the CORS specification, `Access-Control-Allow-Origin: *`
124 /// > cannot be combined with `Access-Control-Allow-Credentials: true`.
125 /// > Therefore `allow_any_origin_insecure()` intentionally leaves credentials **disabled**.
126 /// > If your application needs cookies or HTTP authentication on cross-origin
127 /// > requests, use [`Cors::new`] with an explicit origin list and call
128 /// > [`.allow_credentials(true)`](Cors::allow_credentials).
129 ///
130 /// # Example
131 ///
132 /// ```
133 /// use churust_core::{Churust, Call, TestClient};
134 /// use churust_cors::Cors;
135 ///
136 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
137 /// let app = Churust::server()
138 /// .install(Cors::allow_any_origin_insecure())
139 /// .routing(|r| {
140 /// r.get("/", |_c: Call| async { "hello" });
141 /// })
142 /// .build();
143 ///
144 /// let res = TestClient::new(app)
145 /// .get("/")
146 /// .header("origin", "https://any-origin.example")
147 /// .send()
148 /// .await;
149 ///
150 /// assert_eq!(res.header("access-control-allow-origin"), Some("*"));
151 /// # });
152 /// ```
153 #[deprecated(
154 since = "0.3.0",
155 note = "renamed to `allow_any_origin_insecure`, which says what it does. \
156 This reflects every origin, so any site can read authenticated \
157 responses from your API when credentials are not required."
158 )]
159 pub fn permissive() -> Self {
160 Self::allow_any_origin_insecure()
161 }
162
163 /// Allow **every** origin, method and header.
164 ///
165 /// The name is deliberately uncomfortable. This reflects any origin, so if
166 /// your API answers requests based on an ambient credential — a cookie, a
167 /// bearer token a browser extension replays, an IP allowlist — any website
168 /// a user visits can read those responses.
169 ///
170 /// It is genuinely fine for a public, unauthenticated, read-only API, and
171 /// convenient in local development. It is not a default.
172 pub fn allow_any_origin_insecure() -> Self {
173 Self {
174 origin: AllowOrigin::Any,
175 methods: vec![
176 Method::GET,
177 Method::POST,
178 Method::PUT,
179 Method::DELETE,
180 Method::PATCH,
181 Method::OPTIONS,
182 ],
183 headers: vec!["*".to_string()],
184 credentials: false,
185 max_age: Some(86_400),
186 }
187 }
188
189 /// Creates a restrictive CORS policy with safe defaults.
190 ///
191 /// The initial policy allows **no origins**, permits only `GET` and `POST`,
192 /// exposes no extra headers, disables credentials, and sets no `max-age`.
193 /// Use the builder methods to refine the policy before passing it to
194 /// [`AppBuilder::install`].
195 ///
196 /// # Example
197 ///
198 /// ```
199 /// use churust_core::{Churust, Call, TestClient};
200 /// use churust_cors::Cors;
201 ///
202 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
203 /// // Without adding any allowed origins, cross-origin requests receive
204 /// // no CORS headers and the browser will block the response.
205 /// let app = Churust::server()
206 /// .install(Cors::new().allow_origin("https://trusted.example.com"))
207 /// .routing(|r| {
208 /// r.get("/", |_c: Call| async { "ok" });
209 /// })
210 /// .build();
211 ///
212 /// // Unlisted origin → no CORS header.
213 /// let res = TestClient::new(app)
214 /// .get("/")
215 /// .header("origin", "https://untrusted.example.com")
216 /// .send()
217 /// .await;
218 ///
219 /// assert_eq!(res.header("access-control-allow-origin"), None);
220 /// # });
221 /// ```
222 pub fn new() -> Self {
223 Self {
224 origin: AllowOrigin::List(Vec::new()),
225 methods: vec![Method::GET, Method::POST],
226 headers: Vec::new(),
227 credentials: false,
228 max_age: None,
229 }
230 }
231
232 /// Adds a single origin that is allowed to make cross-origin requests.
233 ///
234 /// Call this method multiple times to whitelist several origins. The value
235 /// should be a fully-qualified origin string such as
236 /// `"https://app.example.com"` (scheme + host + optional port, **no**
237 /// trailing slash).
238 ///
239 /// If the policy was previously set to [`Cors::permissive`] (wildcard
240 /// origin), calling `allow_origin` switches back to an explicit list
241 /// containing only the supplied origin.
242 ///
243 /// # Parameters
244 ///
245 /// - `origin` — any type that converts to [`String`], e.g. `&str` or
246 /// `String`. The value is compared verbatim against the `Origin` request
247 /// header.
248 ///
249 /// # Example
250 ///
251 /// ```
252 /// use churust_core::{Churust, Call, TestClient};
253 /// use churust_cors::Cors;
254 ///
255 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
256 /// let app = Churust::server()
257 /// .install(
258 /// Cors::new()
259 /// .allow_origin("https://frontend.example.com")
260 /// .allow_origin("https://mobile.example.com"),
261 /// )
262 /// .routing(|r| {
263 /// r.get("/", |_c: Call| async { "ok" });
264 /// })
265 /// .build();
266 ///
267 /// let res = TestClient::new(app)
268 /// .get("/")
269 /// .header("origin", "https://frontend.example.com")
270 /// .send()
271 /// .await;
272 ///
273 /// assert_eq!(
274 /// res.header("access-control-allow-origin"),
275 /// Some("https://frontend.example.com")
276 /// );
277 /// # });
278 /// ```
279 pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
280 match &mut self.origin {
281 AllowOrigin::List(v) => v.push(origin.into()),
282 AllowOrigin::Any => {
283 self.origin = AllowOrigin::List(vec![origin.into()]);
284 }
285 }
286 self
287 }
288
289 /// Replaces the list of HTTP methods advertised in preflight responses.
290 ///
291 /// The supplied `methods` are joined with `", "` and sent as the
292 /// `Access-Control-Allow-Methods` header in response to `OPTIONS` preflight
293 /// requests. This call **replaces** the current list entirely — it does
294 /// not append.
295 ///
296 /// The default (from [`Cors::new`]) is `[GET, POST]`.
297 ///
298 /// # Example
299 ///
300 /// ```
301 /// use churust_core::{Churust, Call, TestClient};
302 /// use churust_cors::Cors;
303 /// use http::Method;
304 ///
305 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
306 /// let app = Churust::server()
307 /// .install(
308 /// Cors::new()
309 /// .allow_origin("https://example.com")
310 /// .allow_methods(vec![Method::GET, Method::POST, Method::DELETE]),
311 /// )
312 /// .routing(|r| {
313 /// r.get("/", |_c: Call| async { "ok" });
314 /// })
315 /// .build();
316 ///
317 /// // Send a preflight for DELETE.
318 /// let res = TestClient::new(app)
319 /// .request(Method::OPTIONS, "/")
320 /// .header("origin", "https://example.com")
321 /// .header("access-control-request-method", "DELETE")
322 /// .send()
323 /// .await;
324 ///
325 /// assert_eq!(res.status().as_u16(), 204);
326 /// let allowed = res.header("access-control-allow-methods").unwrap_or("");
327 /// assert!(allowed.contains("DELETE"));
328 /// # });
329 /// ```
330 pub fn allow_methods(mut self, methods: Vec<Method>) -> Self {
331 self.methods = methods;
332 self
333 }
334
335 /// Replaces the list of request headers advertised in preflight responses.
336 ///
337 /// The supplied header names are joined with `", "` and sent as the
338 /// `Access-Control-Allow-Headers` header in response to `OPTIONS` preflight
339 /// requests. Pass `["*"]` to allow any header (note that this is a literal
340 /// wildcard string, not a glob pattern — its meaning is defined by the
341 /// browser's CORS implementation).
342 ///
343 /// An empty list (the default from [`Cors::new`]) omits the
344 /// `Access-Control-Allow-Headers` header entirely from preflight responses.
345 ///
346 /// # Example
347 ///
348 /// ```
349 /// use churust_core::{Churust, Call, TestClient};
350 /// use churust_cors::Cors;
351 /// use http::Method;
352 ///
353 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
354 /// let app = Churust::server()
355 /// .install(
356 /// Cors::new()
357 /// .allow_origin("https://example.com")
358 /// .allow_headers(vec!["Content-Type".into(), "X-Api-Key".into()]),
359 /// )
360 /// .routing(|r| {
361 /// r.get("/", |_c: Call| async { "ok" });
362 /// })
363 /// .build();
364 ///
365 /// let res = TestClient::new(app)
366 /// .request(Method::OPTIONS, "/")
367 /// .header("origin", "https://example.com")
368 /// .header("access-control-request-method", "GET")
369 /// .send()
370 /// .await;
371 ///
372 /// assert_eq!(res.status().as_u16(), 204);
373 /// let hdrs = res.header("access-control-allow-headers").unwrap_or("");
374 /// assert!(hdrs.contains("X-Api-Key"));
375 /// # });
376 /// ```
377 pub fn allow_headers(mut self, headers: Vec<String>) -> Self {
378 self.headers = headers;
379 self
380 }
381
382 /// Controls whether the `Access-Control-Allow-Credentials: true` header is
383 /// sent.
384 ///
385 /// Set this to `true` when your API relies on cookies, HTTP authentication,
386 /// or TLS client certificates for cross-origin requests. The browser only
387 /// forwards credentials when **both** the server sets this header **and**
388 /// the client sets `XMLHttpRequest.withCredentials = true` (or the
389 /// `fetch` `credentials: "include"` option).
390 ///
391 /// > **CORS spec gotcha:** Credentials are incompatible with a wildcard
392 /// > (`*`) `Allow-Origin`. If you enable credentials, make sure the
393 /// > policy lists explicit origins via [`allow_origin`](Cors::allow_origin)
394 /// > rather than using [`Cors::permissive`].
395 ///
396 /// # Example
397 ///
398 /// ```
399 /// use churust_core::{Churust, Call, TestClient};
400 /// use churust_cors::Cors;
401 ///
402 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
403 /// let app = Churust::server()
404 /// .install(
405 /// Cors::new()
406 /// .allow_origin("https://trusted.example.com")
407 /// .allow_credentials(true),
408 /// )
409 /// .routing(|r| {
410 /// r.get("/", |_c: Call| async { "ok" });
411 /// })
412 /// .build();
413 ///
414 /// let res = TestClient::new(app)
415 /// .get("/")
416 /// .header("origin", "https://trusted.example.com")
417 /// .send()
418 /// .await;
419 ///
420 /// assert_eq!(res.header("access-control-allow-credentials"), Some("true"));
421 /// # });
422 /// ```
423 pub fn allow_credentials(mut self, yes: bool) -> Self {
424 self.credentials = yes;
425 self
426 }
427
428 /// Sets the `Access-Control-Max-Age` value (in seconds) for preflight caching.
429 ///
430 /// Browsers may cache a successful preflight response for up to `seconds`
431 /// seconds, avoiding repeated `OPTIONS` round-trips for subsequent requests
432 /// to the same endpoint. The practical upper limit varies by browser (e.g.
433 /// Chrome caps it at 7200 seconds; Firefox caps it at 86 400 seconds).
434 ///
435 /// If this method is not called (the default for [`Cors::new`]), the
436 /// `Access-Control-Max-Age` header is omitted and the browser applies its
437 /// own default (typically 5 seconds).
438 ///
439 /// # Parameters
440 ///
441 /// - `seconds` — cache duration as a non-negative integer. A value of `0`
442 /// is legal and instructs browsers not to cache the preflight at all.
443 ///
444 /// # Example
445 ///
446 /// ```
447 /// use churust_core::{Churust, Call, TestClient};
448 /// use churust_cors::Cors;
449 /// use http::Method;
450 ///
451 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
452 /// let app = Churust::server()
453 /// .install(
454 /// Cors::new()
455 /// .allow_origin("https://example.com")
456 /// .max_age(3600),
457 /// )
458 /// .routing(|r| {
459 /// r.get("/", |_c: Call| async { "ok" });
460 /// })
461 /// .build();
462 ///
463 /// let res = TestClient::new(app)
464 /// .request(Method::OPTIONS, "/")
465 /// .header("origin", "https://example.com")
466 /// .header("access-control-request-method", "GET")
467 /// .send()
468 /// .await;
469 ///
470 /// assert_eq!(res.header("access-control-max-age"), Some("3600"));
471 /// # });
472 /// ```
473 pub fn max_age(mut self, seconds: u64) -> Self {
474 self.max_age = Some(seconds);
475 self
476 }
477
478 fn origin_allowed(&self, origin: &str) -> Option<String> {
479 match &self.origin {
480 AllowOrigin::Any => Some("*".to_string()),
481 AllowOrigin::List(list) => {
482 if list.iter().any(|o| o == origin) {
483 Some(origin.to_string())
484 } else {
485 None
486 }
487 }
488 }
489 }
490
491 fn apply_common(&self, res: &mut Response, allow_origin: &str) {
492 res.headers.insert(
493 ACCESS_CONTROL_ALLOW_ORIGIN,
494 HeaderValue::from_str(allow_origin).unwrap_or(HeaderValue::from_static("*")),
495 );
496 if self.credentials {
497 res.headers.insert(
498 ACCESS_CONTROL_ALLOW_CREDENTIALS,
499 HeaderValue::from_static("true"),
500 );
501 }
502 }
503}
504
505impl Default for Cors {
506 fn default() -> Self {
507 Self::new()
508 }
509}
510
511/// Append `Origin` to `Vary` without disturbing what is already there.
512///
513/// This used to be a plain `insert` of `Vary: Origin` inside `apply_common`,
514/// which threw away whatever another layer had already put in the header.
515/// churust-compression merges `accept-encoding` into `Vary` as it unwinds, and
516/// CORS sits outside it, so the overwrite left a gzip-encoded response keyed on
517/// `Origin` alone: a shared cache would store those compressed bytes and hand
518/// them to the next same-origin client that sent no `Accept-Encoding` at all,
519/// which cannot decode them. The merge below is deliberately the same shape as
520/// `churust_compression`'s `vary_on_accept_encoding` so the two plugins agree
521/// on what a merged `Vary` looks like whichever order they are installed in:
522/// split the existing values on commas, compare case-insensitively because
523/// field names are case-insensitive, and leave the header alone when it is
524/// already `*` (which varies on everything) or already names the origin. The
525/// token is appended in lower case for the same reason — it matches the
526/// spelling the compression plugin emits, so a response passing through both
527/// reads as one consistent list rather than a mixture.
528fn vary_on_origin(res: &mut Response) {
529 let existing: Vec<String> = res
530 .headers
531 .get_all(VARY)
532 .iter()
533 .filter_map(|v| v.to_str().ok())
534 .flat_map(|v| v.split(','))
535 .map(|v| v.trim().to_ascii_lowercase())
536 .filter(|v| !v.is_empty())
537 .collect();
538
539 if existing.iter().any(|v| v == "*" || v == "origin") {
540 return;
541 }
542
543 let mut merged = existing;
544 merged.push("origin".to_string());
545 if let Ok(value) = HeaderValue::from_str(&merged.join(", ")) {
546 res.headers.insert(VARY, value);
547 }
548}
549
550impl Plugin for Cors {
551 fn install(self: Box<Self>, app: &mut AppBuilder) {
552 app.add_middleware_in(Phase::Plugins, Arc::new(CorsMiddleware { cfg: *self }));
553 }
554}
555
556struct CorsMiddleware {
557 cfg: Cors,
558}
559
560#[async_trait]
561impl Middleware for CorsMiddleware {
562 async fn handle(&self, call: Call, next: Next) -> Response {
563 let origin = call.header("origin").map(|s| s.to_string());
564 let is_preflight = *call.method() == Method::OPTIONS
565 && call
566 .header(ACCESS_CONTROL_REQUEST_METHOD.as_str())
567 .is_some();
568
569 // Preflight: short-circuit with 204 + CORS headers.
570 if is_preflight {
571 let mut res = Response::new(StatusCode::NO_CONTENT);
572 if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
573 self.cfg.apply_common(&mut res, &o);
574 let methods = self
575 .cfg
576 .methods
577 .iter()
578 .map(|m| m.as_str())
579 .collect::<Vec<_>>()
580 .join(", ");
581 if let Ok(v) = HeaderValue::from_str(&methods) {
582 res.headers.insert(ACCESS_CONTROL_ALLOW_METHODS, v);
583 }
584 if !self.cfg.headers.is_empty() {
585 let hs = self.cfg.headers.join(", ");
586 if let Ok(v) = HeaderValue::from_str(&hs) {
587 res.headers.insert(ACCESS_CONTROL_ALLOW_HEADERS, v);
588 }
589 }
590 if let Some(age) = self.cfg.max_age {
591 if let Ok(v) = HeaderValue::from_str(&age.to_string()) {
592 res.headers.insert(ACCESS_CONTROL_MAX_AGE, v);
593 }
594 }
595 }
596 vary_on_origin(&mut res);
597 return res;
598 }
599
600 // Actual request: run the chain, then decorate the response.
601 let mut res = next.run(call).await;
602 if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
603 self.cfg.apply_common(&mut res, &o);
604 }
605 // Every response this middleware touches is marked, not only the ones
606 // that came out with an `Access-Control-Allow-Origin`. Whether that
607 // header is present at all is decided by the request's `Origin`: a
608 // same-origin request sends none and gets none back, a refused origin
609 // gets none either, an allowed one does. Marking only the allowed case
610 // left the other two freely cacheable, so a shared cache could store
611 // the header-less answer and replay it to an allowed origin, whose
612 // browser then blocks a response the server would have permitted.
613 vary_on_origin(&mut res);
614 res
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use churust_core::{App, Churust, TestClient};
622
623 fn app() -> App {
624 Churust::server()
625 .install(Cors::allow_any_origin_insecure())
626 .routing(|r| {
627 r.get("/", |_c: Call| async { "ok" });
628 })
629 .build()
630 }
631
632 #[tokio::test]
633 async fn actual_request_gets_allow_origin() {
634 let client = TestClient::new(app());
635 let res = client
636 .get("/")
637 .header("origin", "https://example.com")
638 .send()
639 .await;
640 assert_eq!(res.status(), StatusCode::OK);
641 assert_eq!(res.header("access-control-allow-origin"), Some("*"));
642 // Lower case because the merge normalises what it finds and appends in
643 // the same spelling churust-compression uses; `Vary` names fields, and
644 // field names are case-insensitive, so this is the same header as the
645 // `Origin` this test used to assert.
646 assert_eq!(res.header("vary"), Some("origin"));
647 }
648
649 #[tokio::test]
650 async fn preflight_returns_204_with_methods() {
651 let client = TestClient::new(app());
652 let res = client
653 .request(Method::OPTIONS, "/")
654 .header("origin", "https://example.com")
655 .header("access-control-request-method", "POST")
656 .send()
657 .await;
658 assert_eq!(res.status(), StatusCode::NO_CONTENT);
659 let methods = res.header("access-control-allow-methods").unwrap();
660 assert!(methods.contains("POST"));
661 }
662
663 /// The core dispatcher answers an unclaimed `OPTIONS` with `204` plus an
664 /// `Allow` header. That must not shadow CORS preflight, which needs to
665 /// respond with `access-control-*` headers instead.
666 ///
667 /// Cors sits in the `Plugins` phase and the router in `Fallback`, so
668 /// preflight short-circuits first — but that is an assumption about phase
669 /// ordering, and this test is what keeps it true.
670 #[tokio::test]
671 async fn preflight_takes_priority_over_automatic_options() {
672 let client = TestClient::new(app());
673 let res = client
674 .request(Method::OPTIONS, "/")
675 .header("origin", "https://example.com")
676 .header("access-control-request-method", "GET")
677 .send()
678 .await;
679
680 assert_eq!(res.status(), StatusCode::NO_CONTENT);
681 assert!(
682 res.header("access-control-allow-origin").is_some(),
683 "CORS preflight was swallowed by the automatic OPTIONS handler"
684 );
685 }
686
687 /// A `Vary` another layer already earned has to survive this one.
688 /// churust-compression merges `accept-encoding` into `Vary` on its way out
689 /// and the CORS middleware unwinds after it, so overwriting the header
690 /// would leave a gzip response keyed on `Origin` alone — a shared cache
691 /// would then hand those compressed bytes to the next client that sent no
692 /// `Accept-Encoding` at all.
693 #[tokio::test]
694 async fn a_pre_existing_vary_survives_the_cors_layer() {
695 let app = Churust::server()
696 .install(Cors::allow_any_origin_insecure())
697 .routing(|r| {
698 r.get("/report", |_c: Call| async {
699 Response::text("ok")
700 .with_header(VARY, HeaderValue::from_static("accept-encoding"))
701 });
702 })
703 .build();
704 let res = TestClient::new(app)
705 .get("/report")
706 .header("origin", "https://app.example.com")
707 .send()
708 .await;
709 assert_eq!(res.header("vary"), Some("accept-encoding, origin"));
710 }
711
712 /// Merging must not accumulate: a `Vary` that already names the origin is
713 /// left exactly as it is, and stays a single header line.
714 #[tokio::test]
715 async fn an_origin_already_named_in_vary_is_not_repeated() {
716 let app = Churust::server()
717 .install(Cors::allow_any_origin_insecure())
718 .routing(|r| {
719 r.get("/report", |_c: Call| async {
720 Response::text("ok").with_header(VARY, HeaderValue::from_static("Origin"))
721 });
722 })
723 .build();
724 let res = TestClient::new(app)
725 .get("/report")
726 .header("origin", "https://app.example.com")
727 .send()
728 .await;
729 assert_eq!(res.header("vary"), Some("Origin"));
730 }
731
732 /// The refusal is itself origin-dependent: this response has no
733 /// `Access-Control-Allow-Origin` precisely because of who asked. Without
734 /// `Vary: Origin` a shared cache may store the header-less answer and
735 /// replay it to an allowed origin, which the browser then blocks.
736 #[tokio::test]
737 async fn a_response_to_a_disallowed_origin_still_varies_on_origin() {
738 let app = Churust::server()
739 .install(Cors::new().allow_origin("https://allowed.com"))
740 .routing(|r| {
741 r.get("/", |_c: Call| async { "ok" });
742 })
743 .build();
744 let res = TestClient::new(app)
745 .get("/")
746 .header("origin", "https://evil.com")
747 .send()
748 .await;
749 assert_eq!(res.header("access-control-allow-origin"), None);
750 assert_eq!(res.header("vary"), Some("origin"));
751 }
752
753 /// Same-origin requests carry no `Origin` at all, so they too get an answer
754 /// that differs from the cross-origin one. The cache key has to say so.
755 #[tokio::test]
756 async fn a_response_to_a_request_without_an_origin_still_varies_on_origin() {
757 let res = TestClient::new(app()).get("/").send().await;
758 assert_eq!(res.header("access-control-allow-origin"), None);
759 assert_eq!(res.header("vary"), Some("origin"));
760 }
761
762 #[tokio::test]
763 async fn disallowed_origin_gets_no_cors_header() {
764 let app = Churust::server()
765 .install(Cors::new().allow_origin("https://allowed.com"))
766 .routing(|r| {
767 r.get("/", |_c: Call| async { "ok" });
768 })
769 .build();
770 let client = TestClient::new(app);
771 let res = client
772 .get("/")
773 .header("origin", "https://evil.com")
774 .send()
775 .await;
776 assert_eq!(res.header("access-control-allow-origin"), None);
777 }
778}