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    #[must_use]
44    pub fn new() -> Self {
45        Self {
46            max_age: 300,
47            ..Self::default()
48        }
49    }
50
51    /// Allows an origin, exactly as browsers send it
52    /// (`https://app.example.com`, with a port when it has one).
53    #[must_use]
54    pub fn origin(mut self, origin: &str) -> Self {
55        self.origins.push(origin.to_ascii_lowercase().into());
56        self
57    }
58
59    /// Allows every origin (answered with `*`). Not with
60    /// [`credentials`](Self::credentials): the build refuses that pair.
61    #[must_use]
62    pub fn any_origin(mut self) -> Self {
63        self.any = true;
64        self
65    }
66
67    /// Request fields a cross-origin request may send beyond the simple ones
68    /// (`content-type`, `x-csrf-token`).
69    #[must_use]
70    pub fn headers(mut self, names: &[&str]) -> Self {
71        self.headers.extend(
72            names
73                .iter()
74                .map(|n| n.to_ascii_lowercase().into_boxed_str()),
75        );
76        self
77    }
78
79    /// Reply fields a cross-origin script may read beyond the simple ones.
80    #[must_use]
81    pub fn expose(mut self, names: &[&str]) -> Self {
82        self.expose.extend(
83            names
84                .iter()
85                .map(|n| n.to_ascii_lowercase().into_boxed_str()),
86        );
87        self
88    }
89
90    /// Cross-origin requests may carry cookies.
91    #[must_use]
92    pub fn credentials(mut self) -> Self {
93        self.credentials = true;
94        self
95    }
96
97    /// How long a browser may keep a preflight's answer (default 300 s).
98    #[must_use]
99    pub fn max_age(mut self, seconds: u32) -> Self {
100        self.max_age = seconds;
101        self
102    }
103
104    /// Why the policy cannot be used, if it cannot.
105    pub(crate) fn problem(&self) -> Option<&'static str> {
106        if self.any && self.credentials {
107            return Some("a CORS policy cannot allow any origin and credentials together");
108        }
109        if !self.any && self.origins.is_empty() {
110            return Some("a CORS policy needs at least one origin");
111        }
112        let token = |s: &str| {
113            !s.is_empty()
114                && s.bytes()
115                    .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b))
116        };
117        if !self.headers.iter().chain(&self.expose).all(|h| token(h)) {
118            return Some("a CORS field name is not a token");
119        }
120        let origin_ok = |o: &str| {
121            (o.starts_with("https://") || o.starts_with("http://"))
122                && !o.ends_with('/')
123                && o.bytes().all(|b| (0x21..0x7f).contains(&b))
124        };
125        if !self.origins.iter().all(|o| origin_ok(o)) {
126            return Some("a CORS origin must be scheme://host[:port] with no path");
127        }
128        None
129    }
130
131    /// The `Access-Control-Allow-Origin` value for `origin`, if allowed.
132    pub(crate) fn allow<'o>(&self, origin: &'o [u8]) -> Option<&'o [u8]> {
133        if self.any {
134            return Some(b"*");
135        }
136        let o = core::str::from_utf8(origin).ok()?;
137        self.origins
138            .iter()
139            .any(|a| a.eq_ignore_ascii_case(o))
140            .then_some(origin)
141    }
142
143    /// The fields on a reply to an allowed origin.
144    pub(crate) fn actual(&self, origin: Option<&[u8]>, reply: &mut HttpReply) {
145        reply.header("vary", "origin");
146        let Some(allowed) = origin.and_then(|o| self.allow(o)) else {
147            return;
148        };
149        reply.header("access-control-allow-origin", allowed);
150        if self.credentials {
151            reply.header("access-control-allow-credentials", "true");
152        }
153        if !self.expose.is_empty() {
154            reply.header("access-control-expose-headers", self.expose.join(", "));
155        }
156    }
157
158    /// Answers a preflight. `methods` are the path's methods. Returns
159    /// whether the preflight was allowed.
160    pub(crate) fn preflight(
161        &self,
162        origin: &[u8],
163        method: Method,
164        requested_headers: Option<&[u8]>,
165        methods: MethodSet,
166        reply: &mut HttpReply,
167    ) -> bool {
168        reply.status(204).header("vary", "origin");
169        let Some(allowed) = self.allow(origin) else {
170            return false;
171        };
172        if !methods.contains(method) {
173            return false;
174        }
175        if let Some(req) = requested_headers {
176            let Ok(req) = core::str::from_utf8(req) else {
177                return false;
178            };
179            for name in req.split(',').map(str::trim).filter(|n| !n.is_empty()) {
180                if !self.headers.iter().any(|h| h.eq_ignore_ascii_case(name)) {
181                    return false;
182                }
183            }
184        }
185        let mut allow = Vec::new();
186        methods.write_allow(&mut allow);
187        reply
188            .header("access-control-allow-origin", allowed)
189            .header("access-control-allow-methods", allow);
190        if !self.headers.is_empty() {
191            reply.header("access-control-allow-headers", self.headers.join(", "));
192        }
193        if self.credentials {
194            reply.header("access-control-allow-credentials", "true");
195        }
196        let mut age = String::new();
197        let _ = write!(age, "{}", self.max_age);
198        reply.header("access-control-max-age", age);
199        true
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_problems() {
209        assert!(Cors::new().problem().is_some());
210        assert!(Cors::new().any_origin().credentials().problem().is_some());
211        assert!(Cors::new().origin("https://a.example/").problem().is_some());
212        assert!(Cors::new().origin("a.example").problem().is_some());
213        assert!(
214            Cors::new()
215                .origin("https://a.example")
216                .headers(&["bad name"])
217                .problem()
218                .is_some()
219        );
220        assert!(
221            Cors::new()
222                .origin("https://a.example:8443")
223                .problem()
224                .is_none()
225        );
226        assert!(Cors::new().any_origin().problem().is_none());
227    }
228
229    #[test]
230    fn test_allow() {
231        let c = Cors::new().origin("https://a.example");
232        assert_eq!(
233            c.allow(b"https://A.example"),
234            Some(&b"https://A.example"[..])
235        );
236        assert_eq!(c.allow(b"https://b.example"), None);
237        assert_eq!(c.allow(b"null"), None);
238        assert_eq!(
239            Cors::new().any_origin().allow(b"https://x"),
240            Some(&b"*"[..])
241        );
242    }
243}