Skip to content

Commit

Permalink
set content-security-policy, x-content-type-options, and `x-frame…
Browse files Browse the repository at this point in the history
…-options` headers for console assets (#5545)

`content-security-policy` tells a web browser what type of content, and
from which origins, may be loaded on a page. The primary use is to help
guard against cross-site scripting attacks and other kinds of novel
attacks on web applications.

`x-content-type-options: nosniff` tells the web browser to disallow
content sniffing that can cause a browser to decide that responses with
non-executable content types (e.g. `image/png`) can in fact be used as
executable content types (e.g. `text/javascript`). This needs to be set
for all console assets.

`x-frame-options: DENY` disallows embedding the console within another
page, which helps to prevent click-jacking attacks. (This is obsoleted
by the `frame-ancestors 'none'` CSP directive, but no harm in adding
it.)

`content-security-policy` only needs to be set for the console index
page, but there's no harm in setting it for the console assets as well.

As part of this change I did some refactoring:
- The common code between the `asset` function and the
  `serve_console_index` function are now in a single common function.
  This allows us to ship a gzip-compressed console index in the future.
- Assets are now streamed instead of read completely into memory.
- I removed the dependency on `mime_guess`; we only have a small list
  of file extensions we're willing to serve, so it doesn't make sense to
  compile a huge list of content types we'll never use into Nexus.

There may be other headers from
https://owasp.org/www-project-secure-headers/ (see the Best Practices
tab) that we want but these are probably the most urgent.
  • Loading branch information
iliana authored Apr 25, 2024
1 parent 5463fb7 commit debbf7c
Show file tree
Hide file tree
Showing 7 changed files with 228 additions and 128 deletions.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,6 @@ libnvme = { git = "https://github.com/oxidecomputer/libnvme", rev = "6fffcc81d2c
linear-map = "1.2.0"
macaddr = { version = "1.0.1", features = ["serde_std"] }
maplit = "1.0.2"
mime_guess = "2.0.4"
mockall = "0.12"
newtype_derive = "0.1.6"
mg-admin-client = { git = "https://github.com/oxidecomputer/maghemite", rev = "8207cb9c90cd7144c3f351823bfb2ae3e221ad10" }
Expand Down
2 changes: 1 addition & 1 deletion nexus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ internal-dns.workspace = true
ipnetwork.workspace = true
itertools.workspace = true
macaddr.workspace = true
mime_guess.workspace = true
# Not under "dev-dependencies"; these also need to be implemented for
# integration tests.
nexus-config.workspace = true
Expand Down Expand Up @@ -76,6 +75,7 @@ tempfile.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["full"] }
tokio-postgres = { workspace = true, features = ["with-serde_json-1"] }
tokio-util = { workspace = true, features = ["codec"] }
tough.workspace = true
trust-dns-resolver.workspace = true
uuid.workspace = true
Expand Down
233 changes: 144 additions & 89 deletions nexus/src/external_api/console_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ use dropshot::{
HttpResponseFound, HttpResponseHeaders, HttpResponseSeeOther,
HttpResponseUpdatedNoContent, Path, Query, RequestContext,
};
use http::{header, Response, StatusCode, Uri};
use http::{header, HeaderName, HeaderValue, Response, StatusCode, Uri};
use hyper::Body;
use mime_guess;
use nexus_db_model::AuthenticationMode;
use nexus_db_queries::authn::silos::IdentityProviderType;
use nexus_db_queries::context::OpContext;
Expand All @@ -41,9 +40,12 @@ use parse_display::Display;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_urlencoded;
use std::collections::HashMap;
use std::num::NonZeroU32;
use std::str::FromStr;
use std::{collections::HashSet, sync::Arc};
use std::sync::Arc;
use tokio::fs::File;
use tokio_util::codec::{BytesCodec, FramedRead};

// -----------------------------------------------------
// High-level overview of how login works in the console
Expand Down Expand Up @@ -228,7 +230,7 @@ pub(crate) async fn login_saml_begin(
_path_params: Path<LoginToProviderPathParam>,
_query_params: Query<LoginUrlQuery>,
) -> Result<Response<Body>, HttpError> {
serve_console_index(rqctx.context()).await
serve_console_index(rqctx).await
}

/// Get a redirect straight to the IdP
Expand Down Expand Up @@ -394,7 +396,7 @@ pub(crate) async fn login_local_begin(
// let apictx = rqctx.context();
// let handler = async { serve_console_index(rqctx.context()).await };
// apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await
serve_console_index(rqctx.context()).await
serve_console_index(rqctx).await
}

/// Authenticate a user via username and password
Expand Down Expand Up @@ -648,7 +650,7 @@ pub(crate) async fn console_index_or_login_redirect(
// if authed, serve console index.html with JS bundle in script tag
if let Ok(opctx) = opctx {
if opctx.authn.actor().is_some() {
return serve_console_index(rqctx.context()).await;
return serve_console_index(rqctx).await;
}
}

Expand Down Expand Up @@ -707,6 +709,17 @@ console_page!(console_silo_images, "/images");
console_page!(console_silo_utilization, "/utilization");
console_page!(console_silo_access, "/access");

/// Check if `gzip` is listed in the request's `Accept-Encoding` header.
fn accept_gz(header_value: &str) -> bool {
header_value.split(',').any(|c| {
c.split(';')
.next()
.expect("str::split always yields at least one item")
.trim()
== "gzip"
})
}

/// Make a new Utf8PathBuf with `.gz` on the end
fn with_gz_ext(path: &Utf8Path) -> Utf8PathBuf {
let mut new_path = path.to_owned();
Expand All @@ -718,115 +731,147 @@ fn with_gz_ext(path: &Utf8Path) -> Utf8PathBuf {
new_path
}

/// Fetch a static asset from `<static_dir>/assets`. 404 on virtually all
/// errors. No auth. NO SENSITIVE FILES. Will serve a gzipped version if the
/// `.gz` file is present in the directory and `Accept-Encoding: gzip` is
/// present on the request. Cache in browser for a year because assets have
/// content hash in filename.
#[endpoint {
method = GET,
path = "/assets/{path:.*}",
unpublished = true,
}]
pub(crate) async fn asset(
// Define header values as const so that `HeaderValue::from_static` is given the
// opportunity to panic at compile time
static ALLOWED_EXTENSIONS: Lazy<HashMap<&str, HeaderValue>> = {
const CONTENT_TYPES: [(&str, HeaderValue); 10] = [
("css", HeaderValue::from_static("text/css")),
("html", HeaderValue::from_static("text/html; charset=utf-8")),
("js", HeaderValue::from_static("text/javascript")),
("map", HeaderValue::from_static("application/json")),
("png", HeaderValue::from_static("image/png")),
("svg", HeaderValue::from_static("image/svg+xml")),
("txt", HeaderValue::from_static("text/plain; charset=utf-8")),
("webp", HeaderValue::from_static("image/webp")),
("woff", HeaderValue::from_static("application/font-woff")),
("woff2", HeaderValue::from_static("font/woff2")),
];

Lazy::new(|| HashMap::from(CONTENT_TYPES))
};
const CONTENT_ENCODING_GZIP: HeaderValue = HeaderValue::from_static("gzip");
// Web application security headers; these should stay in sync with the headers
// listed in the console repo that are used in development.
// https://github.com/oxidecomputer/console/blob/main/docs/csp-headers.md
const WEB_SECURITY_HEADERS: [(HeaderName, HeaderValue); 3] = [
(
http::header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(
"default-src 'self'; style-src 'unsafe-inline' 'self'; \
frame-src 'none'; object-src 'none'; \
form-action 'none'; frame-ancestors 'none'",
),
),
(http::header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")),
(http::header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")),
];

/// Serve a static asset from `static_dir`. 404 on virtually all errors.
/// No auth. NO SENSITIVE FILES. Will serve a gzipped version if the `.gz`
/// file is present in the directory and `gzip` is listed in the request's
/// `Accept-Encoding` header.
async fn serve_static(
rqctx: RequestContext<Arc<ServerContext>>,
path_params: Path<RestPathParam>,
path: &Utf8Path,
cache_control: HeaderValue,
) -> Result<Response<Body>, HttpError> {
let apictx = rqctx.context();
let path = Utf8PathBuf::from_iter(path_params.into_inner().path);

// Bail unless the extension is allowed
match path.extension() {
Some(ext) => {
if !ALLOWED_EXTENSIONS.contains(&ext) {
return Err(not_found("file extension not allowed"));
}
}
None => {
return Err(not_found(
"requested file does not have extension, not allowed",
));
}
}

// We only serve assets from assets/ within static_dir
let assets_dir = &apictx
let static_dir = apictx
.console_config
.static_dir
.as_ref()
.ok_or_else(|| not_found("static_dir undefined"))?
.join("assets");
.as_deref()
.ok_or_else(|| not_found("static_dir undefined"))?;

let request = &rqctx.request;
let accept_encoding = request.headers().get(http::header::ACCEPT_ENCODING);
let accept_gz = accept_encoding.map_or(false, |val| {
val.to_str().map_or(false, |s| s.contains("gzip"))
});
// Bail unless the extension is allowed
let content_type = ALLOWED_EXTENSIONS
.get(path.extension().ok_or_else(|| {
not_found("requested file does not have extension, not allowed")
})?)
.ok_or_else(|| not_found("file extension not allowed"))?;

let mut resp = Response::builder()
.status(StatusCode::OK)
.header(http::header::CONTENT_TYPE, content_type)
.header(http::header::CACHE_CONTROL, cache_control);
for (k, v) in WEB_SECURITY_HEADERS {
resp = resp.header(k, v);
}

// If req accepts gzip and we have a gzipped version, serve that. Otherwise
// fall back to non-gz. If neither file found, bubble up 404.
let (path_to_read, set_content_encoding_gzip) =
match accept_gz.then(|| find_file(&with_gz_ext(&path), &assets_dir)) {
Some(Ok(gzipped_path)) => (gzipped_path, true),
_ => (find_file(&path, &assets_dir)?, false),
};
let request = &rqctx.request;
let accept_encoding = request
.headers()
.get(http::header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
let path_to_read = match accept_gz(accept_encoding)
.then(|| find_file(&with_gz_ext(&path), static_dir))
{
Some(Ok(gzipped_path)) => {
resp = resp
.header(http::header::CONTENT_ENCODING, CONTENT_ENCODING_GZIP);
gzipped_path
}
_ => find_file(&path, static_dir)?,
};

// File read is the same regardless of gzip
let file_contents = tokio::fs::read(&path_to_read).await.map_err(|e| {
let file = File::open(&path_to_read).await.map_err(|e| {
not_found(&format!("accessing {:?}: {:#}", path_to_read, e))
})?;
let metadata = file.metadata().await.map_err(|e| {
not_found(&format!("accessing {:?}: {:#}", path_to_read, e))
})?;
resp = resp.header(http::header::CONTENT_LENGTH, metadata.len());

// Derive the MIME type from the file name (can't use path_to_read because
// it might end with .gz)
let content_type = path.file_name().map_or("text/plain", |f| {
mime_guess::from_path(f).first_raw().unwrap_or("text/plain")
});

let mut resp = Response::builder()
.status(StatusCode::OK)
.header(http::header::CONTENT_TYPE, content_type)
.header(http::header::CACHE_CONTROL, "max-age=31536000, immutable"); // 1 year
let stream = FramedRead::new(file, BytesCodec::new());
let body = Body::wrap_stream(stream);
Ok(resp.body(body)?)
}

if set_content_encoding_gzip {
resp = resp.header(http::header::CONTENT_ENCODING, "gzip");
}
/// Serve a static asset from `<static_dir>/assets` via [`serve_static`]. Cache
/// in browser for a year because assets have content hash in filename.
///
/// Note that Dropshot protects us from directory traversal attacks (e.g.
/// `/assets/../../../etc/passwd`). This is tested in the `console_api`
/// integration tests.
#[endpoint {
method = GET,
path = "/assets/{path:.*}",
unpublished = true,
}]
pub(crate) async fn asset(
rqctx: RequestContext<Arc<ServerContext>>,
path_params: Path<RestPathParam>,
) -> Result<Response<Body>, HttpError> {
// asset URLs contain hashes, so cache for 1 year
const CACHE_CONTROL: HeaderValue =
HeaderValue::from_static("max-age=31536000, immutable");

Ok(resp.body(file_contents.into())?)
let mut path = Utf8PathBuf::from("assets");
path.extend(path_params.into_inner().path);
serve_static(rqctx, &path, CACHE_CONTROL).await
}

/// Serve `<static_dir>/index.html` via [`serve_static`]. Disallow caching.
pub(crate) async fn serve_console_index(
apictx: &ServerContext,
rqctx: RequestContext<Arc<ServerContext>>,
) -> Result<Response<Body>, HttpError> {
let static_dir = &apictx
.console_config
.static_dir
.to_owned()
.ok_or_else(|| not_found("static_dir undefined"))?;
let file = static_dir.join("index.html");
let file_contents = tokio::fs::read(&file)
.await
.map_err(|e| not_found(&format!("accessing {:?}: {:#}", file, e)))?;
Ok(Response::builder()
.status(StatusCode::OK)
.header(http::header::CONTENT_TYPE, "text/html; charset=UTF-8")
// do not cache this response in browser
.header(http::header::CACHE_CONTROL, "no-store")
.body(file_contents.into())?)
// do not cache this response in browser
const CACHE_CONTROL: HeaderValue = HeaderValue::from_static("no-store");

serve_static(rqctx, Utf8Path::new("index.html"), CACHE_CONTROL).await
}

fn not_found(internal_msg: &str) -> HttpError {
HttpError::for_not_found(None, internal_msg.to_string())
}

static ALLOWED_EXTENSIONS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
"js", "css", "html", "ico", "map", "otf", "png", "svg", "ttf", "txt",
"webp", "woff", "woff2",
])
});

/// Starting from `root_dir`, follow the segments of `path` down the file tree
/// until we find a file (or not). Do not follow symlinks.
///
/// WARNING: This function assumes that `..` path segments have already been
/// found and rejected.
fn find_file(
path: &Utf8Path,
root_dir: &Utf8Path,
Expand Down Expand Up @@ -862,10 +907,20 @@ fn find_file(

#[cfg(test)]
mod test {
use super::{find_file, RelativeUri};
use super::{accept_gz, find_file, RelativeUri};
use camino::{Utf8Path, Utf8PathBuf};
use http::StatusCode;

#[test]
fn test_accept_gz() {
assert!(!accept_gz(""));
assert!(accept_gz("gzip"));
assert!(accept_gz("deflate, gzip;q=1.0, *;q=0.5"));
assert!(accept_gz(" gzip ; q=0.9 "));
assert!(!accept_gz("gzip2"));
assert!(accept_gz("gzip2, gzip;q=0.9"));
}

#[test]
fn test_find_file_finds_file() {
let root = current_dir();
Expand Down
Loading

0 comments on commit debbf7c

Please sign in to comment.