index : static-web-server.git

ascending towards madness

author Nelson Chen <crazysim@gmail.com> 2022-10-03 6:07:03.0 +00:00:00
committer GitHub <noreply@github.com> 2022-10-03 6:07:03.0 +00:00:00
commit
f369c80b6854f1088cea24d9de8759d0f1ab35b3 [patch]
tree
67af8153ce2162eca8fa92cc980c72727ef560a2
parent
cce7a8501bafb90f644c571a2207b06a0184ac28
download
f369c80b6854f1088cea24d9de8759d0f1ab35b3.tar.gz

CORS expose headers option (#144)

* Small typo fix of commas

* Also expose if cors header is allowed

* WIP: add cors support to cors option

* Add rough support in code for expose-headers

* Add cors expose option to man page template

* Fix tests to handle expose cors

* Add doc updates for SERVER_CORS_EXPOSE_HEADERS

Diff

 docs/content/configuration/command-line-arguments.md |  4 +-
 docs/content/configuration/environment-variables.md  |  3 +-
 docs/content/features/cors.md                        | 20 +++++++-
 docs/man/static-web-server.1.rst                     |  7 +-
 src/cors.rs                                          | 63 ++++++++++++++++-----
 src/server.rs                                        |  1 +-
 src/settings/cli.rs                                  | 12 +++-
 src/settings/file.rs                                 |  1 +-
 src/settings/mod.rs                                  |  5 ++-
 tests/cors.rs                                        | 18 +++---
 10 files changed, 108 insertions(+), 26 deletions(-)

diff --git a/docs/content/configuration/command-line-arguments.md b/docs/content/configuration/command-line-arguments.md
index 30f8cf2..d1f40a7 100644
--- a/docs/content/configuration/command-line-arguments.md
+++ b/docs/content/configuration/command-line-arguments.md
@@ -45,6 +45,10 @@ OPTIONS:
    -c, --cors-allow-origins <cors-allow-origins>
            Specify an optional CORS list of allowed origin hosts separated by comas. Host ports or protocols aren't
            being checked. Use an asterisk (*) to allow any host [env: SERVER_CORS_ALLOW_ORIGINS=]  [default: ]
        --cors-expose-headers <cors-expose-headers>
            Specify an optional CORS list of exposed headers separated by commas. Default "origin, content-type". It
            requires `--cors-expose-origins` to be used along with [env: SERVER_CORS_EXPOSE_HEADERS=]  [default: origin,
            content-type]
    -z, --directory-listing <directory-listing>
            Enable directory listing for all requests ending with the slash character (‘/’) [env:
            SERVER_DIRECTORY_LISTING=]  [default: false]
diff --git a/docs/content/configuration/environment-variables.md b/docs/content/configuration/environment-variables.md
index 7ee5bdc..58895b4 100644
--- a/docs/content/configuration/environment-variables.md
+++ b/docs/content/configuration/environment-variables.md
@@ -57,6 +57,9 @@ Specify an optional CORS list of allowed origin hosts separated by commas. Host 
### SERVER_CORS_ALLOW_HEADERS
Specify an optional CORS list of allowed HTTP headers separated by commas. It requires `SERVER_CORS_ALLOW_ORIGINS` to be used along with. Default `origin, content-type`.

### SERVER_CORS_EXPOSE_HEADERS
Specify an optional CORS list of exposed HTTP headers separated by commas. It requires `SERVER_CORS_ALLOW_ORIGINS` to be used along with. Default `origin, content-type`.

### SERVER_COMPRESSION
`Gzip`, `Deflate` or `Brotli` compression on demand determined by the `Accept-Encoding` header and applied to text-based web file types only. See [ad-hoc mime-type list]https://github.com/joseluisq/static-web-server/blob/master/src/compression.rs#L20. Default `true` (enabled).

diff --git a/docs/content/features/cors.md b/docs/content/features/cors.md
index 0dc07a8..378194f 100644
--- a/docs/content/features/cors.md
+++ b/docs/content/features/cors.md
@@ -38,3 +38,23 @@ static-web-server \
    --cors-allow-origins "https://domain.com"
    --cors-allow-headers "origin, content-type, x-requested-with"
```

## Exposed headers

The server also supports a list of [CORS exposed headers to scripts]https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers separated by commas.

This feature depends on `--cors-allow-origins` to be used along with this feature. It can be controlled by the string `--cors-expose-headers` option or the equivalent [SERVER_CORS_EXPOSE_HEADERS]./../configuration/environment-variables.md#server_cors_expose_headers env.

!!! info "Tips"
    - The default exposed headers value is `origin, content-type`.
    - The server also supports [preflight requests](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) via the `OPTIONS` method. See [Preflighted requests in CORS](./../http-methods/#preflighted-requests-in-cors).

Below is an example of how to CORS.

```sh
static-web-server \
    --port 8787 \
    --root ./my-public-dir \
    --cors-allow-origins "https://domain.com"
    --cors-expose-headers "origin, content-type, x-requested-with"
```
diff --git a/docs/man/static-web-server.1.rst b/docs/man/static-web-server.1.rst
index cb5ea52..cee00df 100644
--- a/docs/man/static-web-server.1.rst
+++ b/docs/man/static-web-server.1.rst
@@ -39,10 +39,13 @@ Gzip, Deflate or Brotli compression on demand determined by the Accept-Encoding 
Server TOML configuration file path [env: SERVER_CONFIG_FILE=]

-j, --cors-allow-headers <cors-allow-headers>::
Specify an optional CORS list of allowed headers separated by comas. Default "origin, content-type". It requires ``--cors-allow-origins`` to be used along with [env: SERVER_CORS_ALLOW_HEADERS=]  [default: origin, content-type]
Specify an optional CORS list of allowed headers separated by commas. Default "origin, content-type". It requires ``--cors-allow-origins`` to be used along with [env: SERVER_CORS_ALLOW_HEADERS=]  [default: origin, content-type]

-c, --cors-allow-origins <cors-allow-origins>::
Specify an optional CORS list of allowed origin hosts separated by comas. Host ports or protocols aren't being checked. Use an asterisk (*) to allow any host [env: SERVER_CORS_ALLOW_ORIGINS=]  [default: ]
Specify an optional CORS list of allowed origin hosts separated by commas. Host ports or protocols aren't being checked. Use an asterisk (*) to allow any host [env: SERVER_CORS_ALLOW_ORIGINS=]  [default: ]

--cors-expose-headers <cors-expose-headers>::
Specify an optional CORS list of exposed headers separated by commas. Default "origin, content-type". It requires ``--cors-expose-origins`` to be used along with [env: SERVER_CORS_EXPOSE_HEADERS=]  [default: origin, content-type]

-z, --directory-listing <directory-listing>::
Enable directory listing for all requests ending with the slash character (‘/’) [env: SERVER_DIRECTORY_LISTING=]  [default: false]
diff --git a/src/cors.rs b/src/cors.rs
index 24286ad..c326e3f 100644
--- a/src/cors.rs
+++ b/src/cors.rs
@@ -2,8 +2,8 @@
// -> Part of the file is borrowed from https://github.com/seanmonstar/warp/blob/master/src/filters/cors.rs

use headers::{
    AccessControlAllowHeaders, AccessControlAllowMethods, HeaderMapExt, HeaderName, HeaderValue,
    Origin,
    AccessControlAllowHeaders, AccessControlAllowMethods, AccessControlExposeHeaders, HeaderMapExt,
    HeaderName, HeaderValue, Origin,
};
use http::header;
use std::{collections::HashSet, convert::TryFrom};
@@ -12,28 +12,38 @@ use std::{collections::HashSet, convert::TryFrom};
#[derive(Clone, Debug)]
pub struct Cors {
    allowed_headers: HashSet<HeaderName>,
    exposed_headers: HashSet<HeaderName>,
    max_age: Option<u64>,
    allowed_methods: HashSet<http::Method>,
    origins: Option<HashSet<HeaderValue>>,
}

/// It builds a new CORS instance.
pub fn new(origins_str: &str, headers_str: &str) -> Option<Configured> {
pub fn new(
    origins_str: &str,
    allow_headers_str: &str,
    expose_headers_str: &str,
) -> Option<Configured> {
    let cors = Cors::new();
    let cors = if origins_str.is_empty() {
        None
    } else {
        let headers_vec = if headers_str.is_empty() {
            vec!["origin", "content-type"]
        } else {
            headers_str.split(',').map(|s| s.trim()).collect::<Vec<_>>()
        };
        let headers_str = headers_vec.join(",");
        let [allow_headers_vec, expose_headers_vec] =
            [allow_headers_str, expose_headers_str].map(|s| {
                if s.is_empty() {
                    vec!["origin", "content-type"]
                } else {
                    s.split(',').map(|s| s.trim()).collect::<Vec<_>>()
                }
            });
        let [allow_headers_str, expose_headers_str] =
            [&allow_headers_vec, &expose_headers_vec].map(|v| v.join(","));

        let cors_res = if origins_str == "*" {
            Some(
                cors.allow_any_origin()
                    .allow_headers(headers_vec)
                    .allow_headers(allow_headers_vec)
                    .expose_headers(expose_headers_vec)
                    .allow_methods(vec!["GET", "HEAD", "OPTIONS"]),
            )
        } else {
@@ -43,7 +53,8 @@ pub fn new(origins_str: &str, headers_str: &str) -> Option<Configured> {
            } else {
                Some(
                    cors.allow_origins(hosts)
                        .allow_headers(headers_vec)
                        .allow_headers(allow_headers_vec)
                        .expose_headers(expose_headers_vec)
                        .allow_methods(vec!["GET", "HEAD", "OPTIONS"]),
                )
            }
@@ -51,9 +62,10 @@ pub fn new(origins_str: &str, headers_str: &str) -> Option<Configured> {

        if cors_res.is_some() {
            tracing::info!(
                    "enabled=true, allow_methods=[GET,HEAD,OPTIONS], allow_origins={}, allow_headers=[{}]",
                    "enabled=true, allow_methods=[GET,HEAD,OPTIONS], allow_origins={}, allow_headers=[{}], expose_headers=[{}]",
                    origins_str,
                    headers_str
                    allow_headers_str,
                    expose_headers_str,
                );
        }
        cors_res
@@ -68,6 +80,7 @@ impl Cors {
        Self {
            origins: None,
            allowed_headers: HashSet::new(),
            exposed_headers: HashSet::new(),
            allowed_methods: HashSet::new(),
            max_age: None,
        }
@@ -153,17 +166,39 @@ impl Cors {
        self
    }

    /// Adds multiple headers to the list of exposed request headers.
    ///
    /// **Note**: These should match the values the browser sends via `Access-Control-Request-Headers`, e.g.`content-type`.
    ///
    /// # Panics
    ///
    /// Panics if any of the headers are not a valid `http::header::HeaderName`.
    pub fn expose_headers<I>(mut self, headers: I) -> Self
    where
        I: IntoIterator,
        HeaderName: TryFrom<I::Item>,
    {
        let iter = headers.into_iter().map(|h| match TryFrom::try_from(h) {
            Ok(h) => h,
            Err(_) => panic!("cors: illegal Header"),
        });
        self.exposed_headers.extend(iter);
        self
    }

    /// Builds the `Cors` wrapper from the configured settings.
    pub fn build(cors: Option<Cors>) -> Option<Configured> {
        cors.as_ref()?;
        let cors = cors?;

        let allowed_headers = cors.allowed_headers.iter().cloned().collect();
        let exposed_headers = cors.exposed_headers.iter().cloned().collect();
        let methods_header = cors.allowed_methods.iter().cloned().collect();

        Some(Configured {
            cors,
            allowed_headers,
            exposed_headers,
            methods_header,
        })
    }
@@ -179,6 +214,7 @@ impl Default for Cors {
pub struct Configured {
    cors: Cors,
    allowed_headers: AccessControlAllowHeaders,
    exposed_headers: AccessControlExposeHeaders,
    methods_header: AccessControlAllowMethods,
}

@@ -285,6 +321,7 @@ impl Configured {

    fn append_preflight_headers(&self, headers: &mut http::HeaderMap) {
        headers.typed_insert(self.allowed_headers.clone());
        headers.typed_insert(self.exposed_headers.clone());
        headers.typed_insert(self.methods_header.clone());

        if let Some(max_age) = self.cors.max_age {
diff --git a/src/server.rs b/src/server.rs
index 2e95e38..cd3a608 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -158,6 +158,7 @@ impl Server {
        let cors = cors::new(
            general.cors_allow_origins.trim(),
            general.cors_allow_headers.trim(),
            general.cors_expose_headers.trim(),
        );

        // `Basic` HTTP Authentication Schema option
diff --git a/src/settings/cli.rs b/src/settings/cli.rs
index 5fb712d..2614ec5 100644
--- a/src/settings/cli.rs
+++ b/src/settings/cli.rs
@@ -76,7 +76,7 @@ pub struct General {
        default_value = "",
        env = "SERVER_CORS_ALLOW_ORIGINS"
    )]
    /// Specify an optional CORS list of allowed origin hosts separated by comas. Host ports or protocols aren't being checked. Use an asterisk (*) to allow any host.
    /// Specify an optional CORS list of allowed origin hosts separated by commas. Host ports or protocols aren't being checked. Use an asterisk (*) to allow any host.
    pub cors_allow_origins: String,

    #[structopt(
@@ -85,11 +85,19 @@ pub struct General {
        default_value = "origin, content-type",
        env = "SERVER_CORS_ALLOW_HEADERS"
    )]
    /// Specify an optional CORS list of allowed headers separated by comas. Default "origin, content-type". It requires `--cors-allow-origins` to be used along with.
    /// Specify an optional CORS list of allowed headers separated by commas. Default "origin, content-type". It requires `--cors-allow-origins` to be used along with.
    pub cors_allow_headers: String,

    #[structopt(
        long,
        default_value = "origin, content-type",
        env = "SERVER_CORS_EXPOSE_HEADERS"
    )]
    /// Specify an optional CORS list of exposed headers separated by commas. Default "origin, content-type". It requires `--cors-expose-origins` to be used along with.
    pub cors_expose_headers: String,

    #[structopt(
        long,
        short = "t",
        parse(try_from_str),
        default_value = "false",
diff --git a/src/settings/file.rs b/src/settings/file.rs
index 00bda76..d65f370 100644
--- a/src/settings/file.rs
+++ b/src/settings/file.rs
@@ -111,6 +111,7 @@ pub struct General {
    // CORS
    pub cors_allow_origins: Option<String>,
    pub cors_allow_headers: Option<String>,
    pub cors_expose_headers: Option<String>,

    // Directory listing
    pub directory_listing: Option<bool>,
diff --git a/src/settings/mod.rs b/src/settings/mod.rs
index 2e6821e..c61dcd6 100644
--- a/src/settings/mod.rs
+++ b/src/settings/mod.rs
@@ -76,6 +76,7 @@ impl Settings {
        let mut security_headers = opts.security_headers;
        let mut cors_allow_origins = opts.cors_allow_origins;
        let mut cors_allow_headers = opts.cors_allow_headers;
        let mut cors_expose_headers = opts.cors_expose_headers;
        let mut directory_listing = opts.directory_listing;
        let mut directory_listing_order = opts.directory_listing_order;
        let mut basic_auth = opts.basic_auth;
@@ -155,6 +156,9 @@ impl Settings {
                    if let Some(ref v) = general.cors_allow_headers {
                        cors_allow_headers = v.to_owned()
                    }
                    if let Some(ref v) = general.cors_expose_headers {
                        cors_expose_headers = v.to_owned()
                    }
                    if let Some(v) = general.directory_listing {
                        directory_listing = v
                    }
@@ -301,6 +305,7 @@ impl Settings {
                security_headers,
                cors_allow_origins,
                cors_allow_headers,
                cors_expose_headers,
                directory_listing,
                directory_listing_order,
                basic_auth,
diff --git a/tests/cors.rs b/tests/cors.rs
index a094ade..3147ccb 100644
--- a/tests/cors.rs
+++ b/tests/cors.rs
@@ -11,14 +11,14 @@ mod tests {

    #[tokio::test]
    async fn allow_methods() {
        let cors = cors::new("*", "").unwrap();
        let cors = cors::new("*", "", "").unwrap();
        let headers = HeaderMap::new();
        let methods = &[Method::GET, Method::HEAD, Method::OPTIONS];
        for method in methods {
            assert!(cors.check_request(method, &headers).is_ok());
        }

        let cors = cors::new("https://localhost", "").unwrap();
        let cors = cors::new("https://localhost", "", "").unwrap();
        let mut headers = HeaderMap::new();
        headers.insert("origin", "https://localhost".parse().unwrap());
        headers.insert("access-control-request-method", "GET".parse().unwrap());
@@ -29,7 +29,7 @@ mod tests {

    #[test]
    fn disallow_methods() {
        let cors = cors::new("*", "").unwrap();
        let cors = cors::new("*", "", "").unwrap();
        let headers = HeaderMap::new();
        let methods = [
            Method::CONNECT,
@@ -50,7 +50,7 @@ mod tests {

    #[tokio::test]
    async fn origin_allowed() {
        let cors = cors::new("*", "").unwrap();
        let cors = cors::new("*", "", "").unwrap();
        let mut headers = HeaderMap::new();
        headers.insert("origin", "https://localhost".parse().unwrap());
        let methods = [Method::GET, Method::HEAD, Method::OPTIONS];
@@ -67,7 +67,7 @@ mod tests {

    #[tokio::test]
    async fn origin_not_allowed() {
        let cors = cors::new("https://localhost.rs", "").unwrap();
        let cors = cors::new("https://localhost.rs", "", "").unwrap();
        let mut headers = HeaderMap::new();
        headers.insert("origin", "https://localhost".parse().unwrap());
        let methods = [Method::GET, Method::HEAD, Method::OPTIONS];
@@ -80,7 +80,7 @@ mod tests {

    #[tokio::test]
    async fn method_allowed() {
        let cors = cors::new("*", "").unwrap();
        let cors = cors::new("*", "", "").unwrap();
        let mut headers = HeaderMap::new();
        headers.insert("origin", "https://localhost".parse().unwrap());
        headers.insert("access-control-request-method", "GET".parse().unwrap());
@@ -92,7 +92,7 @@ mod tests {

    #[tokio::test]
    async fn method_disallowed() {
        let cors = cors::new("*", "").unwrap();
        let cors = cors::new("*", "", "").unwrap();
        let mut headers = HeaderMap::new();
        headers.insert("origin", "https://localhost".parse().unwrap());
        headers.insert("access-control-request-method", "POST".parse().unwrap());
@@ -110,7 +110,7 @@ mod tests {

    #[tokio::test]
    async fn headers_allowed() {
        let cors = cors::new("*", "").unwrap();
        let cors = cors::new("*", "", "").unwrap();
        let mut headers = HeaderMap::new();
        headers.insert("origin", "https://localhost".parse().unwrap());
        headers.insert("access-control-request-method", "GET".parse().unwrap());
@@ -127,7 +127,7 @@ mod tests {

    #[tokio::test]
    async fn headers_invalid() {
        let cors = cors::new("*", "").unwrap();
        let cors = cors::new("*", "", "").unwrap();
        let mut headers = HeaderMap::new();
        headers.insert("origin", "https://localhost".parse().unwrap());
        headers.insert(