diff --git a/tower-http/src/services/fs/serve_dir/mod.rs b/tower-http/src/services/fs/serve_dir/mod.rs index bab51014..9143b569 100644 --- a/tower-http/src/services/fs/serve_dir/mod.rs +++ b/tower-http/src/services/fs/serve_dir/mod.rs @@ -52,6 +52,7 @@ const DEFAULT_CAPACITY: usize = 65536; #[derive(Clone, Debug)] pub struct ServeDir { base: PathBuf, + redirect_path_prefix: String, buf_chunk_size: usize, precompressed_variants: Option, // This is used to specialize implementation for @@ -72,6 +73,7 @@ impl ServeDir { Self { base, + redirect_path_prefix: String::new(), buf_chunk_size: DEFAULT_CAPACITY, precompressed_variants: None, variant: ServeVariant::Directory { @@ -89,6 +91,7 @@ impl ServeDir { { Self { base: path.as_ref().to_owned(), + redirect_path_prefix: String::new(), buf_chunk_size: DEFAULT_CAPACITY, precompressed_variants: None, variant: ServeVariant::SingleFile { mime }, @@ -133,6 +136,19 @@ impl ServeDir { } } + /// 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/`. + /// In that example, you should pass in `/static` so that a trailing slash redirect does not + /// redirect to `//` but instead to `/static//` + /// + /// The default is the empty string. + pub fn redirect_path_prefix(mut self, prefix: impl Into) -> Self { + self.redirect_path_prefix = prefix.into(); + self + } + /// Set a specific read buffer chunk size. /// /// The default capacity is 64kb. @@ -229,6 +245,7 @@ impl ServeDir { /// ``` pub fn fallback(self, new_fallback: F2) -> ServeDir { ServeDir { + redirect_path_prefix: self.redirect_path_prefix, base: self.base, buf_chunk_size: self.buf_chunk_size, precompressed_variants: self.precompressed_variants, @@ -377,6 +394,8 @@ impl ServeDir { } }; + let redirect_path_prefix = self.redirect_path_prefix.clone(); + let buf_chunk_size = self.buf_chunk_size; let range_header = req .headers() @@ -391,16 +410,19 @@ impl ServeDir { ) .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) diff --git a/tower-http/src/services/fs/serve_dir/open_file.rs b/tower-http/src/services/fs/serve_dir/open_file.rs index 6ab4dfb4..bb2f4c91 100644 --- a/tower-http/src/services/fs/serve_dir/open_file.rs +++ b/tower-http/src/services/fs/serve_dir/open_file.rs @@ -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>, negotiated_encodings: Vec<(Encoding, QValue)>, range_header: Option, - buf_chunk_size: usize, - precompression_configured: bool, ) -> io::Result { + let OpenFileConfig { + variant, + redirect_path_prefix, + buf_chunk_size, + precompression_configured, + } = config; + let preconditions = Preconditions { if_match: req .headers() @@ -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, @@ -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, @@ -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), }; @@ -434,7 +448,7 @@ async fn is_dir(path_to_file: &Path) -> Option { .map(|meta_data| meta_data.is_dir()) } -fn append_slash_on_path(uri: Uri) -> Result { +fn append_slash_on_path(uri: Uri, redirect_path_prefix: &str) -> Result { let http::uri::Parts { scheme, authority, @@ -454,12 +468,16 @@ fn append_slash_on_path(uri: Uri) -> Result { 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| { diff --git a/tower-http/src/services/fs/serve_dir/tests.rs b/tower-http/src/services/fs/serve_dir/tests.rs index 30fe0d39..4a9a58e9 100644 --- a/tower-http/src/services/fs/serve_dir/tests.rs +++ b/tower-http/src/services/fs/serve_dir/tests.rs @@ -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(_: Request) -> Result, 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); @@ -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]