Skip to main content

web_net/
cors.rs

1//! Cross-origin resource sharing, declared per route or group.
2//!
3//! A route without a [`Cors`] policy answers no cross-origin request with
4//! CORS fields, so a browser keeps another origin's script from reading it.
5//! With one, a preflight (`OPTIONS` with `Access-Control-Request-Method`) is
6//! answered before the gate (preflights carry no credentials), and replies to
7//! allowed origins carry `Access-Control-Allow-Origin`.
8//!
9//! CORS relaxes what a browser lets a page read; it is not authentication.
10//! A cross-origin `POST` still meets the CSRF step: an origin allowed here
11//! for unsafe methods must also be allowed in the `Csrf` builder
12//! (`allow_origin`), or the route must take its token from a header.
13
14use std::fmt::Write as _;
15
16use serve_net::http_net::MethodSet;
17use serve_net::{HttpReply, Method};
18
19/// A CORS policy.
20///
21/// ```
22/// use web_net::Cors;
23///
24/// let api = Cors::new()
25///     .origin("https://app.example.com")
26///     .headers(&["content-type", "x-csrf-token"])
27///     .credentials()
28///     .max_age(600);
29/// # let _ = api;
30/// ```
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct Cors {
33    pub(crate) origins: Vec<Box<str>>,
34    pub(crate) any: bool,
35    pub(crate) headers: Vec<Box<str>>,
36    pub(crate) expose: Vec<Box<str>>,
37    pub(crate) credentials: bool,
38    pub(crate) max_age: u32,
39}
40
41impl Cors {
42    /// A policy that allows no origin yet.
43    ///
44    /// ```
45    /// use web_net::{App, Cors, get};
46    ///
47    /// // A policy that allows no origin is refused at build.
48    /// let empty = App::builder().route(get("/api", |cx| cx.text(200, "[]")).cors(Cors::new())).build();
49    /// assert!(empty.is_err());
50    ///
51    /// let app = App::builder()
52    ///     .route(get("/api", |cx| cx.text(200, "[]")).cors(Cors::new().origin("https://app.example.com")))
53    ///     .build()?;
54    /// let r = serve_net::testing::respond(&app, b"GET /api HTTP/1.1\r\nHost: localhost\r\nOrigin: https://app.example.com\r\n\r\n");
55    /// assert_eq!(r.header("access-control-allow-origin"), Some(&b"https://app.example.com"[..]));
56    /// # Ok::<(), web_net::BuildError>(())
57    /// ```
58    #[must_use]
59    pub fn new() -> Self {
60        Self {
61            max_age: 300,
62            ..Self::default()
63        }
64    }
65
66    /// Allows an origin, exactly as browsers send it
67    /// (`https://app.example.com`, with a port when it has one).
68    ///
69    /// ```
70    /// use web_net::{App, Cors, get};
71    ///
72    /// let app = App::builder()
73    ///     .route(get("/api", |cx| cx.text(200, "[]")).cors(Cors::new().origin("https://app.example.com")))
74    ///     .build()?;
75    /// let from = |origin: &str| serve_net::testing::respond(&app, format!("GET /api HTTP/1.1\r\nHost: localhost\r\nOrigin: {origin}\r\n\r\n").as_bytes());
76    /// assert!(from("https://app.example.com").header("access-control-allow-origin").is_some());
77    /// assert!(from("https://other.example").header("access-control-allow-origin").is_none());
78    /// # Ok::<(), web_net::BuildError>(())
79    /// ```
80    #[must_use]
81    pub fn origin(mut self, origin: &str) -> Self {
82        self.origins.push(origin.to_ascii_lowercase().into());
83        self
84    }
85
86    /// Allows every origin (answered with `*`). Not with
87    /// [`credentials`](Self::credentials): the build refuses that pair.
88    ///
89    /// ```
90    /// use web_net::{App, Cors, get};
91    ///
92    /// let app = App::builder()
93    ///     .route(get("/api", |cx| cx.text(200, "[]")).cors(Cors::new().any_origin()))
94    ///     .build()?;
95    /// let r = serve_net::testing::respond(&app, b"GET /api HTTP/1.1\r\nHost: localhost\r\nOrigin: https://app.example.com\r\n\r\n");
96    /// assert_eq!(r.header("access-control-allow-origin"), Some(&b"*"[..]));
97    /// # Ok::<(), web_net::BuildError>(())
98    /// ```
99    #[must_use]
100    pub fn any_origin(mut self) -> Self {
101        self.any = true;
102        self
103    }
104
105    /// Request fields a cross-origin request may send beyond the simple ones
106    /// (`content-type`, `x-csrf-token`).
107    ///
108    /// ```
109    /// use web_net::{App, Cors, get};
110    ///
111    /// let app = App::builder()
112    ///     .route(get("/api", |cx| cx.text(200, "[]")).cors(Cors::new().origin("https://app.example.com").headers(&["x-csrf-token"])))
113    ///     .build()?;
114    /// let r = serve_net::testing::respond(&app, b"OPTIONS /api HTTP/1.1\r\nHost: localhost\r\nOrigin: https://app.example.com\r\nAccess-Control-Request-Method: GET\r\nAccess-Control-Request-Headers: x-csrf-token\r\n\r\n");
115    /// assert_eq!(r.header("access-control-allow-headers"), Some(&b"x-csrf-token"[..]));
116    /// # Ok::<(), web_net::BuildError>(())
117    /// ```
118    #[must_use]
119    pub fn headers(mut self, names: &[&str]) -> Self {
120        self.headers.extend(
121            names
122                .iter()
123                .map(|n| n.to_ascii_lowercase().into_boxed_str()),
124        );
125        self
126    }
127
128    /// Reply fields a cross-origin script may read beyond the simple ones.
129    ///
130    /// ```
131    /// use web_net::{App, Cors, get};
132    ///
133    /// let app = App::builder()
134    ///     .route(get("/api", |cx| cx.text(200, "[]")).cors(Cors::new().origin("https://app.example.com").expose(&["x-total"])))
135    ///     .build()?;
136    /// let r = serve_net::testing::respond(&app, b"GET /api HTTP/1.1\r\nHost: localhost\r\nOrigin: https://app.example.com\r\n\r\n");
137    /// assert_eq!(r.header("access-control-expose-headers"), Some(&b"x-total"[..]));
138    /// # Ok::<(), web_net::BuildError>(())
139    /// ```
140    #[must_use]
141    pub fn expose(mut self, names: &[&str]) -> Self {
142        self.expose.extend(
143            names
144                .iter()
145                .map(|n| n.to_ascii_lowercase().into_boxed_str()),
146        );
147        self
148    }
149
150    /// Cross-origin requests may carry cookies.
151    ///
152    /// ```
153    /// use web_net::{App, Cors, get};
154    ///
155    /// let app = App::builder()
156    ///     .route(get("/api", |cx| cx.text(200, "[]")).cors(Cors::new().origin("https://app.example.com").credentials()))
157    ///     .build()?;
158    /// let r = serve_net::testing::respond(&app, b"GET /api HTTP/1.1\r\nHost: localhost\r\nOrigin: https://app.example.com\r\n\r\n");
159    /// assert_eq!(r.header("access-control-allow-credentials"), Some(&b"true"[..]));
160    ///
161    /// // Any origin with credentials is refused at build.
162    /// let open = App::builder().route(get("/api", |cx| cx.text(200, "[]")).cors(Cors::new().any_origin().credentials()));
163    /// assert!(open.build().is_err());
164    /// # Ok::<(), web_net::BuildError>(())
165    /// ```
166    #[must_use]
167    pub fn credentials(mut self) -> Self {
168        self.credentials = true;
169        self
170    }
171
172    /// How long a browser may keep a preflight's answer (default 300 s).
173    ///
174    /// ```
175    /// use web_net::{App, Cors, get};
176    ///
177    /// let app = App::builder()
178    ///     .route(get("/api", |cx| cx.text(200, "[]")).cors(Cors::new().origin("https://app.example.com").headers(&["x-csrf-token"]).max_age(600)))
179    ///     .build()?;
180    /// let r = serve_net::testing::respond(&app, b"OPTIONS /api HTTP/1.1\r\nHost: localhost\r\nOrigin: https://app.example.com\r\nAccess-Control-Request-Method: GET\r\nAccess-Control-Request-Headers: x-csrf-token\r\n\r\n");
181    /// assert_eq!(r.header("access-control-max-age"), Some(&b"600"[..]));
182    /// # Ok::<(), web_net::BuildError>(())
183    /// ```
184    #[must_use]
185    pub fn max_age(mut self, seconds: u32) -> Self {
186        self.max_age = seconds;
187        self
188    }
189
190    /// Why the policy cannot be used, if it cannot.
191    pub(crate) fn problem(&self) -> Option<&'static str> {
192        if self.any && self.credentials {
193            return Some("a CORS policy cannot allow any origin and credentials together");
194        }
195        if !self.any && self.origins.is_empty() {
196            return Some("a CORS policy needs at least one origin");
197        }
198        let token = |s: &str| {
199            !s.is_empty()
200                && s.bytes()
201                    .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b))
202        };
203        if !self.headers.iter().chain(&self.expose).all(|h| token(h)) {
204            return Some("a CORS field name is not a token");
205        }
206        let origin_ok = |o: &str| {
207            (o.starts_with("https://") || o.starts_with("http://"))
208                && !o.ends_with('/')
209                && o.bytes().all(|b| (0x21..0x7f).contains(&b))
210        };
211        if !self.origins.iter().all(|o| origin_ok(o)) {
212            return Some("a CORS origin must be scheme://host[:port] with no path");
213        }
214        None
215    }
216
217    /// The `Access-Control-Allow-Origin` value for `origin`, if allowed.
218    pub(crate) fn allow<'o>(&self, origin: &'o [u8]) -> Option<&'o [u8]> {
219        if self.any {
220            return Some(b"*");
221        }
222        let o = core::str::from_utf8(origin).ok()?;
223        self.origins
224            .iter()
225            .any(|a| a.eq_ignore_ascii_case(o))
226            .then_some(origin)
227    }
228
229    /// The fields on a reply to an allowed origin.
230    pub(crate) fn actual(&self, origin: Option<&[u8]>, reply: &mut HttpReply) {
231        reply.header("vary", "origin");
232        let Some(allowed) = origin.and_then(|o| self.allow(o)) else {
233            return;
234        };
235        reply.header("access-control-allow-origin", allowed);
236        if self.credentials {
237            reply.header("access-control-allow-credentials", "true");
238        }
239        if !self.expose.is_empty() {
240            reply.header("access-control-expose-headers", self.expose.join(", "));
241        }
242    }
243
244    /// Answers a preflight. `methods` are the path's methods. Returns
245    /// whether the preflight was allowed.
246    pub(crate) fn preflight(
247        &self,
248        origin: &[u8],
249        method: Method,
250        requested_headers: Option<&[u8]>,
251        methods: MethodSet,
252        reply: &mut HttpReply,
253    ) -> bool {
254        reply.status(204).header("vary", "origin");
255        let Some(allowed) = self.allow(origin) else {
256            return false;
257        };
258        if !methods.contains(method) {
259            return false;
260        }
261        if let Some(req) = requested_headers {
262            let Ok(req) = core::str::from_utf8(req) else {
263                return false;
264            };
265            for name in req.split(',').map(str::trim).filter(|n| !n.is_empty()) {
266                if !self.headers.iter().any(|h| h.eq_ignore_ascii_case(name)) {
267                    return false;
268                }
269            }
270        }
271        let mut allow = Vec::new();
272        methods.write_allow(&mut allow);
273        reply
274            .header("access-control-allow-origin", allowed)
275            .header("access-control-allow-methods", allow);
276        if !self.headers.is_empty() {
277            reply.header("access-control-allow-headers", self.headers.join(", "));
278        }
279        if self.credentials {
280            reply.header("access-control-allow-credentials", "true");
281        }
282        let mut age = String::new();
283        let _ = write!(age, "{}", self.max_age);
284        reply.header("access-control-max-age", age);
285        true
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_problems() {
295        assert!(Cors::new().problem().is_some());
296        assert!(Cors::new().any_origin().credentials().problem().is_some());
297        assert!(Cors::new().origin("https://a.example/").problem().is_some());
298        assert!(Cors::new().origin("a.example").problem().is_some());
299        assert!(
300            Cors::new()
301                .origin("https://a.example")
302                .headers(&["bad name"])
303                .problem()
304                .is_some()
305        );
306        assert!(
307            Cors::new()
308                .origin("https://a.example:8443")
309                .problem()
310                .is_none()
311        );
312        assert!(Cors::new().any_origin().problem().is_none());
313    }
314
315    #[test]
316    fn test_allow() {
317        let c = Cors::new().origin("https://a.example");
318        assert_eq!(
319            c.allow(b"https://A.example"),
320            Some(&b"https://A.example"[..])
321        );
322        assert_eq!(c.allow(b"https://b.example"), None);
323        assert_eq!(c.allow(b"null"), None);
324        assert_eq!(
325            Cors::new().any_origin().allow(b"https://x"),
326            Some(&b"*"[..])
327        );
328    }
329}