Skip to content

Commit

Permalink
CORS expose headers option (#144)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
nelsonjchen authored Oct 3, 2022
1 parent cce7a85 commit f369c80
Show file tree
Hide file tree
Showing 10 changed files with 108 additions and 26 deletions.
4 changes: 4 additions & 0 deletions docs/content/configuration/command-line-arguments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions docs/content/configuration/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
20 changes: 20 additions & 0 deletions docs/content/features/cors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```
7 changes: 5 additions & 2 deletions docs/man/static-web-server.1.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
63 changes: 50 additions & 13 deletions src/cors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 {
Expand All @@ -43,17 +53,19 @@ 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"]),
)
}
};

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
Expand All @@ -68,6 +80,7 @@ impl Cors {
Self {
origins: None,
allowed_headers: HashSet::new(),
exposed_headers: HashSet::new(),
allowed_methods: HashSet::new(),
max_age: None,
}
Expand Down Expand Up @@ -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,
})
}
Expand All @@ -179,6 +214,7 @@ impl Default for Cors {
pub struct Configured {
cors: Cors,
allowed_headers: AccessControlAllowHeaders,
exposed_headers: AccessControlExposeHeaders,
methods_header: AccessControlAllowMethods,
}

Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions src/settings/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -85,9 +85,17 @@ 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",
Expand Down
1 change: 1 addition & 0 deletions src/settings/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down
5 changes: 5 additions & 0 deletions src/settings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -301,6 +305,7 @@ impl Settings {
security_headers,
cors_allow_origins,
cors_allow_headers,
cors_expose_headers,
directory_listing,
directory_listing_order,
basic_auth,
Expand Down
18 changes: 9 additions & 9 deletions tests/cors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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,
Expand All @@ -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];
Expand All @@ -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];
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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(
Expand Down

0 comments on commit f369c80

Please sign in to comment.