Skip to main content

static_web_server/
cors.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// This file is part of Static Web Server.
3// See https://static-web-server.net/ for more information
4// Copyright (C) 2019-present Jose Quintana <joseluisq.net>
5
6//! CORS module to handle incoming requests.
7//!
8
9// Part of the file is borrowed from https://github.com/seanmonstar/warp/blob/master/src/filters/cors.rs
10
11use headers::{
12    AccessControlAllowHeaders, AccessControlAllowMethods, AccessControlExposeHeaders, HeaderMap,
13    HeaderMapExt, HeaderName, HeaderValue, Origin,
14};
15use http::header;
16use hyper::{Request, Response, StatusCode};
17use std::collections::HashSet;
18
19use crate::body::Body;
20use crate::{Error, error_page, handler::RequestHandlerOpts};
21
22/// It defines CORS instance.
23#[derive(Clone, Debug)]
24pub struct Cors {
25    allowed_headers: HashSet<HeaderName>,
26    exposed_headers: HashSet<HeaderName>,
27    max_age: Option<u64>,
28    allowed_methods: HashSet<http::Method>,
29    origins: Option<HashSet<HeaderValue>>,
30}
31
32/// It builds a new CORS instance.
33pub fn new(
34    origins_str: &str,
35    allow_headers_str: &str,
36    expose_headers_str: &str,
37) -> Option<Configured> {
38    let cors = Cors::new();
39    let cors = if origins_str.is_empty() {
40        None
41    } else {
42        let [allow_headers_vec, expose_headers_vec] =
43            [allow_headers_str, expose_headers_str].map(|s| {
44                if s.is_empty() {
45                    vec!["origin", "content-type"]
46                } else {
47                    s.split(',').map(|s| s.trim()).collect::<Vec<_>>()
48                }
49            });
50        // SECURITY/ROBUSTNESS: Reject malformed admin-supplied tokens with
51        // a structured `tracing::error!` instead of letting the builder
52        // panic at startup. This keeps SWS aligned with the rest of the
53        // codebase's error model and avoids an attacker-controlled abort
54        // surface if origin/header lists ever get fed from a less-trusted
55        // configuration source.
56        let allow_headers_vec = validate_header_names("cors.allow_headers", &allow_headers_vec);
57        let expose_headers_vec = validate_header_names("cors.expose_headers", &expose_headers_vec);
58        let [allow_headers_str, expose_headers_str] =
59            [&allow_headers_vec, &expose_headers_vec].map(|v| v.join(","));
60
61        let cors_res = if origins_str == "*" {
62            match cors
63                .allow_any_origin()
64                .allow_headers(allow_headers_vec)
65                .and_then(|cors| cors.expose_headers(expose_headers_vec))
66                .and_then(|cors| cors.allow_methods(["GET", "HEAD", "OPTIONS"]))
67            {
68                Ok(cors) => Some(cors),
69                Err(err) => {
70                    tracing::error!("cors: failed to build configuration: {err:?}");
71                    None
72                }
73            }
74        } else {
75            let hosts = origins_str
76                .split(',')
77                .map(|s| s.trim())
78                .filter(|s| validate_origin_str("cors.allow_origins", s))
79                .collect::<Vec<_>>();
80            if hosts.is_empty() {
81                tracing::error!(
82                    "cors: no valid origins found in `{origins_str}`; CORS will be disabled"
83                );
84                None
85            } else {
86                match cors
87                    .allow_origins(hosts)
88                    .and_then(|cors| cors.allow_headers(allow_headers_vec))
89                    .and_then(|cors| cors.expose_headers(expose_headers_vec))
90                    .and_then(|cors| cors.allow_methods(["GET", "HEAD", "OPTIONS"]))
91                {
92                    Ok(cors) => Some(cors),
93                    Err(err) => {
94                        tracing::error!("cors: failed to build configuration: {err:?}");
95                        None
96                    }
97                }
98            }
99        };
100
101        if cors_res.is_some() {
102            tracing::info!(
103                "cors enabled=true, allow_methods=[GET,HEAD,OPTIONS], allow_origins={}, allow_headers=[{}], expose_headers=[{}]",
104                origins_str,
105                allow_headers_str,
106                expose_headers_str,
107            );
108        }
109        cors_res
110    };
111
112    Cors::build(cors)
113}
114
115/// Filter out entries that would cause `Cors::allow_headers` /
116/// `expose_headers` to panic, logging each rejected value. Returns the
117/// surviving entries.
118fn validate_header_names<'a>(field: &str, names: &[&'a str]) -> Vec<&'a str> {
119    names
120        .iter()
121        .copied()
122        .filter(|h| {
123            if HeaderName::try_from(*h).is_ok() {
124                true
125            } else {
126                tracing::error!("{field}: ignoring invalid HTTP header name `{h}`");
127                false
128            }
129        })
130        .collect()
131}
132
133/// Verifies that an origin string looks like `scheme://authority` so
134/// `IntoOrigin::into_origin` will not panic on it.
135#[doc(hidden)]
136pub fn validate_origin_str(field: &str, origin: &str) -> bool {
137    let mut parts = origin.splitn(2, "://");
138    let scheme = parts.next();
139    let rest = parts.next();
140    match (scheme, rest) {
141        (Some(s), Some(r)) if !s.is_empty() && !r.is_empty() => {
142            if Origin::try_from_parts(s, r, None).is_ok() {
143                true
144            } else {
145                tracing::error!("{field}: ignoring invalid origin `{origin}`");
146                false
147            }
148        }
149        _ => {
150            tracing::error!(
151                "{field}: ignoring origin `{origin}` (expected `scheme://host[:port]`)"
152            );
153            false
154        }
155    }
156}
157
158impl Cors {
159    /// Creates a new Cors instance.
160    pub fn new() -> Self {
161        Self {
162            origins: None,
163            allowed_headers: HashSet::new(),
164            exposed_headers: HashSet::new(),
165            allowed_methods: HashSet::new(),
166            max_age: None,
167        }
168    }
169
170    /// Adds multiple methods to the existing list of allowed request methods.
171    pub fn allow_methods<I>(mut self, methods: I) -> Result<Self, Error>
172    where
173        I: IntoIterator,
174        http::Method: TryFrom<I::Item>,
175    {
176        for method in methods {
177            let method =
178                http::Method::try_from(method).map_err(|_| Error::msg("cors: illegal method"))?;
179            self.allowed_methods.insert(method);
180        }
181        Ok(self)
182    }
183
184    /// Sets that *any* `Origin` header is allowed.
185    ///
186    /// # Warning
187    ///
188    /// This can allow websites you didn't intend to access this resource,
189    /// it is usually better to set an explicit list.
190    pub fn allow_any_origin(mut self) -> Self {
191        self.origins = None;
192        self
193    }
194
195    /// Add multiple origins to the existing list of allowed `Origin`s.
196    pub fn allow_origins<I>(mut self, origins: I) -> Result<Self, Error>
197    where
198        I: IntoIterator,
199        I::Item: IntoOrigin,
200    {
201        let allowed = self.origins.get_or_insert_with(HashSet::new);
202        for origin in origins {
203            let origin = origin.into_origin()?;
204            let value = HeaderValue::from_str(&origin.to_string())
205                .map_err(|err| Error::msg(format!("cors: invalid origin header value: {err}")))?;
206            allowed.insert(value);
207        }
208        Ok(self)
209    }
210
211    /// Adds multiple headers to the list of allowed request headers.
212    ///
213    /// **Note**: These should match the values the browser sends via `Access-Control-Request-Headers`, e.g.`content-type`.
214    ///
215    pub fn allow_headers<I>(mut self, headers: I) -> Result<Self, Error>
216    where
217        I: IntoIterator,
218        HeaderName: TryFrom<I::Item>,
219    {
220        for header in headers {
221            let header = HeaderName::try_from(header)
222                .map_err(|_| Error::msg("cors: illegal allow header"))?;
223            self.allowed_headers.insert(header);
224        }
225        Ok(self)
226    }
227
228    /// Adds multiple headers to the list of exposed request headers.
229    ///
230    /// **Note**: These should match the values the browser sends via `Access-Control-Request-Headers`, e.g.`content-type`.
231    ///
232    pub fn expose_headers<I>(mut self, headers: I) -> Result<Self, Error>
233    where
234        I: IntoIterator,
235        HeaderName: TryFrom<I::Item>,
236    {
237        for header in headers {
238            let header = HeaderName::try_from(header)
239                .map_err(|_| Error::msg("cors: illegal expose header"))?;
240            self.exposed_headers.insert(header);
241        }
242        Ok(self)
243    }
244
245    /// Builds the `Cors` wrapper from the configured settings.
246    pub fn build(cors: Option<Cors>) -> Option<Configured> {
247        cors.as_ref()?;
248        let cors = cors?;
249
250        let allowed_headers = cors.allowed_headers.iter().cloned().collect();
251        let exposed_headers = cors.exposed_headers.iter().cloned().collect();
252        let methods_header = cors.allowed_methods.iter().cloned().collect();
253
254        Some(Configured {
255            cors,
256            allowed_headers,
257            exposed_headers,
258            methods_header,
259        })
260    }
261}
262
263impl Default for Cors {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269#[derive(Clone, Debug)]
270/// CORS configured.
271pub struct Configured {
272    cors: Cors,
273    allowed_headers: AccessControlAllowHeaders,
274    exposed_headers: AccessControlExposeHeaders,
275    methods_header: AccessControlAllowMethods,
276}
277
278#[derive(Debug)]
279/// Validated CORS request.
280pub enum Validated {
281    /// Validated as preflight.
282    Preflight(HeaderValue),
283    /// Validated as simple.
284    Simple(HeaderValue),
285    /// Validated as not cors.
286    NotCors,
287}
288
289#[derive(Debug, Default)]
290/// Forbidden errors.
291pub enum Forbidden {
292    /// Forbidden error origin.
293    #[default]
294    Origin,
295    /// Forbidden error method.
296    Method,
297    /// Forbidden error header.
298    Header,
299}
300
301impl Configured {
302    /// Check for the incoming CORS request.
303    pub fn check_request(
304        &self,
305        method: &http::Method,
306        headers: &HeaderMap,
307    ) -> Result<(HeaderMap, Validated), Forbidden> {
308        match (headers.get(header::ORIGIN), method) {
309            (Some(origin), &http::Method::OPTIONS) => {
310                // OPTIONS requests are preflight CORS requests...
311
312                if !self.is_origin_allowed(origin) {
313                    return Err(Forbidden::Origin);
314                }
315
316                if let Some(req_method) = headers.get(header::ACCESS_CONTROL_REQUEST_METHOD) {
317                    if !self.is_method_allowed(req_method) {
318                        return Err(Forbidden::Method);
319                    }
320                } else {
321                    tracing::warn!(
322                        "cors: preflight request missing `access-control-request-method` header"
323                    );
324                    return Err(Forbidden::Method);
325                }
326
327                if let Some(req_headers) = headers.get(header::ACCESS_CONTROL_REQUEST_HEADERS) {
328                    let headers = match req_headers.to_str() {
329                        Ok(val) => val,
330                        Err(err) => {
331                            tracing::error!(
332                                "cors: error parsing header `access-control-request-headers` value: {:?}",
333                                err,
334                            );
335                            return Err(Forbidden::Header);
336                        }
337                    };
338
339                    for header in headers.split(',') {
340                        let h = header.trim();
341                        if !self.is_header_allowed(h) {
342                            tracing::error!(
343                                "cors: header `{}` is not allowed because is missing in `cors_allow_headers` server option",
344                                h
345                            );
346                            return Err(Forbidden::Header);
347                        }
348                    }
349                }
350
351                let mut headers = HeaderMap::new();
352                self.append_preflight_headers(&mut headers);
353                headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin.into());
354
355                Ok((headers, Validated::Preflight(origin.clone())))
356            }
357            (Some(origin), _) => {
358                // Any other method, simply check for a valid origin...
359                tracing::trace!("cors origin header: {:?}", origin);
360
361                if self.is_origin_allowed(origin) {
362                    let mut headers = HeaderMap::new();
363                    self.append_preflight_headers(&mut headers);
364                    headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin.into());
365
366                    Ok((headers, Validated::Simple(origin.clone())))
367                } else {
368                    Err(Forbidden::Origin)
369                }
370            }
371            _ => {
372                // No `ORIGIN` header means this isn't CORS!
373                Ok((HeaderMap::new(), Validated::NotCors))
374            }
375        }
376    }
377
378    fn is_method_allowed(&self, header: &HeaderValue) -> bool {
379        http::Method::from_bytes(header.as_bytes())
380            .map(|method| self.cors.allowed_methods.contains(&method))
381            .unwrap_or(false)
382    }
383
384    fn is_header_allowed(&self, header: &str) -> bool {
385        if header.is_empty() {
386            return false;
387        }
388        HeaderName::from_bytes(header.as_bytes())
389            .map(|header| self.cors.allowed_headers.contains(&header))
390            .unwrap_or(false)
391    }
392
393    fn is_origin_allowed(&self, origin: &HeaderValue) -> bool {
394        if origin.is_empty() {
395            return false;
396        }
397        if let Some(ref allowed) = self.cors.origins {
398            allowed.contains(origin)
399        } else {
400            true
401        }
402    }
403
404    fn append_preflight_headers(&self, headers: &mut HeaderMap) {
405        headers.typed_insert(self.allowed_headers.clone());
406        headers.typed_insert(self.exposed_headers.clone());
407        headers.typed_insert(self.methods_header.clone());
408
409        if let Some(max_age) = self.cors.max_age {
410            headers.insert(header::ACCESS_CONTROL_MAX_AGE, max_age.into());
411        }
412    }
413}
414
415/// Cast values into the origin header.
416pub trait IntoOrigin {
417    /// Cast actual value into an origin header.
418    fn into_origin(self) -> Result<Origin, Error>;
419}
420
421impl IntoOrigin for &str {
422    fn into_origin(self) -> Result<Origin, Error> {
423        let (scheme, rest) = self
424            .split_once("://")
425            .ok_or_else(|| Error::msg("cors::into_origin: expected `scheme://host[:port]`"))?;
426        Origin::try_from_parts(scheme, rest, None)
427            .map_err(|_| Error::msg("cors::into_origin: invalid Origin"))
428    }
429}
430
431/// Initializes CORS settings
432pub(crate) fn init(
433    cors_allow_origins: &str,
434    cors_allow_headers: &str,
435    cors_expose_headers: &str,
436    handler_opts: &mut RequestHandlerOpts,
437) {
438    handler_opts.cors = new(
439        cors_allow_origins.trim(),
440        cors_allow_headers.trim(),
441        cors_expose_headers.trim(),
442    );
443}
444
445/// Cached CORS headers stored in request extensions to avoid
446/// re-validating the request in `post_process`.
447#[derive(Clone)]
448pub(crate) struct CorsHeaders(pub(crate) HeaderMap);
449
450/// Rejects requests with wrong CORS headers
451pub(crate) fn pre_process<T>(
452    opts: &RequestHandlerOpts,
453    req: &mut Request<T>,
454) -> Option<Result<Response<Body>, Error>> {
455    let cors = opts.cors.as_ref()?;
456    match cors.check_request(req.method(), req.headers()) {
457        Ok((headers, state)) => {
458            tracing::debug!("cors state: {:?}", state);
459            // Stash validated headers for post_process to reuse
460            if !headers.is_empty() {
461                req.extensions_mut().insert(CorsHeaders(headers));
462            }
463            None
464        }
465        Err(err) => {
466            tracing::error!("cors error kind: {:?}", err);
467            Some(error_page::error_response(
468                req.uri(),
469                req.method(),
470                &StatusCode::FORBIDDEN,
471                &opts.page404,
472                &opts.page50x,
473            ))
474        }
475    }
476}
477
478/// Adds CORS headers to response
479pub(crate) fn post_process<T>(
480    opts: &RequestHandlerOpts,
481    req: &Request<T>,
482    mut resp: Response<Body>,
483) -> Result<Response<Body>, Error> {
484    if opts.cors.is_some()
485        && let Some(cors_headers) = req.extensions().get::<CorsHeaders>()
486    {
487        for (k, v) in cors_headers.0.iter() {
488            resp.headers_mut().insert(k, v.to_owned());
489        }
490        resp.headers_mut().insert(
491            http::header::VARY,
492            HeaderValue::from_name(http::header::ORIGIN),
493        );
494        resp.headers_mut().remove(http::header::ALLOW);
495    }
496    Ok(resp)
497}
498
499#[cfg(test)]
500mod tests {
501    use super::{Configured, Cors, post_process, pre_process};
502    use crate::body::Body;
503    use crate::{Error, handler::RequestHandlerOpts};
504    use hyper::{Request, Response, StatusCode};
505
506    fn make_request(method: &str, origin: &str) -> Request<Body> {
507        let mut builder = Request::builder();
508        if !origin.is_empty() {
509            builder = builder.header("Origin", origin);
510        }
511        builder
512            .method(method)
513            .uri("/")
514            .body(crate::body::empty())
515            .unwrap()
516    }
517
518    fn make_response() -> Response<Body> {
519        Response::builder().body(crate::body::empty()).unwrap()
520    }
521
522    fn make_cors_config() -> Option<Configured> {
523        let cors = Cors::new()
524            .allow_origins(vec!["https://example.com/"])
525            .unwrap()
526            .allow_headers(vec!["X-Allowed"])
527            .unwrap()
528            .allow_methods(vec!["GET", "HEAD"])
529            .unwrap();
530        Cors::build(Some(cors))
531    }
532
533    fn get_allowed_origin(resp: Response<Body>) -> Option<String> {
534        resp.headers()
535            .get("Access-Control-Allow-Origin")
536            .and_then(|v| v.to_str().ok())
537            .map(|s| s.to_owned())
538    }
539
540    fn is_403(result: Option<Result<Response<Body>, Error>>) -> bool {
541        if let Some(Ok(response)) = result {
542            response.status() == StatusCode::FORBIDDEN
543        } else {
544            false
545        }
546    }
547
548    #[test]
549    fn test_cors_disabled() -> Result<(), Error> {
550        let opts = RequestHandlerOpts {
551            cors: None,
552            ..Default::default()
553        };
554        let mut req = make_request("GET", "https://example.com/");
555
556        assert!(pre_process(&opts, &mut req).is_none());
557
558        let resp = post_process(&opts, &req, make_response())?;
559        assert_eq!(get_allowed_origin(resp), None);
560
561        Ok(())
562    }
563
564    #[test]
565    fn test_non_cors_request() -> Result<(), Error> {
566        let opts = RequestHandlerOpts {
567            cors: make_cors_config(),
568            ..Default::default()
569        };
570        let mut req = make_request("GET", "");
571
572        assert!(pre_process(&opts, &mut req).is_none());
573
574        let resp = post_process(&opts, &req, make_response())?;
575        assert_eq!(get_allowed_origin(resp), None);
576
577        Ok(())
578    }
579
580    #[test]
581    fn test_forbidden_request() {
582        let opts = RequestHandlerOpts {
583            cors: make_cors_config(),
584            ..Default::default()
585        };
586
587        assert!(is_403(pre_process(
588            &opts,
589            &mut make_request("GET", "https://example.info")
590        )));
591        assert!(is_403(pre_process(
592            &opts,
593            &mut make_request("OPTIONS", "https://example.com")
594        )));
595
596        let mut req = make_request("OPTIONS", "https://example.com");
597        req.headers_mut()
598            .insert("Access-Control-Request-Method", "POST".try_into().unwrap());
599        assert!(is_403(pre_process(&opts, &mut req)));
600
601        let mut req = make_request("OPTIONS", "https://example.com");
602        req.headers_mut()
603            .insert("Access-Control-Request-Method", "GET".try_into().unwrap());
604        req.headers_mut().insert(
605            "Access-Control-Request-Headers",
606            "X-Forbidden".try_into().unwrap(),
607        );
608        assert!(is_403(pre_process(&opts, &mut req)));
609    }
610
611    #[test]
612    fn test_allowed_request() -> Result<(), Error> {
613        let opts = RequestHandlerOpts {
614            cors: make_cors_config(),
615            ..Default::default()
616        };
617
618        let mut req = make_request("GET", "https://example.com");
619        assert!(pre_process(&opts, &mut req).is_none());
620
621        let resp = post_process(&opts, &req, make_response())?;
622        assert_eq!(get_allowed_origin(resp), Some("https://example.com".into()));
623
624        let mut req = make_request("GET", "https://example.com");
625        req.headers_mut()
626            .insert("Access-Control-Request-Method", "GET".try_into().unwrap());
627        req.headers_mut().insert(
628            "Access-Control-Request-Headers",
629            "X-Allowed".try_into().unwrap(),
630        );
631        assert!(pre_process(&opts, &mut req).is_none());
632
633        let resp = post_process(&opts, &req, make_response())?;
634        assert_eq!(get_allowed_origin(resp), Some("https://example.com".into()));
635
636        Ok(())
637    }
638
639    // Property-based regression tests for the CORS configuration
640    // validators. These functions exist precisely to keep panicking
641    // builder methods (`Cors::allow_origins` / `allow_headers`) away
642    // from arbitrary admin-supplied tokens, so the property to enforce
643    // is "never panic, and when we return `true` the builder must
644    // accept the value".
645    use super::{validate_header_names, validate_origin_str};
646    use headers::{HeaderName, Origin};
647    use proptest::prelude::*;
648
649    proptest! {
650        #![proptest_config(ProptestConfig {
651            cases: 256, ..ProptestConfig::default()
652        })]
653
654        /// `validate_origin_str` MUST be total: it must never panic for
655        /// any UTF-8 input. Additionally, when it returns `true`, the
656        /// downstream `Origin::try_from_parts(scheme, rest, None)` call
657        /// performed by `IntoOrigin for &str` MUST succeed.
658        #[test]
659        fn prop_validate_origin_str_never_panics(origin in "\\PC{0,128}") {
660            let ok = validate_origin_str("cors.allow_origins", &origin);
661            if ok {
662                let (scheme, rest) = origin.split_once("://").unwrap();
663                prop_assert!(
664                    Origin::try_from_parts(scheme, rest, None).is_ok(),
665                    "validator accepted `{origin}` but `Origin::try_from_parts` rejects it"
666                );
667            }
668        }
669
670        /// `validate_origin_str` rejects any string that does not match
671        /// the `scheme://rest` shape, regardless of payload.
672        #[test]
673        fn prop_validate_origin_str_rejects_missing_scheme(s in "[^/:\\s]{0,32}") {
674            prop_assert!(
675                !validate_origin_str("cors.allow_origins", &s),
676                "validator accepted `{s}` which has no `scheme://` separator"
677            );
678        }
679
680        /// `validate_header_names` MUST be total and may only retain
681        /// entries that `HeaderName::try_from` actually accepts.
682        #[test]
683        fn prop_validate_header_names_retains_only_valid(
684            names in proptest::collection::vec("\\PC{0,32}", 0..16),
685        ) {
686            let slice: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
687            let kept = validate_header_names("cors.allow_headers", &slice);
688            for h in &kept {
689                prop_assert!(
690                    HeaderName::try_from(*h).is_ok(),
691                    "validator kept invalid header name `{h}`"
692                );
693            }
694            // Filtering must be order-preserving and idempotent.
695            let kept2 = validate_header_names("cors.allow_headers", &kept);
696            prop_assert_eq!(kept, kept2);
697        }
698    }
699}