Skip to content

Commit f369c80

Browse files
authored
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
1 parent cce7a85 commit f369c80

File tree

10 files changed

+108
-26
lines changed

10 files changed

+108
-26
lines changed

docs/content/configuration/command-line-arguments.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ OPTIONS:
4545
-c, --cors-allow-origins <cors-allow-origins>
4646
Specify an optional CORS list of allowed origin hosts separated by comas. Host ports or protocols aren't
4747
being checked. Use an asterisk (*) to allow any host [env: SERVER_CORS_ALLOW_ORIGINS=] [default: ]
48+
--cors-expose-headers <cors-expose-headers>
49+
Specify an optional CORS list of exposed headers separated by commas. Default "origin, content-type". It
50+
requires `--cors-expose-origins` to be used along with [env: SERVER_CORS_EXPOSE_HEADERS=] [default: origin,
51+
content-type]
4852
-z, --directory-listing <directory-listing>
4953
Enable directory listing for all requests ending with the slash character (‘/’) [env:
5054
SERVER_DIRECTORY_LISTING=] [default: false]

docs/content/configuration/environment-variables.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ Specify an optional CORS list of allowed origin hosts separated by commas. Host
5757
### SERVER_CORS_ALLOW_HEADERS
5858
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`.
5959

60+
### SERVER_CORS_EXPOSE_HEADERS
61+
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`.
62+
6063
### SERVER_COMPRESSION
6164
`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).
6265

docs/content/features/cors.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,23 @@ static-web-server \
3838
--cors-allow-origins "https://domain.com"
3939
--cors-allow-headers "origin, content-type, x-requested-with"
4040
```
41+
42+
## Exposed headers
43+
44+
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.
45+
46+
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.
47+
48+
!!! info "Tips"
49+
- The default exposed headers value is `origin, content-type`.
50+
- 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).
51+
52+
Below is an example of how to CORS.
53+
54+
```sh
55+
static-web-server \
56+
--port 8787 \
57+
--root ./my-public-dir \
58+
--cors-allow-origins "https://domain.com"
59+
--cors-expose-headers "origin, content-type, x-requested-with"
60+
```

docs/man/static-web-server.1.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,13 @@ Gzip, Deflate or Brotli compression on demand determined by the Accept-Encoding
3939
Server TOML configuration file path [env: SERVER_CONFIG_FILE=]
4040

4141
-j, --cors-allow-headers <cors-allow-headers>::
42-
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]
42+
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]
4343

4444
-c, --cors-allow-origins <cors-allow-origins>::
45-
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: ]
45+
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: ]
46+
47+
--cors-expose-headers <cors-expose-headers>::
48+
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]
4649

4750
-z, --directory-listing <directory-listing>::
4851
Enable directory listing for all requests ending with the slash character (‘/’) [env: SERVER_DIRECTORY_LISTING=] [default: false]

src/cors.rs

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// -> Part of the file is borrowed from https://github.com/seanmonstar/warp/blob/master/src/filters/cors.rs
33

44
use headers::{
5-
AccessControlAllowHeaders, AccessControlAllowMethods, HeaderMapExt, HeaderName, HeaderValue,
6-
Origin,
5+
AccessControlAllowHeaders, AccessControlAllowMethods, AccessControlExposeHeaders, HeaderMapExt,
6+
HeaderName, HeaderValue, Origin,
77
};
88
use http::header;
99
use std::{collections::HashSet, convert::TryFrom};
@@ -12,28 +12,38 @@ use std::{collections::HashSet, convert::TryFrom};
1212
#[derive(Clone, Debug)]
1313
pub struct Cors {
1414
allowed_headers: HashSet<HeaderName>,
15+
exposed_headers: HashSet<HeaderName>,
1516
max_age: Option<u64>,
1617
allowed_methods: HashSet<http::Method>,
1718
origins: Option<HashSet<HeaderValue>>,
1819
}
1920

2021
/// It builds a new CORS instance.
21-
pub fn new(origins_str: &str, headers_str: &str) -> Option<Configured> {
22+
pub fn new(
23+
origins_str: &str,
24+
allow_headers_str: &str,
25+
expose_headers_str: &str,
26+
) -> Option<Configured> {
2227
let cors = Cors::new();
2328
let cors = if origins_str.is_empty() {
2429
None
2530
} else {
26-
let headers_vec = if headers_str.is_empty() {
27-
vec!["origin", "content-type"]
28-
} else {
29-
headers_str.split(',').map(|s| s.trim()).collect::<Vec<_>>()
30-
};
31-
let headers_str = headers_vec.join(",");
31+
let [allow_headers_vec, expose_headers_vec] =
32+
[allow_headers_str, expose_headers_str].map(|s| {
33+
if s.is_empty() {
34+
vec!["origin", "content-type"]
35+
} else {
36+
s.split(',').map(|s| s.trim()).collect::<Vec<_>>()
37+
}
38+
});
39+
let [allow_headers_str, expose_headers_str] =
40+
[&allow_headers_vec, &expose_headers_vec].map(|v| v.join(","));
3241

3342
let cors_res = if origins_str == "*" {
3443
Some(
3544
cors.allow_any_origin()
36-
.allow_headers(headers_vec)
45+
.allow_headers(allow_headers_vec)
46+
.expose_headers(expose_headers_vec)
3747
.allow_methods(vec!["GET", "HEAD", "OPTIONS"]),
3848
)
3949
} else {
@@ -43,17 +53,19 @@ pub fn new(origins_str: &str, headers_str: &str) -> Option<Configured> {
4353
} else {
4454
Some(
4555
cors.allow_origins(hosts)
46-
.allow_headers(headers_vec)
56+
.allow_headers(allow_headers_vec)
57+
.expose_headers(expose_headers_vec)
4758
.allow_methods(vec!["GET", "HEAD", "OPTIONS"]),
4859
)
4960
}
5061
};
5162

5263
if cors_res.is_some() {
5364
tracing::info!(
54-
"enabled=true, allow_methods=[GET,HEAD,OPTIONS], allow_origins={}, allow_headers=[{}]",
65+
"enabled=true, allow_methods=[GET,HEAD,OPTIONS], allow_origins={}, allow_headers=[{}], expose_headers=[{}]",
5566
origins_str,
56-
headers_str
67+
allow_headers_str,
68+
expose_headers_str,
5769
);
5870
}
5971
cors_res
@@ -68,6 +80,7 @@ impl Cors {
6880
Self {
6981
origins: None,
7082
allowed_headers: HashSet::new(),
83+
exposed_headers: HashSet::new(),
7184
allowed_methods: HashSet::new(),
7285
max_age: None,
7386
}
@@ -153,17 +166,39 @@ impl Cors {
153166
self
154167
}
155168

169+
/// Adds multiple headers to the list of exposed request headers.
170+
///
171+
/// **Note**: These should match the values the browser sends via `Access-Control-Request-Headers`, e.g.`content-type`.
172+
///
173+
/// # Panics
174+
///
175+
/// Panics if any of the headers are not a valid `http::header::HeaderName`.
176+
pub fn expose_headers<I>(mut self, headers: I) -> Self
177+
where
178+
I: IntoIterator,
179+
HeaderName: TryFrom<I::Item>,
180+
{
181+
let iter = headers.into_iter().map(|h| match TryFrom::try_from(h) {
182+
Ok(h) => h,
183+
Err(_) => panic!("cors: illegal Header"),
184+
});
185+
self.exposed_headers.extend(iter);
186+
self
187+
}
188+
156189
/// Builds the `Cors` wrapper from the configured settings.
157190
pub fn build(cors: Option<Cors>) -> Option<Configured> {
158191
cors.as_ref()?;
159192
let cors = cors?;
160193

161194
let allowed_headers = cors.allowed_headers.iter().cloned().collect();
195+
let exposed_headers = cors.exposed_headers.iter().cloned().collect();
162196
let methods_header = cors.allowed_methods.iter().cloned().collect();
163197

164198
Some(Configured {
165199
cors,
166200
allowed_headers,
201+
exposed_headers,
167202
methods_header,
168203
})
169204
}
@@ -179,6 +214,7 @@ impl Default for Cors {
179214
pub struct Configured {
180215
cors: Cors,
181216
allowed_headers: AccessControlAllowHeaders,
217+
exposed_headers: AccessControlExposeHeaders,
182218
methods_header: AccessControlAllowMethods,
183219
}
184220

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

286322
fn append_preflight_headers(&self, headers: &mut http::HeaderMap) {
287323
headers.typed_insert(self.allowed_headers.clone());
324+
headers.typed_insert(self.exposed_headers.clone());
288325
headers.typed_insert(self.methods_header.clone());
289326

290327
if let Some(max_age) = self.cors.max_age {

src/server.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ impl Server {
158158
let cors = cors::new(
159159
general.cors_allow_origins.trim(),
160160
general.cors_allow_headers.trim(),
161+
general.cors_expose_headers.trim(),
161162
);
162163

163164
// `Basic` HTTP Authentication Schema option

src/settings/cli.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ pub struct General {
7676
default_value = "",
7777
env = "SERVER_CORS_ALLOW_ORIGINS"
7878
)]
79-
/// 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.
79+
/// 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.
8080
pub cors_allow_origins: String,
8181

8282
#[structopt(
@@ -85,9 +85,17 @@ pub struct General {
8585
default_value = "origin, content-type",
8686
env = "SERVER_CORS_ALLOW_HEADERS"
8787
)]
88-
/// 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.
88+
/// 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.
8989
pub cors_allow_headers: String,
9090

91+
#[structopt(
92+
long,
93+
default_value = "origin, content-type",
94+
env = "SERVER_CORS_EXPOSE_HEADERS"
95+
)]
96+
/// 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.
97+
pub cors_expose_headers: String,
98+
9199
#[structopt(
92100
long,
93101
short = "t",

src/settings/file.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ pub struct General {
111111
// CORS
112112
pub cors_allow_origins: Option<String>,
113113
pub cors_allow_headers: Option<String>,
114+
pub cors_expose_headers: Option<String>,
114115

115116
// Directory listing
116117
pub directory_listing: Option<bool>,

src/settings/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ impl Settings {
7676
let mut security_headers = opts.security_headers;
7777
let mut cors_allow_origins = opts.cors_allow_origins;
7878
let mut cors_allow_headers = opts.cors_allow_headers;
79+
let mut cors_expose_headers = opts.cors_expose_headers;
7980
let mut directory_listing = opts.directory_listing;
8081
let mut directory_listing_order = opts.directory_listing_order;
8182
let mut basic_auth = opts.basic_auth;
@@ -155,6 +156,9 @@ impl Settings {
155156
if let Some(ref v) = general.cors_allow_headers {
156157
cors_allow_headers = v.to_owned()
157158
}
159+
if let Some(ref v) = general.cors_expose_headers {
160+
cors_expose_headers = v.to_owned()
161+
}
158162
if let Some(v) = general.directory_listing {
159163
directory_listing = v
160164
}
@@ -301,6 +305,7 @@ impl Settings {
301305
security_headers,
302306
cors_allow_origins,
303307
cors_allow_headers,
308+
cors_expose_headers,
304309
directory_listing,
305310
directory_listing_order,
306311
basic_auth,

tests/cors.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ mod tests {
1111

1212
#[tokio::test]
1313
async fn allow_methods() {
14-
let cors = cors::new("*", "").unwrap();
14+
let cors = cors::new("*", "", "").unwrap();
1515
let headers = HeaderMap::new();
1616
let methods = &[Method::GET, Method::HEAD, Method::OPTIONS];
1717
for method in methods {
1818
assert!(cors.check_request(method, &headers).is_ok());
1919
}
2020

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

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

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

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

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

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

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

128128
#[tokio::test]
129129
async fn headers_invalid() {
130-
let cors = cors::new("*", "").unwrap();
130+
let cors = cors::new("*", "", "").unwrap();
131131
let mut headers = HeaderMap::new();
132132
headers.insert("origin", "https://localhost".parse().unwrap());
133133
headers.insert(

0 commit comments

Comments
 (0)