From f369c80b6854f1088cea24d9de8759d0f1ab35b3 Mon Sep 17 00:00:00 2001 From: Nelson Chen Date: Sun, 2 Oct 2022 23:07:03 -0700 Subject: [PATCH] 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 --- 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 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 + 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 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 :: -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 :: -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 :: +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 :: 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, + exposed_headers: HashSet, max_age: Option, allowed_methods: HashSet, origins: Option>, } /// It builds a new CORS instance. -pub fn new(origins_str: &str, headers_str: &str) -> Option { +pub fn new( + origins_str: &str, + allow_headers_str: &str, + expose_headers_str: &str, +) -> Option { 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::>() - }; - 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::>() + } + }); + 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 { } 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 { 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(mut self, headers: I) -> Self + where + I: IntoIterator, + HeaderName: TryFrom, + { + 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) -> Option { 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, pub cors_allow_headers: Option, + pub cors_expose_headers: Option, // Directory listing pub directory_listing: Option, 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( -- libgit2 1.7.2