Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions tower-http/src/services/fs/serve_dir/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const DEFAULT_CAPACITY: usize = 65536;
#[derive(Clone, Debug)]
pub struct ServeDir<F = DefaultServeDirFallback> {
base: PathBuf,
redirect_path_prefix: String,
buf_chunk_size: usize,
precompressed_variants: Option<PrecompressedVariants>,
// This is used to specialize implementation for
Expand All @@ -72,6 +73,7 @@ impl ServeDir<DefaultServeDirFallback> {

Self {
base,
redirect_path_prefix: String::new(),
buf_chunk_size: DEFAULT_CAPACITY,
precompressed_variants: None,
variant: ServeVariant::Directory {
Expand All @@ -89,6 +91,7 @@ impl ServeDir<DefaultServeDirFallback> {
{
Self {
base: path.as_ref().to_owned(),
redirect_path_prefix: String::new(),
buf_chunk_size: DEFAULT_CAPACITY,
precompressed_variants: None,
variant: ServeVariant::SingleFile { mime },
Expand Down Expand Up @@ -133,6 +136,19 @@ impl<F> ServeDir<F> {
}
}

/// Sets a path to be prepended when performing a trailing slash redirect.
///
/// This is useful when you want to serve the files at another location than `/`, for example
/// when you are using multiple services and want this instance to handle `/static/<path>`.
/// In that example, you should pass in `/static` so that a trailing slash redirect does not
/// redirect to `/<path>/` but instead to `/static/<path>/`
///
/// The default is the empty string.
pub fn redirect_path_prefix(mut self, prefix: impl Into<String>) -> Self {
self.redirect_path_prefix = prefix.into();
self
}

/// Set a specific read buffer chunk size.
///
/// The default capacity is 64kb.
Expand Down Expand Up @@ -229,6 +245,7 @@ impl<F> ServeDir<F> {
/// ```
pub fn fallback<F2>(self, new_fallback: F2) -> ServeDir<F2> {
ServeDir {
redirect_path_prefix: self.redirect_path_prefix,
base: self.base,
buf_chunk_size: self.buf_chunk_size,
precompressed_variants: self.precompressed_variants,
Expand Down Expand Up @@ -377,6 +394,8 @@ impl<F> ServeDir<F> {
}
};

let redirect_path_prefix = self.redirect_path_prefix.clone();

let buf_chunk_size = self.buf_chunk_size;
let range_header = req
.headers()
Expand All @@ -391,16 +410,19 @@ impl<F> ServeDir<F> {
)
.collect();

let variant = self.variant.clone();
let open_file_config = open_file::OpenFileConfig {
variant: self.variant.clone(),
redirect_path_prefix,
buf_chunk_size,
precompression_configured,
};

let open_file_future = Box::pin(open_file::open_file(
variant,
open_file_config,
path_to_file,
req,
negotiated_encodings,
range_header,
buf_chunk_size,
precompression_configured,
));

ResponseFuture::open_file_future(open_file_future, fallback_and_request)
Expand Down
34 changes: 26 additions & 8 deletions tower-http/src/services/fs/serve_dir/open_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,27 @@ pub(super) enum FileRequestExtent {
Head(Metadata),
}

pub(super) struct OpenFileConfig {
pub(super) variant: ServeVariant,
pub(super) redirect_path_prefix: String,
pub(super) buf_chunk_size: usize,
pub(super) precompression_configured: bool,
}

pub(super) async fn open_file(
variant: ServeVariant,
config: OpenFileConfig,
mut path_to_file: PathBuf,
req: Request<Empty<Bytes>>,
negotiated_encodings: Vec<(Encoding, QValue)>,
range_header: Option<String>,
buf_chunk_size: usize,
precompression_configured: bool,
) -> io::Result<OpenFileOutput> {
let OpenFileConfig {
variant,
redirect_path_prefix,
buf_chunk_size,
precompression_configured,
} = config;

let preconditions = Preconditions {
if_match: req
.headers()
Expand Down Expand Up @@ -84,6 +96,7 @@ pub(super) async fn open_file(
// returned which corresponds to a Some(output). Otherwise the path might be
// modified and proceed to the open file/metadata future.
if let Some(output) = maybe_redirect_or_append_path(
&redirect_path_prefix,
&mut path_to_file,
req.uri(),
append_index_html_on_directories,
Expand Down Expand Up @@ -377,6 +390,7 @@ async fn file_metadata_with_fallback(
}

async fn maybe_redirect_or_append_path(
redirect_path_prefix: &str,
path_to_file: &mut PathBuf,
uri: &Uri,
append_index_html_on_directories: bool,
Expand Down Expand Up @@ -408,7 +422,7 @@ async fn maybe_redirect_or_append_path(
path_to_file.push("index.html");
None
} else {
let uri = match append_slash_on_path(uri.clone()) {
let uri = match append_slash_on_path(uri.clone(), redirect_path_prefix) {
Ok(uri) => uri,
Err(err) => return Some(err),
};
Expand All @@ -434,7 +448,7 @@ async fn is_dir(path_to_file: &Path) -> Option<bool> {
.map(|meta_data| meta_data.is_dir())
}

fn append_slash_on_path(uri: Uri) -> Result<Uri, OpenFileOutput> {
fn append_slash_on_path(uri: Uri, redirect_path_prefix: &str) -> Result<Uri, OpenFileOutput> {
let http::uri::Parts {
scheme,
authority,
Expand All @@ -454,12 +468,16 @@ fn append_slash_on_path(uri: Uri) -> Result<Uri, OpenFileOutput> {

let uri_builder = if let Some(path_and_query) = path_and_query {
if let Some(query) = path_and_query.query() {
uri_builder.path_and_query(format!("{}/?{}", path_and_query.path(), query))
uri_builder.path_and_query(format!(
"{redirect_path_prefix}{}/?{}",
path_and_query.path(),
query
))
} else {
uri_builder.path_and_query(format!("{}/", path_and_query.path()))
uri_builder.path_and_query(format!("{redirect_path_prefix}{}/", path_and_query.path()))
}
} else {
uri_builder.path_and_query("/")
uri_builder.path_and_query(format!("{redirect_path_prefix}/"))
};

uri_builder.build().map_err(|_err| {
Expand Down
80 changes: 80 additions & 0 deletions tower-http/src/services/fs/serve_dir/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,48 @@ async fn redirect_to_trailing_slash_on_dir() {
assert_eq!(location, "/src/");
}

#[tokio::test]
async fn redirect_to_trailing_slash_with_redirect_path_prefix() {
let cases = [
("/foo", "/src", "/foo/src/"),
("/foo/", "/src", "/foo//src/"),
("", "/src", "/src/"),
("/foo", "/src?key=value", "/foo/src/?key=value"),
("/foo", "/s%72c", "/foo/s%72c/"),
];

for (prefix, uri, expected_location) in cases {
let svc = ServeDir::new(".").redirect_path_prefix(prefix);

let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
let res = svc.oneshot(req).await.unwrap();

assert_eq!(res.status(), StatusCode::TEMPORARY_REDIRECT);

let location = &res.headers()[http::header::LOCATION];
assert_eq!(location, expected_location);
}
}

#[tokio::test]
async fn redirect_path_prefix_preserved_through_fallback() {
async fn fallback<B>(_: Request<B>) -> Result<Response<Body>, Infallible> {
Ok(Response::new(Body::empty()))
}

let svc = ServeDir::new(".")
.redirect_path_prefix("/foo")
.fallback(tower::service_fn(fallback));

let req = Request::builder().uri("/src").body(Body::empty()).unwrap();
let res = svc.oneshot(req).await.unwrap();

assert_eq!(res.status(), StatusCode::TEMPORARY_REDIRECT);

let location = &res.headers()[http::header::LOCATION];
assert_eq!(location, "/foo/src/");
}

#[tokio::test]
async fn empty_directory_without_index() {
let svc = ServeDir::new(".").append_index_html_on_directories(false);
Expand Down Expand Up @@ -1117,6 +1159,44 @@ fn test_build_and_validate_path_reserved_dos_names() {
}
}

// Regression test for the Windows directory-traversal fix in #204 (tracked by #251):
// a drive-letter prefix such as `C:` must not be served as an absolute path.
#[tokio::test]
async fn reject_windows_drive_prefixed_path() {
let svc = ServeDir::new(TEST_FILES_DIR);

let req = Request::builder()
.uri("/C:/windows/win.ini")
.body(Body::empty())
.unwrap();
let res = svc.oneshot(req).await.unwrap();

assert_eq!(
res.status(),
StatusCode::NOT_FOUND,
"drive-prefixed path should be rejected, not served"
);
}

// As above, but with the `:` percent-encoded (`%3A`) to confirm the drive prefix
// is still rejected *after* URL decoding.
#[tokio::test]
async fn reject_percent_encoded_windows_drive_prefixed_path() {
let svc = ServeDir::new(TEST_FILES_DIR);

let req = Request::builder()
.uri("/anypath/c%3A/windows/win.ini")
.body(Body::empty())
.unwrap();
let res = svc.oneshot(req).await.unwrap();

assert_eq!(
res.status(),
StatusCode::NOT_FOUND,
"percent-encoded drive prefix should be rejected after decoding"
);
}

// Regression test for https://github.com/tower-rs/tower-http/issues/664
// Accept-Encoding: identity should not cause extension stripping
#[tokio::test]
Expand Down
Loading