Skip to main content

sofie_http/
cors.rs

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/// Allows nothing until told; methods default to `GET`, `HEAD` and `POST`.
24#[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    /// Exactly `scheme://host[:port]`, or `*` for any origin without credentials.
41    #[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    /// Cookies ride along, so a write from an allowed origin passes the CSRF check with its session.
68    #[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/// `area=origin` pairs separated by commas, an area named once per origin.
82#[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    /// `sofie::run` reads them from `SOFIE_CORS_ORIGINS`; they add to each area's `App::cors` origins.
106    #[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    /// A write carrying the session cookie needs `credentials()`, the area's consent to it.
195    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    /// An answer that differs by origin must not be served from a cache to another one.
263    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(router: &Router, asked: Asked<'_>) -> Option<Response> {
271    let request = asked.request;
272    if request.method != Method::OPTIONS || !request.headers.contains_key(ORIGIN) {
273        return None;
274    }
275    let requested = request.headers.get(ACCESS_CONTROL_REQUEST_METHOD)?;
276    let method = Method::from_bytes(requested.as_bytes()).ok()?;
277    let Lookup::Found(target, _) = router.find(&method, request.uri.path()) else {
278        return None;
279    };
280    let cors = target.admission.area.cors.as_ref()?;
281    Some(cors.preflight(&method, asked))
282}
283
284pub(crate) fn answered(
285    target: Option<&Target>,
286    request: &Parts,
287    mut response: Response,
288) -> Response {
289    let Some(cors) = target.and_then(|target| target.admission.area.cors.as_ref()) else {
290        return response;
291    };
292    let headers = response.headers_mut();
293    let Some(origin) = cors.allowed_origin(&request.headers) else {
294        cors.vary(headers);
295        return response;
296    };
297    cors.allow(origin, headers);
298    if let Some(exposed) = &cors.exposed {
299        headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, exposed.clone());
300    }
301    response
302}
303
304/// Browsers send an origin lowercased and without a path, so it is compared byte for byte.
305fn exact_origin(text: &str) -> Result<HeaderValue> {
306    let invalid = || {
307        Error::internal_message(format!(
308            "`{text}` is not an origin such as https://app.example.com"
309        ))
310    };
311    let authority = text
312        .strip_prefix("https://")
313        .or_else(|| text.strip_prefix("http://"))
314        .ok_or_else(invalid)?;
315    let valid = !authority.is_empty()
316        && !authority.contains(['@', '/', '?', '#'])
317        && authority.parse::<http::uri::Authority>().is_ok();
318    if !valid {
319        return Err(invalid());
320    }
321    HeaderValue::try_from(text.to_ascii_lowercase()).map_err(|_| invalid())
322}
323
324fn header_names(names: &[String]) -> Result<Vec<HeaderName>> {
325    names
326        .iter()
327        .map(|name| {
328            HeaderName::try_from(name.as_str())
329                .map_err(|error| Error::internal(format!("header name `{name}`"), error))
330        })
331        .collect()
332}
333
334fn joined<'n>(names: impl Iterator<Item = &'n str>) -> Result<Option<HeaderValue>> {
335    let joined = names.collect::<Vec<_>>().join(", ");
336    if joined.is_empty() {
337        return Ok(None);
338    }
339    HeaderValue::try_from(joined)
340        .map(Some)
341        .map_err(|error| Error::internal("joining a CORS header", error))
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn an_origin_is_exactly_a_scheme_and_a_host() {
350        assert_eq!(
351            exact_origin("https://App.example:8443").unwrap(),
352            "https://app.example:8443"
353        );
354        for invalid in [
355            "app.example",
356            "https://",
357            "https://app.example/",
358            "https://app.example/path",
359            "https://user@app.example",
360            "ftp://app.example",
361            "null",
362            "*",
363        ] {
364            assert!(exact_origin(invalid).is_err(), "{invalid}");
365        }
366    }
367
368    #[test]
369    fn any_origin_is_alone_and_without_credentials() {
370        assert!(AreaCors::new(Cors::new().origin("*"), Vec::new()).is_ok());
371        for cors in [
372            Cors::new().origin("*").credentials(),
373            Cors::new().origin("*").origin("https://app.example"),
374        ] {
375            assert!(AreaCors::new(cors, Vec::new()).is_err());
376        }
377        let from_settings = vec![exact_origin("https://app.example").unwrap()];
378        assert!(AreaCors::new(Cors::new().origin("*"), from_settings).is_err());
379    }
380
381    #[test]
382    fn settings_name_an_origin_per_area_and_refuse_a_malformed_one() {
383        let origins = CorsOrigins::parse(
384            "api=https://a.example, api=http://b.example:8080, web=https://c.example",
385        )
386        .unwrap();
387        assert_eq!(origins.origins.len(), 3);
388        assert_eq!(origins.origins[1].0, "api");
389
390        assert_eq!(CorsOrigins::parse("").unwrap().origins.len(), 0);
391        assert!(CorsOrigins::parse("https://a.example").is_err());
392        assert!(CorsOrigins::parse("api=https://a.example/").is_err());
393    }
394}