1use std::time::Duration;
2
3use bytes::Bytes;
4use http::header::{
5 ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
6 ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE,
7 ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, ORIGIN, VARY,
8};
9use http::request::Parts;
10use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
11use sofie_core::{Error, Result};
12
13use crate::App;
14use crate::answer::{self, Response};
15use crate::cookie::CookieNames;
16use crate::pipeline::Asked;
17use crate::router::{Lookup, Router};
18use crate::target::Target;
19
20const ANY_ORIGIN: &str = "*";
21const SETTING: &str = "SOFIE_CORS_ORIGINS";
22
23#[derive(Clone, Debug, Default)]
25pub struct Cors {
26 origins: Vec<String>,
27 methods: Vec<Method>,
28 headers: Vec<String>,
29 exposed: Vec<String>,
30 credentials: bool,
31 max_age: Option<Duration>,
32}
33
34impl Cors {
35 #[must_use]
36 pub fn new() -> Self {
37 Self::default()
38 }
39
40 #[must_use]
42 pub fn origin(mut self, origin: &str) -> Self {
43 self.origins.push(origin.to_owned());
44 self
45 }
46
47 #[must_use]
48 pub fn methods(mut self, methods: &[Method]) -> Self {
49 self.methods.extend_from_slice(methods);
50 self
51 }
52
53 #[must_use]
54 pub fn headers(mut self, names: &[&str]) -> Self {
55 self.headers
56 .extend(names.iter().map(|name| (*name).to_owned()));
57 self
58 }
59
60 #[must_use]
61 pub fn exposed_headers(mut self, names: &[&str]) -> Self {
62 self.exposed
63 .extend(names.iter().map(|name| (*name).to_owned()));
64 self
65 }
66
67 #[must_use]
69 pub fn credentials(mut self) -> Self {
70 self.credentials = true;
71 self
72 }
73
74 #[must_use]
75 pub fn max_age(mut self, age: Duration) -> Self {
76 self.max_age = Some(age);
77 self
78 }
79}
80
81#[derive(Clone, Debug, Default)]
83pub struct CorsOrigins {
84 origins: Vec<(String, HeaderValue)>,
85}
86
87impl CorsOrigins {
88 pub fn parse(text: &str) -> Result<Self> {
89 let origins = text
90 .split(',')
91 .map(str::trim)
92 .filter(|entry| !entry.is_empty())
93 .map(|entry| {
94 let (area, origin) = entry.split_once('=').ok_or_else(|| {
95 Error::internal_message(format!("`{entry}` is not `area=origin`"))
96 })?;
97 Ok((area.trim().to_owned(), exact_origin(origin.trim())?))
98 })
99 .collect::<Result<_>>()?;
100 Ok(Self { origins })
101 }
102}
103
104impl crate::Serving {
105 #[must_use]
107 pub fn cors_origins(mut self, origins: CorsOrigins) -> Self {
108 self.cors_origins = origins;
109 self
110 }
111}
112
113pub(crate) struct AreaCors {
114 origins: Origins,
115 methods: Vec<Method>,
116 allow_methods: HeaderValue,
117 headers: Vec<HeaderName>,
118 allow_headers: Option<HeaderValue>,
119 exposed: Option<HeaderValue>,
120 credentials: bool,
121 max_age: Option<HeaderValue>,
122}
123
124enum Origins {
125 Any,
126 Listed(Vec<HeaderValue>),
127}
128
129pub(crate) fn prepare(app: &mut App) -> Result<()> {
130 let mut configured = std::mem::take(&mut app.serving.cors_origins).origins;
131 for (area, cors) in std::mem::take(&mut app.cors) {
132 let from_settings = configured
133 .extract_if(.., |(named, _)| named == area)
134 .map(|(_, origin)| origin)
135 .collect();
136 let prepared = AreaCors::new(cors, from_settings)
137 .map_err(|error| Error::internal(format!("the CORS policy of area `{area}`"), error))?;
138 app.areas.entry(area).or_default().cors = Some(prepared);
139 }
140 match configured.first() {
141 Some((area, _)) => Err(Error::internal_message(format!(
142 "{SETTING} names area `{area}`, which has no `App::cors` policy"
143 ))),
144 None => Ok(()),
145 }
146}
147
148impl AreaCors {
149 fn new(cors: Cors, from_settings: Vec<HeaderValue>) -> Result<Self> {
150 let origins = if cors.origins.iter().any(|origin| origin == ANY_ORIGIN) {
151 if cors.credentials || cors.origins.len() > 1 || !from_settings.is_empty() {
152 return Err(Error::internal_message(
153 "`*` must be the only origin, and allows no credentials",
154 ));
155 }
156 Origins::Any
157 } else {
158 let mut listed: Vec<HeaderValue> = cors
159 .origins
160 .iter()
161 .map(|origin| exact_origin(origin))
162 .collect::<Result<_>>()?;
163 listed.extend(from_settings);
164 Origins::Listed(listed)
165 };
166 let methods = if cors.methods.is_empty() {
167 vec![Method::GET, Method::HEAD, Method::POST]
168 } else {
169 cors.methods
170 };
171 let headers = header_names(&cors.headers)?;
172 let exposed = header_names(&cors.exposed)?;
173 Ok(Self {
174 origins,
175 allow_methods: joined(methods.iter().map(Method::as_str))?
176 .ok_or_else(|| Error::internal_message("no methods"))?,
177 methods,
178 allow_headers: joined(headers.iter().map(HeaderName::as_str))?,
179 headers,
180 exposed: joined(exposed.iter().map(HeaderName::as_str))?,
181 credentials: cors.credentials,
182 max_age: cors.max_age.map(|age| HeaderValue::from(age.as_secs())),
183 })
184 }
185
186 fn allowed_origin(&self, request: &HeaderMap) -> Option<HeaderValue> {
187 let origin = request.get(ORIGIN)?;
188 match &self.origins {
189 Origins::Any => Some(HeaderValue::from_static(ANY_ORIGIN)),
190 Origins::Listed(listed) => listed.contains(origin).then(|| origin.clone()),
191 }
192 }
193
194 pub(crate) fn admits_write(&self, request: &HeaderMap, cookies: &CookieNames) -> bool {
196 self.allowed_origin(request).is_some()
197 && (self.credentials || cookies.session_value(request).is_none())
198 }
199
200 fn allows_requested_headers(&self, request: &HeaderMap) -> bool {
201 request
202 .get_all(ACCESS_CONTROL_REQUEST_HEADERS)
203 .iter()
204 .all(|value| {
205 value.to_str().is_ok_and(|names| {
206 names
207 .split(',')
208 .map(str::trim)
209 .filter(|name| !name.is_empty())
210 .all(|name| {
211 self.headers
212 .iter()
213 .any(|allowed| allowed.as_str().eq_ignore_ascii_case(name))
214 })
215 })
216 })
217 }
218
219 fn preflight(&self, method: &Method, asked: Asked<'_>) -> Response {
220 let request = &asked.request.headers;
221 let origin = self
222 .allowed_origin(request)
223 .filter(|_| self.methods.contains(method) && self.allows_requested_headers(request));
224 let Some(origin) = origin else {
225 tracing::warn!(
226 http.request.method = method.as_str(),
227 url.path = asked.request.uri.path(),
228 "refused a cross-origin preflight"
229 );
230 let mut refused = answer::refusal(
231 StatusCode::FORBIDDEN,
232 "This origin may not call this route.",
233 asked.caller,
234 );
235 self.vary(refused.headers_mut());
236 return refused;
237 };
238 let mut response = answer::response(StatusCode::NO_CONTENT, None, Bytes::new());
239 let headers = response.headers_mut();
240 self.allow(origin, headers);
241 headers.insert(ACCESS_CONTROL_ALLOW_METHODS, self.allow_methods.clone());
242 if let Some(allowed) = &self.allow_headers {
243 headers.insert(ACCESS_CONTROL_ALLOW_HEADERS, allowed.clone());
244 }
245 if let Some(age) = &self.max_age {
246 headers.insert(ACCESS_CONTROL_MAX_AGE, age.clone());
247 }
248 response
249 }
250
251 fn allow(&self, origin: HeaderValue, headers: &mut HeaderMap) {
252 self.vary(headers);
253 headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
254 if self.credentials {
255 headers.insert(
256 ACCESS_CONTROL_ALLOW_CREDENTIALS,
257 HeaderValue::from_static("true"),
258 );
259 }
260 }
261
262 fn vary(&self, headers: &mut HeaderMap) {
264 if matches!(self.origins, Origins::Listed(_)) {
265 headers.append(VARY, HeaderValue::from_static("Origin"));
266 }
267 }
268}
269
270pub(crate) fn preflight(
272 router: &Router,
273 asked: Asked<'_>,
274 hidden: impl Fn(&Target) -> bool,
275) -> Option<Response> {
276 let request = asked.request;
277 if request.method != Method::OPTIONS || !request.headers.contains_key(ORIGIN) {
278 return None;
279 }
280 let requested = request.headers.get(ACCESS_CONTROL_REQUEST_METHOD)?;
281 let method = Method::from_bytes(requested.as_bytes()).ok()?;
282 let Lookup::Found(target, _) = router.find(&method, request.uri.path()) else {
283 return None;
284 };
285 if hidden(target) {
286 return None;
287 }
288 let cors = target.admission.area.cors.as_ref()?;
289 Some(cors.preflight(&method, asked))
290}
291
292pub(crate) fn answered(
293 target: Option<&Target>,
294 request: &Parts,
295 mut response: Response,
296) -> Response {
297 let Some(cors) = target.and_then(|target| target.admission.area.cors.as_ref()) else {
298 return response;
299 };
300 let headers = response.headers_mut();
301 let Some(origin) = cors.allowed_origin(&request.headers) else {
302 cors.vary(headers);
303 return response;
304 };
305 cors.allow(origin, headers);
306 if let Some(exposed) = &cors.exposed {
307 headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, exposed.clone());
308 }
309 response
310}
311
312fn exact_origin(text: &str) -> Result<HeaderValue> {
314 let invalid = || {
315 Error::internal_message(format!(
316 "`{text}` is not an origin such as https://app.example.com"
317 ))
318 };
319 let authority = text
320 .strip_prefix("https://")
321 .or_else(|| text.strip_prefix("http://"))
322 .ok_or_else(invalid)?;
323 let valid = !authority.is_empty()
324 && !authority.contains(['@', '/', '?', '#'])
325 && authority.parse::<http::uri::Authority>().is_ok();
326 if !valid {
327 return Err(invalid());
328 }
329 HeaderValue::try_from(text.to_ascii_lowercase()).map_err(|_| invalid())
330}
331
332fn header_names(names: &[String]) -> Result<Vec<HeaderName>> {
333 names
334 .iter()
335 .map(|name| {
336 HeaderName::try_from(name.as_str())
337 .map_err(|error| Error::internal(format!("header name `{name}`"), error))
338 })
339 .collect()
340}
341
342fn joined<'n>(names: impl Iterator<Item = &'n str>) -> Result<Option<HeaderValue>> {
343 let joined = names.collect::<Vec<_>>().join(", ");
344 if joined.is_empty() {
345 return Ok(None);
346 }
347 HeaderValue::try_from(joined)
348 .map(Some)
349 .map_err(|error| Error::internal("joining a CORS header", error))
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn an_origin_is_exactly_a_scheme_and_a_host() {
358 assert_eq!(
359 exact_origin("https://App.example:8443").unwrap(),
360 "https://app.example:8443"
361 );
362 for invalid in [
363 "app.example",
364 "https://",
365 "https://app.example/",
366 "https://app.example/path",
367 "https://user@app.example",
368 "ftp://app.example",
369 "null",
370 "*",
371 ] {
372 assert!(exact_origin(invalid).is_err(), "{invalid}");
373 }
374 }
375
376 #[test]
377 fn any_origin_is_alone_and_without_credentials() {
378 assert!(AreaCors::new(Cors::new().origin("*"), Vec::new()).is_ok());
379 for cors in [
380 Cors::new().origin("*").credentials(),
381 Cors::new().origin("*").origin("https://app.example"),
382 ] {
383 assert!(AreaCors::new(cors, Vec::new()).is_err());
384 }
385 let from_settings = vec![exact_origin("https://app.example").unwrap()];
386 assert!(AreaCors::new(Cors::new().origin("*"), from_settings).is_err());
387 }
388
389 #[test]
390 fn settings_name_an_origin_per_area_and_refuse_a_malformed_one() {
391 let origins = CorsOrigins::parse(
392 "api=https://a.example, api=http://b.example:8080, web=https://c.example",
393 )
394 .unwrap();
395 assert_eq!(origins.origins.len(), 3);
396 assert_eq!(origins.origins[1].0, "api");
397
398 assert_eq!(CorsOrigins::parse("").unwrap().origins.len(), 0);
399 assert!(CorsOrigins::parse("https://a.example").is_err());
400 assert!(CorsOrigins::parse("api=https://a.example/").is_err());
401 }
402}