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}