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,
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.
520///
521/// The merge itself is `Response::vary_on`, in churust-core, rather than a shape
522/// copied here and kept in step with the compression plugin's by hand: the two
523/// only interoperate if they agree on what a merged `Vary` looks like whichever
524/// order they are installed in, and one implementation is how that is
525/// guaranteed rather than intended.
526fn vary_on_origin(res: &mut Response) {
527 res.vary_on("origin");
528}
529
530impl Plugin for Cors {
531 fn install(self: Box<Self>, app: &mut AppBuilder) {
532 app.add_middleware_in(Phase::Plugins, Arc::new(CorsMiddleware { cfg: *self }));
533 }
534}
535
536struct CorsMiddleware {
537 cfg: Cors,
538}
539
540#[async_trait]
541impl Middleware for CorsMiddleware {
542 async fn handle(&self, call: Call, next: Next) -> Response {
543 let origin = call.header("origin").map(|s| s.to_string());
544 let is_preflight = *call.method() == Method::OPTIONS
545 && call
546 .header(ACCESS_CONTROL_REQUEST_METHOD.as_str())
547 .is_some();
548
549 // Preflight: short-circuit with 204 + CORS headers.
550 if is_preflight {
551 let mut res = Response::new(StatusCode::NO_CONTENT);
552 if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
553 self.cfg.apply_common(&mut res, &o);
554 let methods = self
555 .cfg
556 .methods
557 .iter()
558 .map(|m| m.as_str())
559 .collect::<Vec<_>>()
560 .join(", ");
561 if let Ok(v) = HeaderValue::from_str(&methods) {
562 res.headers.insert(ACCESS_CONTROL_ALLOW_METHODS, v);
563 }
564 if !self.cfg.headers.is_empty() {
565 let hs = self.cfg.headers.join(", ");
566 if let Ok(v) = HeaderValue::from_str(&hs) {
567 res.headers.insert(ACCESS_CONTROL_ALLOW_HEADERS, v);
568 }
569 }
570 if let Some(age) = self.cfg.max_age {
571 if let Ok(v) = HeaderValue::from_str(&age.to_string()) {
572 res.headers.insert(ACCESS_CONTROL_MAX_AGE, v);
573 }
574 }
575 }
576 vary_on_origin(&mut res);
577 return res;
578 }
579
580 // Actual request: run the chain, then decorate the response.
581 let mut res = next.run(call).await;
582 if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
583 self.cfg.apply_common(&mut res, &o);
584 }
585 // Every response this middleware touches is marked, not only the ones
586 // that came out with an `Access-Control-Allow-Origin`. Whether that
587 // header is present at all is decided by the request's `Origin`: a
588 // same-origin request sends none and gets none back, a refused origin
589 // gets none either, an allowed one does. Marking only the allowed case
590 // left the other two freely cacheable, so a shared cache could store
591 // the header-less answer and replay it to an allowed origin, whose
592 // browser then blocks a response the server would have permitted.
593 vary_on_origin(&mut res);
594 res
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 // Only the tests name it now that merging lives in churust-core.
602 use churust_core::{App, Churust, TestClient};
603 use http::header::VARY;
604
605 fn app() -> App {
606 Churust::server()
607 .install(Cors::allow_any_origin_insecure())
608 .routing(|r| {
609 r.get("/", |_c: Call| async { "ok" });
610 })
611 .build()
612 }
613
614 #[tokio::test]
615 async fn actual_request_gets_allow_origin() {
616 let client = TestClient::new(app());
617 let res = client
618 .get("/")
619 .header("origin", "https://example.com")
620 .send()
621 .await;
622 assert_eq!(res.status(), StatusCode::OK);
623 assert_eq!(res.header("access-control-allow-origin"), Some("*"));
624 // Lower case because the merge normalises what it finds and appends in
625 // the same spelling churust-compression uses; `Vary` names fields, and
626 // field names are case-insensitive, so this is the same header as the
627 // `Origin` this test used to assert.
628 assert_eq!(res.header("vary"), Some("origin"));
629 }
630
631 #[tokio::test]
632 async fn preflight_returns_204_with_methods() {
633 let client = TestClient::new(app());
634 let res = client
635 .request(Method::OPTIONS, "/")
636 .header("origin", "https://example.com")
637 .header("access-control-request-method", "POST")
638 .send()
639 .await;
640 assert_eq!(res.status(), StatusCode::NO_CONTENT);
641 let methods = res.header("access-control-allow-methods").unwrap();
642 assert!(methods.contains("POST"));
643 }
644
645 /// The core dispatcher answers an unclaimed `OPTIONS` with `204` plus an
646 /// `Allow` header. That must not shadow CORS preflight, which needs to
647 /// respond with `access-control-*` headers instead.
648 ///
649 /// Cors sits in the `Plugins` phase and the router in `Fallback`, so
650 /// preflight short-circuits first — but that is an assumption about phase
651 /// ordering, and this test is what keeps it true.
652 #[tokio::test]
653 async fn preflight_takes_priority_over_automatic_options() {
654 let client = TestClient::new(app());
655 let res = client
656 .request(Method::OPTIONS, "/")
657 .header("origin", "https://example.com")
658 .header("access-control-request-method", "GET")
659 .send()
660 .await;
661
662 assert_eq!(res.status(), StatusCode::NO_CONTENT);
663 assert!(
664 res.header("access-control-allow-origin").is_some(),
665 "CORS preflight was swallowed by the automatic OPTIONS handler"
666 );
667 }
668
669 /// A `Vary` another layer already earned has to survive this one.
670 /// churust-compression merges `accept-encoding` into `Vary` on its way out
671 /// and the CORS middleware unwinds after it, so overwriting the header
672 /// would leave a gzip response keyed on `Origin` alone — a shared cache
673 /// would then hand those compressed bytes to the next client that sent no
674 /// `Accept-Encoding` at all.
675 #[tokio::test]
676 async fn a_pre_existing_vary_survives_the_cors_layer() {
677 let app = Churust::server()
678 .install(Cors::allow_any_origin_insecure())
679 .routing(|r| {
680 r.get("/report", |_c: Call| async {
681 Response::text("ok")
682 .with_header(VARY, HeaderValue::from_static("accept-encoding"))
683 });
684 })
685 .build();
686 let res = TestClient::new(app)
687 .get("/report")
688 .header("origin", "https://app.example.com")
689 .send()
690 .await;
691 assert_eq!(res.header("vary"), Some("accept-encoding, origin"));
692 }
693
694 /// Merging must not accumulate: a `Vary` that already names the origin is
695 /// left exactly as it is, and stays a single header line.
696 #[tokio::test]
697 async fn an_origin_already_named_in_vary_is_not_repeated() {
698 let app = Churust::server()
699 .install(Cors::allow_any_origin_insecure())
700 .routing(|r| {
701 r.get("/report", |_c: Call| async {
702 Response::text("ok").with_header(VARY, HeaderValue::from_static("Origin"))
703 });
704 })
705 .build();
706 let res = TestClient::new(app)
707 .get("/report")
708 .header("origin", "https://app.example.com")
709 .send()
710 .await;
711 assert_eq!(res.header("vary"), Some("Origin"));
712 }
713
714 /// The refusal is itself origin-dependent: this response has no
715 /// `Access-Control-Allow-Origin` precisely because of who asked. Without
716 /// `Vary: Origin` a shared cache may store the header-less answer and
717 /// replay it to an allowed origin, which the browser then blocks.
718 #[tokio::test]
719 async fn a_response_to_a_disallowed_origin_still_varies_on_origin() {
720 let app = Churust::server()
721 .install(Cors::new().allow_origin("https://allowed.com"))
722 .routing(|r| {
723 r.get("/", |_c: Call| async { "ok" });
724 })
725 .build();
726 let res = TestClient::new(app)
727 .get("/")
728 .header("origin", "https://evil.com")
729 .send()
730 .await;
731 assert_eq!(res.header("access-control-allow-origin"), None);
732 assert_eq!(res.header("vary"), Some("origin"));
733 }
734
735 /// Same-origin requests carry no `Origin` at all, so they too get an answer
736 /// that differs from the cross-origin one. The cache key has to say so.
737 #[tokio::test]
738 async fn a_response_to_a_request_without_an_origin_still_varies_on_origin() {
739 let res = TestClient::new(app()).get("/").send().await;
740 assert_eq!(res.header("access-control-allow-origin"), None);
741 assert_eq!(res.header("vary"), Some("origin"));
742 }
743
744 #[tokio::test]
745 async fn disallowed_origin_gets_no_cors_header() {
746 let app = Churust::server()
747 .install(Cors::new().allow_origin("https://allowed.com"))
748 .routing(|r| {
749 r.get("/", |_c: Call| async { "ok" });
750 })
751 .build();
752 let client = TestClient::new(app);
753 let res = client
754 .get("/")
755 .header("origin", "https://evil.com")
756 .send()
757 .await;
758 assert_eq!(res.header("access-control-allow-origin"), None);
759 }
760}