Skip to main content

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}