1use std::fmt::Write as _;
15
16use serve_net::http_net::MethodSet;
17use serve_net::{HttpReply, Method};
18
19#[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 #[must_use]
44 pub fn new() -> Self {
45 Self {
46 max_age: 300,
47 ..Self::default()
48 }
49 }
50
51 #[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 #[must_use]
62 pub fn any_origin(mut self) -> Self {
63 self.any = true;
64 self
65 }
66
67 #[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 #[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 #[must_use]
92 pub fn credentials(mut self) -> Self {
93 self.credentials = true;
94 self
95 }
96
97 #[must_use]
99 pub fn max_age(mut self, seconds: u32) -> Self {
100 self.max_age = seconds;
101 self
102 }
103
104 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 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 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 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}