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::permissive())
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 `permissive()` 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::permissive())
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 pub fn permissive() -> Self {
154 Self {
155 origin: AllowOrigin::Any,
156 methods: vec![
157 Method::GET,
158 Method::POST,
159 Method::PUT,
160 Method::DELETE,
161 Method::PATCH,
162 Method::OPTIONS,
163 ],
164 headers: vec!["*".to_string()],
165 credentials: false,
166 max_age: Some(86_400),
167 }
168 }
169
170 /// Creates a restrictive CORS policy with safe defaults.
171 ///
172 /// The initial policy allows **no origins**, permits only `GET` and `POST`,
173 /// exposes no extra headers, disables credentials, and sets no `max-age`.
174 /// Use the builder methods to refine the policy before passing it to
175 /// [`AppBuilder::install`].
176 ///
177 /// # Example
178 ///
179 /// ```
180 /// use churust_core::{Churust, Call, TestClient};
181 /// use churust_cors::Cors;
182 ///
183 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
184 /// // Without adding any allowed origins, cross-origin requests receive
185 /// // no CORS headers and the browser will block the response.
186 /// let app = Churust::server()
187 /// .install(Cors::new().allow_origin("https://trusted.example.com"))
188 /// .routing(|r| {
189 /// r.get("/", |_c: Call| async { "ok" });
190 /// })
191 /// .build();
192 ///
193 /// // Unlisted origin → no CORS header.
194 /// let res = TestClient::new(app)
195 /// .get("/")
196 /// .header("origin", "https://untrusted.example.com")
197 /// .send()
198 /// .await;
199 ///
200 /// assert_eq!(res.header("access-control-allow-origin"), None);
201 /// # });
202 /// ```
203 pub fn new() -> Self {
204 Self {
205 origin: AllowOrigin::List(Vec::new()),
206 methods: vec![Method::GET, Method::POST],
207 headers: Vec::new(),
208 credentials: false,
209 max_age: None,
210 }
211 }
212
213 /// Adds a single origin that is allowed to make cross-origin requests.
214 ///
215 /// Call this method multiple times to whitelist several origins. The value
216 /// should be a fully-qualified origin string such as
217 /// `"https://app.example.com"` (scheme + host + optional port, **no**
218 /// trailing slash).
219 ///
220 /// If the policy was previously set to [`Cors::permissive`] (wildcard
221 /// origin), calling `allow_origin` switches back to an explicit list
222 /// containing only the supplied origin.
223 ///
224 /// # Parameters
225 ///
226 /// - `origin` — any type that converts to [`String`], e.g. `&str` or
227 /// `String`. The value is compared verbatim against the `Origin` request
228 /// header.
229 ///
230 /// # Example
231 ///
232 /// ```
233 /// use churust_core::{Churust, Call, TestClient};
234 /// use churust_cors::Cors;
235 ///
236 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
237 /// let app = Churust::server()
238 /// .install(
239 /// Cors::new()
240 /// .allow_origin("https://frontend.example.com")
241 /// .allow_origin("https://mobile.example.com"),
242 /// )
243 /// .routing(|r| {
244 /// r.get("/", |_c: Call| async { "ok" });
245 /// })
246 /// .build();
247 ///
248 /// let res = TestClient::new(app)
249 /// .get("/")
250 /// .header("origin", "https://frontend.example.com")
251 /// .send()
252 /// .await;
253 ///
254 /// assert_eq!(
255 /// res.header("access-control-allow-origin"),
256 /// Some("https://frontend.example.com")
257 /// );
258 /// # });
259 /// ```
260 pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
261 match &mut self.origin {
262 AllowOrigin::List(v) => v.push(origin.into()),
263 AllowOrigin::Any => {
264 self.origin = AllowOrigin::List(vec![origin.into()]);
265 }
266 }
267 self
268 }
269
270 /// Replaces the list of HTTP methods advertised in preflight responses.
271 ///
272 /// The supplied `methods` are joined with `", "` and sent as the
273 /// `Access-Control-Allow-Methods` header in response to `OPTIONS` preflight
274 /// requests. This call **replaces** the current list entirely — it does
275 /// not append.
276 ///
277 /// The default (from [`Cors::new`]) is `[GET, POST]`.
278 ///
279 /// # Example
280 ///
281 /// ```
282 /// use churust_core::{Churust, Call, TestClient};
283 /// use churust_cors::Cors;
284 /// use http::Method;
285 ///
286 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
287 /// let app = Churust::server()
288 /// .install(
289 /// Cors::new()
290 /// .allow_origin("https://example.com")
291 /// .allow_methods(vec![Method::GET, Method::POST, Method::DELETE]),
292 /// )
293 /// .routing(|r| {
294 /// r.get("/", |_c: Call| async { "ok" });
295 /// })
296 /// .build();
297 ///
298 /// // Send a preflight for DELETE.
299 /// let res = TestClient::new(app)
300 /// .request(Method::OPTIONS, "/")
301 /// .header("origin", "https://example.com")
302 /// .header("access-control-request-method", "DELETE")
303 /// .send()
304 /// .await;
305 ///
306 /// assert_eq!(res.status().as_u16(), 204);
307 /// let allowed = res.header("access-control-allow-methods").unwrap_or("");
308 /// assert!(allowed.contains("DELETE"));
309 /// # });
310 /// ```
311 pub fn allow_methods(mut self, methods: Vec<Method>) -> Self {
312 self.methods = methods;
313 self
314 }
315
316 /// Replaces the list of request headers advertised in preflight responses.
317 ///
318 /// The supplied header names are joined with `", "` and sent as the
319 /// `Access-Control-Allow-Headers` header in response to `OPTIONS` preflight
320 /// requests. Pass `["*"]` to allow any header (note that this is a literal
321 /// wildcard string, not a glob pattern — its meaning is defined by the
322 /// browser's CORS implementation).
323 ///
324 /// An empty list (the default from [`Cors::new`]) omits the
325 /// `Access-Control-Allow-Headers` header entirely from preflight responses.
326 ///
327 /// # Example
328 ///
329 /// ```
330 /// use churust_core::{Churust, Call, TestClient};
331 /// use churust_cors::Cors;
332 /// use http::Method;
333 ///
334 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
335 /// let app = Churust::server()
336 /// .install(
337 /// Cors::new()
338 /// .allow_origin("https://example.com")
339 /// .allow_headers(vec!["Content-Type".into(), "X-Api-Key".into()]),
340 /// )
341 /// .routing(|r| {
342 /// r.get("/", |_c: Call| async { "ok" });
343 /// })
344 /// .build();
345 ///
346 /// let res = TestClient::new(app)
347 /// .request(Method::OPTIONS, "/")
348 /// .header("origin", "https://example.com")
349 /// .header("access-control-request-method", "GET")
350 /// .send()
351 /// .await;
352 ///
353 /// assert_eq!(res.status().as_u16(), 204);
354 /// let hdrs = res.header("access-control-allow-headers").unwrap_or("");
355 /// assert!(hdrs.contains("X-Api-Key"));
356 /// # });
357 /// ```
358 pub fn allow_headers(mut self, headers: Vec<String>) -> Self {
359 self.headers = headers;
360 self
361 }
362
363 /// Controls whether the `Access-Control-Allow-Credentials: true` header is
364 /// sent.
365 ///
366 /// Set this to `true` when your API relies on cookies, HTTP authentication,
367 /// or TLS client certificates for cross-origin requests. The browser only
368 /// forwards credentials when **both** the server sets this header **and**
369 /// the client sets `XMLHttpRequest.withCredentials = true` (or the
370 /// `fetch` `credentials: "include"` option).
371 ///
372 /// > **CORS spec gotcha:** Credentials are incompatible with a wildcard
373 /// > (`*`) `Allow-Origin`. If you enable credentials, make sure the
374 /// > policy lists explicit origins via [`allow_origin`](Cors::allow_origin)
375 /// > rather than using [`Cors::permissive`].
376 ///
377 /// # Example
378 ///
379 /// ```
380 /// use churust_core::{Churust, Call, TestClient};
381 /// use churust_cors::Cors;
382 ///
383 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
384 /// let app = Churust::server()
385 /// .install(
386 /// Cors::new()
387 /// .allow_origin("https://trusted.example.com")
388 /// .allow_credentials(true),
389 /// )
390 /// .routing(|r| {
391 /// r.get("/", |_c: Call| async { "ok" });
392 /// })
393 /// .build();
394 ///
395 /// let res = TestClient::new(app)
396 /// .get("/")
397 /// .header("origin", "https://trusted.example.com")
398 /// .send()
399 /// .await;
400 ///
401 /// assert_eq!(res.header("access-control-allow-credentials"), Some("true"));
402 /// # });
403 /// ```
404 pub fn allow_credentials(mut self, yes: bool) -> Self {
405 self.credentials = yes;
406 self
407 }
408
409 /// Sets the `Access-Control-Max-Age` value (in seconds) for preflight caching.
410 ///
411 /// Browsers may cache a successful preflight response for up to `seconds`
412 /// seconds, avoiding repeated `OPTIONS` round-trips for subsequent requests
413 /// to the same endpoint. The practical upper limit varies by browser (e.g.
414 /// Chrome caps it at 7200 seconds; Firefox caps it at 86 400 seconds).
415 ///
416 /// If this method is not called (the default for [`Cors::new`]), the
417 /// `Access-Control-Max-Age` header is omitted and the browser applies its
418 /// own default (typically 5 seconds).
419 ///
420 /// # Parameters
421 ///
422 /// - `seconds` — cache duration as a non-negative integer. A value of `0`
423 /// is legal and instructs browsers not to cache the preflight at all.
424 ///
425 /// # Example
426 ///
427 /// ```
428 /// use churust_core::{Churust, Call, TestClient};
429 /// use churust_cors::Cors;
430 /// use http::Method;
431 ///
432 /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
433 /// let app = Churust::server()
434 /// .install(
435 /// Cors::new()
436 /// .allow_origin("https://example.com")
437 /// .max_age(3600),
438 /// )
439 /// .routing(|r| {
440 /// r.get("/", |_c: Call| async { "ok" });
441 /// })
442 /// .build();
443 ///
444 /// let res = TestClient::new(app)
445 /// .request(Method::OPTIONS, "/")
446 /// .header("origin", "https://example.com")
447 /// .header("access-control-request-method", "GET")
448 /// .send()
449 /// .await;
450 ///
451 /// assert_eq!(res.header("access-control-max-age"), Some("3600"));
452 /// # });
453 /// ```
454 pub fn max_age(mut self, seconds: u64) -> Self {
455 self.max_age = Some(seconds);
456 self
457 }
458
459 fn origin_allowed(&self, origin: &str) -> Option<String> {
460 match &self.origin {
461 AllowOrigin::Any => Some("*".to_string()),
462 AllowOrigin::List(list) => {
463 if list.iter().any(|o| o == origin) {
464 Some(origin.to_string())
465 } else {
466 None
467 }
468 }
469 }
470 }
471
472 fn apply_common(&self, res: &mut Response, allow_origin: &str) {
473 res.headers.insert(
474 ACCESS_CONTROL_ALLOW_ORIGIN,
475 HeaderValue::from_str(allow_origin).unwrap_or(HeaderValue::from_static("*")),
476 );
477 if self.credentials {
478 res.headers.insert(
479 ACCESS_CONTROL_ALLOW_CREDENTIALS,
480 HeaderValue::from_static("true"),
481 );
482 }
483 // Vary: Origin so caches don't serve the wrong CORS headers.
484 res.headers.insert(VARY, HeaderValue::from_static("Origin"));
485 }
486}
487
488impl Default for Cors {
489 fn default() -> Self {
490 Self::new()
491 }
492}
493
494impl Plugin for Cors {
495 fn install(self: Box<Self>, app: &mut AppBuilder) {
496 app.add_middleware_in(Phase::Plugins, Arc::new(CorsMiddleware { cfg: *self }));
497 }
498}
499
500struct CorsMiddleware {
501 cfg: Cors,
502}
503
504#[async_trait]
505impl Middleware for CorsMiddleware {
506 async fn handle(&self, call: Call, next: Next) -> Response {
507 let origin = call.header("origin").map(|s| s.to_string());
508 let is_preflight = *call.method() == Method::OPTIONS
509 && call
510 .header(ACCESS_CONTROL_REQUEST_METHOD.as_str())
511 .is_some();
512
513 // Preflight: short-circuit with 204 + CORS headers.
514 if is_preflight {
515 let mut res = Response::new(StatusCode::NO_CONTENT);
516 if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
517 self.cfg.apply_common(&mut res, &o);
518 let methods = self
519 .cfg
520 .methods
521 .iter()
522 .map(|m| m.as_str())
523 .collect::<Vec<_>>()
524 .join(", ");
525 if let Ok(v) = HeaderValue::from_str(&methods) {
526 res.headers.insert(ACCESS_CONTROL_ALLOW_METHODS, v);
527 }
528 if !self.cfg.headers.is_empty() {
529 let hs = self.cfg.headers.join(", ");
530 if let Ok(v) = HeaderValue::from_str(&hs) {
531 res.headers.insert(ACCESS_CONTROL_ALLOW_HEADERS, v);
532 }
533 }
534 if let Some(age) = self.cfg.max_age {
535 if let Ok(v) = HeaderValue::from_str(&age.to_string()) {
536 res.headers.insert(ACCESS_CONTROL_MAX_AGE, v);
537 }
538 }
539 }
540 return res;
541 }
542
543 // Actual request: run the chain, then decorate the response.
544 let mut res = next.run(call).await;
545 if let Some(o) = origin.as_deref().and_then(|o| self.cfg.origin_allowed(o)) {
546 self.cfg.apply_common(&mut res, &o);
547 }
548 res
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use churust_core::{App, Churust, TestClient};
556
557 fn app() -> App {
558 Churust::server()
559 .install(Cors::permissive())
560 .routing(|r| {
561 r.get("/", |_c: Call| async { "ok" });
562 })
563 .build()
564 }
565
566 #[tokio::test]
567 async fn actual_request_gets_allow_origin() {
568 let client = TestClient::new(app());
569 let res = client
570 .get("/")
571 .header("origin", "https://example.com")
572 .send()
573 .await;
574 assert_eq!(res.status(), StatusCode::OK);
575 assert_eq!(res.header("access-control-allow-origin"), Some("*"));
576 assert_eq!(res.header("vary"), Some("Origin"));
577 }
578
579 #[tokio::test]
580 async fn preflight_returns_204_with_methods() {
581 let client = TestClient::new(app());
582 let res = client
583 .request(Method::OPTIONS, "/")
584 .header("origin", "https://example.com")
585 .header("access-control-request-method", "POST")
586 .send()
587 .await;
588 assert_eq!(res.status(), StatusCode::NO_CONTENT);
589 let methods = res.header("access-control-allow-methods").unwrap();
590 assert!(methods.contains("POST"));
591 }
592
593 /// The core dispatcher answers an unclaimed `OPTIONS` with `204` plus an
594 /// `Allow` header. That must not shadow CORS preflight, which needs to
595 /// respond with `access-control-*` headers instead.
596 ///
597 /// Cors sits in the `Plugins` phase and the router in `Fallback`, so
598 /// preflight short-circuits first — but that is an assumption about phase
599 /// ordering, and this test is what keeps it true.
600 #[tokio::test]
601 async fn preflight_takes_priority_over_automatic_options() {
602 let client = TestClient::new(app());
603 let res = client
604 .request(Method::OPTIONS, "/")
605 .header("origin", "https://example.com")
606 .header("access-control-request-method", "GET")
607 .send()
608 .await;
609
610 assert_eq!(res.status(), StatusCode::NO_CONTENT);
611 assert!(
612 res.header("access-control-allow-origin").is_some(),
613 "CORS preflight was swallowed by the automatic OPTIONS handler"
614 );
615 }
616
617 #[tokio::test]
618 async fn disallowed_origin_gets_no_cors_header() {
619 let app = Churust::server()
620 .install(Cors::new().allow_origin("https://allowed.com"))
621 .routing(|r| {
622 r.get("/", |_c: Call| async { "ok" });
623 })
624 .build();
625 let client = TestClient::new(app);
626 let res = client
627 .get("/")
628 .header("origin", "https://evil.com")
629 .send()
630 .await;
631 assert_eq!(res.header("access-control-allow-origin"), None);
632 }
633}