Skip to main content

ullun/
cors.rs

1//! Cross-origin resource sharing (CORS)
2//!
3//! Browsers only let a page read a cross-origin response when the server opts
4//! in. [`Cors`] implements that opt-in with the pitfalls handled:
5//!
6//! * the request's `Origin` is checked against the allow list and echoed back
7//!   (never "the first configured origin"), with `Vary: Origin` so caches do not
8//!   serve one origin's answer to another
9//! * credentials are never combined with a wildcard: with
10//!   [`Cors::allow_any_origin`] the `Access-Control-Allow-Credentials` header is
11//!   simply not sent, whatever else is configured
12//! * a preflight (`OPTIONS` with `Origin` and `Access-Control-Request-Method`) is
13//!   answered by the middleware itself, even for paths without an `OPTIONS`
14//!   route; preflights from unknown origins or for unlisted methods get `403`
15//! * error responses carry the CORS headers too, so the browser shows the real
16//!   status instead of an opaque network error
17//!
18//! ```no_run
19//! use std::time::Duration;
20//! use ullun::cors::Cors;
21//! use ullun::prelude::*;
22//!
23//! # async fn run() {
24//! App::new()
25//!     .middleware(
26//!         Cors::new()
27//!             .allow_origin("https://app.example.com")
28//!             .allow_credentials()
29//!             .expose_headers(["x-request-id"])
30//!             .max_age(Duration::from_secs(3600)),
31//!     )
32//!     .get("/", |_req: Request| async { Ok(Response::text("ok")) })
33//!     .run("127.0.0.1:3000")
34//!     .await
35//!     .unwrap();
36//! # }
37//! ```
38
39use crate::error::Result;
40use crate::middleware::{Middleware, Next};
41use crate::request::Request;
42use crate::response::Response;
43use async_trait::async_trait;
44use http::header::{HeaderValue, VARY};
45use http::{Method, StatusCode};
46use std::sync::Arc;
47use std::time::Duration;
48
49type OriginFn = dyn Fn(&str) -> bool + Send + Sync;
50
51enum Origins {
52    List(Vec<String>),
53    Any,
54    Predicate(Arc<OriginFn>),
55}
56
57/// CORS middleware, see the [module documentation](self)
58pub struct Cors {
59    origins: Origins,
60    methods: Vec<Method>,
61    /// `None` mirrors `Access-Control-Request-Headers`
62    headers: Option<Vec<String>>,
63    expose: Vec<String>,
64    credentials: bool,
65    max_age: Duration,
66    private_network: bool,
67}
68
69impl Default for Cors {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75impl Cors {
76    /// No origin allowed yet; add some with [`allow_origin`](Cors::allow_origin),
77    /// [`allow_origins`](Cors::allow_origins), [`allow_origin_fn`](Cors::allow_origin_fn)
78    /// or [`allow_any_origin`](Cors::allow_any_origin)
79    pub fn new() -> Self {
80        Self {
81            origins: Origins::List(Vec::new()),
82            methods: vec![
83                Method::GET,
84                Method::HEAD,
85                Method::POST,
86                Method::PUT,
87                Method::PATCH,
88                Method::DELETE,
89            ],
90            headers: None,
91            expose: Vec::new(),
92            credentials: false,
93            max_age: Duration::from_secs(600),
94            private_network: false,
95        }
96    }
97
98    /// Allow one origin, written exactly as browsers send it
99    /// (`https://app.example.com`, no trailing slash)
100    pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
101        let origin = origin.into();
102        match &mut self.origins {
103            Origins::List(list) => list.push(origin),
104            _ => self.origins = Origins::List(vec![origin]),
105        }
106        self
107    }
108
109    pub fn allow_origins<I, S>(mut self, origins: I) -> Self
110    where
111        I: IntoIterator<Item = S>,
112        S: Into<String>,
113    {
114        for origin in origins {
115            self = self.allow_origin(origin);
116        }
117        self
118    }
119
120    /// Allow every origin (`Access-Control-Allow-Origin: *`). Credentials are
121    /// never allowed in this mode.
122    pub fn allow_any_origin(mut self) -> Self {
123        self.origins = Origins::Any;
124        self
125    }
126
127    /// Decide per origin, e.g. all subdomains of your own domain
128    pub fn allow_origin_fn<F>(mut self, f: F) -> Self
129    where
130        F: Fn(&str) -> bool + Send + Sync + 'static,
131    {
132        self.origins = Origins::Predicate(Arc::new(f));
133        self
134    }
135
136    /// Methods a cross-origin page may use (default: GET, HEAD, POST, PUT, PATCH, DELETE)
137    pub fn allow_methods<I>(mut self, methods: I) -> Self
138    where
139        I: IntoIterator<Item = Method>,
140    {
141        self.methods = methods.into_iter().collect();
142        self
143    }
144
145    /// Request headers a cross-origin page may send. Without this call the
146    /// headers named in the preflight are allowed.
147    pub fn allow_headers<I, S>(mut self, headers: I) -> Self
148    where
149        I: IntoIterator<Item = S>,
150        S: Into<String>,
151    {
152        self.headers = Some(headers.into_iter().map(Into::into).collect());
153        self
154    }
155
156    /// Response headers a cross-origin page may read besides the safelisted ones
157    pub fn expose_headers<I, S>(mut self, headers: I) -> Self
158    where
159        I: IntoIterator<Item = S>,
160        S: Into<String>,
161    {
162        self.expose = headers.into_iter().map(Into::into).collect();
163        self
164    }
165
166    /// Let the page send cookies and `Authorization` (`Access-Control-Allow-Credentials`).
167    /// Ignored together with [`allow_any_origin`](Cors::allow_any_origin).
168    pub fn allow_credentials(mut self) -> Self {
169        self.credentials = true;
170        self
171    }
172
173    /// How long browsers may cache a preflight answer (default 10 minutes)
174    pub fn max_age(mut self, max_age: Duration) -> Self {
175        self.max_age = max_age;
176        self
177    }
178
179    /// Answer Private Network Access preflights, which public pages send before
180    /// reaching into a private network (`Access-Control-Allow-Private-Network`)
181    pub fn allow_private_network(mut self) -> Self {
182        self.private_network = true;
183        self
184    }
185
186    fn origin_allowed(&self, origin: &str) -> bool {
187        match &self.origins {
188            Origins::Any => true,
189            Origins::List(list) => list.iter().any(|o| o == origin),
190            Origins::Predicate(f) => f(origin),
191        }
192    }
193
194    fn any(&self) -> bool {
195        matches!(self.origins, Origins::Any)
196    }
197
198    /// `Access-Control-Allow-Origin` and friends for an allowed origin
199    fn decorate(&self, mut res: Response, origin: &str) -> Response {
200        if self.any() {
201            res = res.with_header("access-control-allow-origin", "*");
202        } else {
203            res = res.with_header("access-control-allow-origin", origin);
204            add_vary(&mut res, "origin");
205            if self.credentials {
206                res = res.with_header("access-control-allow-credentials", "true");
207            }
208        }
209        if !self.expose.is_empty() {
210            res = res.with_header("access-control-expose-headers", self.expose.join(", "));
211        }
212        res
213    }
214
215    fn preflight(&self, req: &Request, origin: &str) -> Response {
216        let requested = req
217            .headers
218            .get("access-control-request-method")
219            .and_then(|v| v.to_str().ok())
220            .unwrap_or("");
221        let method_ok = self.methods.iter().any(|m| m.as_str() == requested);
222        if !self.origin_allowed(origin) || !method_ok {
223            let mut res = Response::new(StatusCode::FORBIDDEN);
224            add_vary(&mut res, "origin");
225            return res;
226        }
227
228        let mut res = self.decorate(Response::new(StatusCode::NO_CONTENT), origin);
229        let methods: Vec<&str> = self.methods.iter().map(Method::as_str).collect();
230        res = res
231            .with_header("access-control-allow-methods", methods.join(", "))
232            .with_header("access-control-max-age", self.max_age.as_secs().to_string());
233        match &self.headers {
234            Some(list) if !list.is_empty() => {
235                res = res.with_header("access-control-allow-headers", list.join(", "));
236            }
237            Some(_) => {}
238            None => {
239                if let Some(asked) = req
240                    .headers
241                    .get("access-control-request-headers")
242                    .and_then(|v| v.to_str().ok())
243                    .filter(|v| !v.trim().is_empty())
244                {
245                    res = res.with_header("access-control-allow-headers", asked);
246                    add_vary(&mut res, "access-control-request-headers");
247                }
248            }
249        }
250        if self.private_network
251            && req
252                .headers
253                .get("access-control-request-private-network")
254                .is_some_and(|v| v == "true")
255        {
256            res = res.with_header("access-control-allow-private-network", "true");
257        }
258        res
259    }
260}
261
262/// Add a token to `Vary` without dropping the ones already there
263pub(crate) fn add_vary(res: &mut Response, token: &str) {
264    let existing: Vec<String> = res
265        .headers
266        .get_all(VARY)
267        .iter()
268        .filter_map(|v| v.to_str().ok())
269        .flat_map(|v| v.split(','))
270        .map(|t| t.trim().to_string())
271        .filter(|t| !t.is_empty())
272        .collect();
273    if existing
274        .iter()
275        .any(|t| t == "*" || t.eq_ignore_ascii_case(token))
276    {
277        return;
278    }
279    let mut all = existing;
280    all.push(token.to_string());
281    if let Ok(value) = HeaderValue::from_str(&all.join(", ")) {
282        res.headers.insert(VARY, value);
283    }
284}
285
286#[async_trait]
287impl Middleware for Cors {
288    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
289        let origin = req
290            .headers
291            .get("origin")
292            .and_then(|v| v.to_str().ok())
293            .map(str::to_string);
294        let Some(origin) = origin else {
295            // same-origin or non-browser request: nothing to negotiate, but a
296            // cache must still keep the variants apart
297            let mut res = into_response(next.run(req).await);
298            if !self.any() {
299                add_vary(&mut res, "origin");
300            }
301            return Ok(res);
302        };
303
304        let is_preflight = req.method == Method::OPTIONS
305            && req.headers.contains_key("access-control-request-method");
306        if is_preflight {
307            return Ok(self.preflight(&req, &origin));
308        }
309
310        let mut res = into_response(next.run(req).await);
311        if self.origin_allowed(&origin) {
312            res = self.decorate(res, &origin);
313        } else if !self.any() {
314            add_vary(&mut res, "origin");
315        }
316        Ok(res)
317    }
318}
319
320fn into_response(result: Result<Response>) -> Response {
321    result.unwrap_or_else(Response::from_error)
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn vary_is_merged_not_replaced() {
330        let mut res = Response::ok().with_header("vary", "accept-encoding");
331        add_vary(&mut res, "origin");
332        assert_eq!(res.headers[VARY], "accept-encoding, origin");
333        add_vary(&mut res, "Origin");
334        assert_eq!(res.headers[VARY], "accept-encoding, origin");
335    }
336
337    #[test]
338    fn vary_star_is_left_alone() {
339        let mut res = Response::ok().with_header("vary", "*");
340        add_vary(&mut res, "origin");
341        assert_eq!(res.headers[VARY], "*");
342    }
343
344    #[test]
345    fn origin_matching_is_exact() {
346        let cors = Cors::new().allow_origin("https://a.example");
347        assert!(cors.origin_allowed("https://a.example"));
348        assert!(!cors.origin_allowed("https://a.example.evil.com"));
349        assert!(!cors.origin_allowed("http://a.example"));
350        assert!(!Cors::new().origin_allowed("null"));
351    }
352}