diff options
-rw-r--r-- | README.md | 13 | ||||
-rw-r--r-- | data/style.scss | 35 | ||||
-rw-r--r-- | src/args.rs | 4 | ||||
-rw-r--r-- | src/config.rs | 4 | ||||
-rw-r--r-- | src/errors.rs | 6 | ||||
-rw-r--r-- | src/file_upload.rs | 138 | ||||
-rw-r--r-- | src/listing.rs | 1 | ||||
-rw-r--r-- | src/renderer.rs | 55 | ||||
-rw-r--r-- | tests/create_directories.rs | 163 |
9 files changed, 380 insertions, 39 deletions
@@ -70,6 +70,15 @@ Sometimes this is just a more practical and quick way than doing things properly (where `$FILE` is the path to the file. This uses miniserve's default port of 8080) +### Create a directory using `curl`: + + # in one terminal + miniserve --upload-files --mkdir . + # in another terminal + curl -F "mkdir=$DIR_NAME" http://localhost:8080/upload\?path=\/ + +(where `$DIR_NAME` is the name of the directory. This uses miniserve's default port of 8080.) + ### Take pictures and upload them from smartphones: miniserve -u -m image -q @@ -86,6 +95,7 @@ Some mobile browsers like Firefox on Android will offer to open the camera app w - Mega fast and highly parallel (thanks to [Rust](https://www.rust-lang.org/) and [Actix](https://actix.rs/)) - Folder download (compressed on the fly as `.tar.gz` or `.zip`) - File uploading +- Directory creation - Pretty themes (with light and dark theme support) - Scan QR code for quick access - Shell completions @@ -216,6 +226,9 @@ Some mobile browsers like Firefox on Android will offer to open the camera app w -u, --upload-files Enable file uploading + -U --mkdir + Enable directory creating + -v, --verbose Be verbose, includes emitting access logs diff --git a/data/style.scss b/data/style.scss index c23bff4..1ce2e92 100644 --- a/data/style.scss +++ b/data/style.scss @@ -395,39 +395,52 @@ th span.active span { margin-right: 1rem; } -.upload { +.toolbar_box_group { + min-width: max-content; +} + +.toolbar_box { margin-top: 1rem; display: flex; justify-content: flex-end; } -.upload p { +.toolbar_box p { font-size: 0.8rem; margin-bottom: 1rem; color: var(--upload_text_color); } -.upload form { +.toolbar_box form { padding: 1rem; border: 1px solid var(--upload_form_border_color); background: var(--upload_form_background); } -.upload button { +.toolbar_box input { + padding: 0.5rem; + margin-right: 0.2rem; + border-radius: 0.2rem; + border: 0; + display: inline; +} + +.toolbar_box button { background: var(--upload_button_background); padding: 0.5rem; border-radius: 0.2rem; color: var(--upload_button_text_color); border: none; + min-width: max-content; } -.upload div { +.toolbar_box div { display: flex; align-items: baseline; justify-content: space-between; } -.drag-form { +.form { display: none; background: var(--drag_background); position: absolute; @@ -436,9 +449,10 @@ th span.active span { height: calc(100% - 1rem); text-align: center; z-index: 2; + margin: 0 5px; } -.drag-title { +.form_title { position: fixed; color: var(--drag_text_color); top: 50%; @@ -470,6 +484,13 @@ th span.active span { margin-top: 4rem; } +@media (min-width: 900px) { + .toolbar_box_group { + display: flex; + justify-content: flex-end; + } +} + @media (max-width: 760px) { nav { padding: 0 2.5rem; diff --git a/src/args.rs b/src/args.rs index f02efa1..687649d 100644 --- a/src/args.rs +++ b/src/args.rs @@ -111,6 +111,10 @@ pub struct CliArgs { #[clap(short = 'u', long = "upload-files")] pub file_upload: bool, + /// Enable creating directories + #[clap(short = 'U', long = "mkdir", requires = "file-upload")] + pub mkdir_enabled: bool, + /// Specify uploadable media types #[clap(arg_enum, short = 'm', long = "media-type", requires = "file-upload")] pub media_type: Option<Vec<MediaType>>, diff --git a/src/config.rs b/src/config.rs index deec606..2ee014f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -81,6 +81,9 @@ pub struct MiniserveConfig { /// Enable QR code display pub show_qrcode: bool, + /// Enable creating directories + pub mkdir_enabled: bool, + /// Enable file upload pub file_upload: bool, @@ -228,6 +231,7 @@ impl MiniserveConfig { spa: args.spa, overwrite_files: args.overwrite_files, show_qrcode: args.qrcode, + mkdir_enabled: args.mkdir_enabled, file_upload: args.file_upload, uploadable_media_type, tar_enabled: args.enable_tar, diff --git a/src/errors.rs b/src/errors.rs index 5f55514..22351a3 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -30,7 +30,7 @@ pub enum ContextualError { #[error("Invalid format for credentials string. Expected username:password, username:sha256:hash or username:sha512:hash")] InvalidAuthFormat, - /// Might occure if the hash method is neither sha256 nor sha512 + /// Might occur if the hash method is neither sha256 nor sha512 #[error("{0} is not a valid hashing method. Expected sha256 or sha512")] InvalidHashMethod(String), @@ -42,7 +42,7 @@ pub enum ContextualError { #[error("HTTP password length exceeds 255 characters")] PasswordTooLongError, - /// Might occur if the user has unsufficient permissions to create an entry in a given directory + /// Might occur if the user has insufficient permissions to create an entry in a given directory #[error("Insufficient permissions to create file in {0}")] InsufficientPermissionsError(String), @@ -51,7 +51,7 @@ pub enum ContextualError { ParseError(String, String), /// Might occur when the creation of an archive fails - #[error("An error occured while creating the {0}\ncaused by: {1}")] + #[error("An error occurred while creating the {0}\ncaused by: {1}")] ArchiveCreationError(String, Box<ContextualError>), /// More specific archive creation failure reason diff --git a/src/file_upload.rs b/src/file_upload.rs index 0d4b8a5..6643c68 100644 --- a/src/file_upload.rs +++ b/src/file_upload.rs @@ -39,20 +39,14 @@ async fn save_file( /// Handles a single field in a multipart form async fn handle_multipart( - field: actix_multipart::Field, + mut field: actix_multipart::Field, path: PathBuf, overwrite_files: bool, + allow_mkdir: bool, + allow_hidden_paths: bool, + allow_symlinks: bool, ) -> Result<u64, ContextualError> { - let filename = field.content_disposition().get_filename().ok_or_else(|| { - ContextualError::ParseError( - "HTTP header".to_string(), - "Failed to retrieve the name of the file to upload".to_string(), - ) - })?; - - let filename = sanitize_path(Path::new(&filename), false).ok_or_else(|| { - ContextualError::InvalidPathError("Invalid file name to upload".to_string()) - })?; + let field_name = field.name().to_string(); match std::fs::metadata(&path) { Err(_) => Err(ContextualError::InsufficientPermissionsError( @@ -68,10 +62,88 @@ async fn handle_multipart( Ok(_) => Ok(()), }?; - save_file(field, path.join(filename), overwrite_files).await + if field_name == "mkdir" { + if !allow_mkdir { + return Err(ContextualError::InsufficientPermissionsError( + path.display().to_string(), + )); + } + + let mut user_given_path = PathBuf::new(); + let mut absolute_path = path.clone(); + + // Get the path the user gave + let mkdir_path_bytes = field.try_next().await; + match mkdir_path_bytes { + Ok(Some(mkdir_path_bytes)) => { + let mkdir_path = std::str::from_utf8(&mkdir_path_bytes).map_err(|e| { + ContextualError::ParseError( + "Failed to parse 'mkdir' path".to_string(), + e.to_string(), + ) + })?; + let mkdir_path = mkdir_path.replace('\\', "/"); + absolute_path.push(&mkdir_path); + user_given_path.push(&mkdir_path); + } + _ => { + return Err(ContextualError::ParseError( + "Failed to parse 'mkdir' path".to_string(), + "".to_string(), + )) + } + }; + + // Disallow using `..` (parent) in mkdir path + if user_given_path + .components() + .any(|c| c == Component::ParentDir) + { + return Err(ContextualError::InvalidPathError( + "Cannot use '..' in mkdir path".to_string(), + )); + } + // Hidden paths check + sanitize_path(&user_given_path, allow_hidden_paths).ok_or_else(|| { + ContextualError::InvalidPathError("Cannot use hidden paths in mkdir path".to_string()) + })?; + + // Ensure there are no illegal symlinks + if !allow_symlinks && contains_symlink(&absolute_path) { + return Err(ContextualError::InsufficientPermissionsError( + user_given_path.display().to_string(), + )); + } + + std::fs::create_dir_all(&absolute_path).map_err(|e| { + ContextualError::IoError(format!("Failed to create {}", user_given_path.display()), e) + })?; + + return Ok(0); + } + + let filename = field.content_disposition().get_filename().ok_or_else(|| { + ContextualError::ParseError( + "HTTP header".to_string(), + "Failed to retrieve the name of the file to upload".to_string(), + ) + })?; + + let filename_path = sanitize_path(Path::new(&filename), false).ok_or_else(|| { + ContextualError::InvalidPathError("Invalid file name to upload".to_string()) + })?; + + // Ensure there are no illegal symlinks in the file upload path + if !allow_symlinks && contains_symlink(&path) { + return Err(ContextualError::InsufficientPermissionsError( + filename.to_string(), + )); + } + + save_file(field, path.join(filename_path), overwrite_files).await } -/// Handle incoming request to upload file. +/// Handle incoming request to upload a file or create a directory. /// Target file path is expected as path parameter in URI and is interpreted as relative from /// server root directory. Any path which will go outside of this directory is considered /// invalid. @@ -99,8 +171,11 @@ pub async fn upload_file( ContextualError::IoError("Failed to resolve path served by miniserve".to_string(), e) })?; - // If the target path is under the app root directory, save the file. - let target_dir = match app_root_dir.join(upload_path).canonicalize() { + // Disallow the target path to go outside of the served directory + // The target directory shouldn't be canonicalized when it gets passed to + // handle_multipart so that it can check for symlinks if needed + let non_canonicalized_target_dir = app_root_dir.join(upload_path); + match non_canonicalized_target_dir.canonicalize() { Ok(path) if !conf.no_symlinks => Ok(path), Ok(path) if path.starts_with(&app_root_dir) => Ok(path), _ => Err(ContextualError::InvalidHttpRequestError( @@ -110,7 +185,16 @@ pub async fn upload_file( actix_multipart::Multipart::new(req.headers(), payload) .map_err(ContextualError::MultipartError) - .and_then(|field| handle_multipart(field, target_dir.clone(), conf.overwrite_files)) + .and_then(|field| { + handle_multipart( + field, + non_canonicalized_target_dir.clone(), + conf.overwrite_files, + conf.mkdir_enabled, + conf.show_hidden, + !conf.no_symlinks, + ) + }) .try_collect::<Vec<u64>>() .await?; @@ -148,6 +232,28 @@ fn sanitize_path(path: &Path, traverse_hidden: bool) -> Option<PathBuf> { Some(buf) } +/// Returns if a path goes through a symolic link +fn contains_symlink(path: &PathBuf) -> bool { + let mut joined_path = PathBuf::new(); + for path_slice in path { + joined_path = joined_path.join(path_slice); + if !joined_path.exists() { + // On Windows, `\\?\` won't exist even though it's the root + // So, we can't just return here + // But we don't need to check if it's a symlink since it won't be + continue; + } + if joined_path + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + return true; + } + } + false +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/listing.rs b/src/listing.rs index 38e971e..887fa8b 100644 --- a/src/listing.rs +++ b/src/listing.rs @@ -36,6 +36,7 @@ pub struct QueryParameters { pub sort: Option<SortingMethod>, pub order: Option<SortingOrder>, pub raw: Option<bool>, + pub mkdir_name: Option<String>, qrcode: Option<String>, download: Option<ArchiveMethod>, } diff --git a/src/renderer.rs b/src/renderer.rs index 9c8b5bf..75d2c71 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -29,6 +29,7 @@ pub fn page( let (sort_method, sort_order) = (query_params.sort, query_params.order); let upload_action = build_upload_action(&upload_route, encoded_dir, sort_method, sort_order); + let mkdir_action = build_mkdir_action(&upload_route, encoded_dir); let title_path = breadcrumbs .iter() @@ -69,10 +70,20 @@ pub fn page( </script> "#)) - @if conf.file_upload { - div.drag-form { - div.drag-title { - h1 { "Drop your file here to upload it" } + div.toolbar_box_group { + @if conf.file_upload { + div.form { + div.form_title { + h1 { "Drop your file here to upload it" } + } + } + } + + @if conf.mkdir_enabled { + div.form { + div.form_title { + h1 { "Create a new directory" } + } } } } @@ -102,16 +113,29 @@ pub fn page( } } } - @if conf.file_upload { - div.upload { - form id="file_submit" action=(upload_action) method="POST" enctype="multipart/form-data" { - p { "Select a file to upload or drag it anywhere into the window" } - div { - @match &conf.uploadable_media_type { - Some(accept) => {input #file-input accept=(accept) type="file" name="file_to_upload" required="" multiple {}}, - None => {input #file-input type="file" name="file_to_upload" required="" multiple {}} + div.toolbar_box_group { + @if conf.file_upload { + div.toolbar_box { + form id="file_submit" action=(upload_action) method="POST" enctype="multipart/form-data" { + p { "Select a file to upload or drag it anywhere into the window" } + div { + @match &conf.uploadable_media_type { + Some(accept) => {input #file-input accept=(accept) type="file" name="file_to_upload" required="" multiple {}}, + None => {input #file-input type="file" name="file_to_upload" required="" multiple {}} + } + button type="submit" { "Upload file" } + } + } + } + } + @if conf.mkdir_enabled { + div.toolbar_box { + form id="mkdir" action=(mkdir_action) method="POST" enctype="multipart/form-data" { + p { "Specify a directory name to create" } + div.toolbar_box { + input type="text" name="mkdir" required="" placeholder="Directory name" {} + button type="submit" { "Create directory" } } - button type="submit" { "Upload file" } } } } @@ -243,6 +267,11 @@ fn build_upload_action( upload_action } +/// Build the action of the mkdir form +fn build_mkdir_action(mkdir_route: &str, encoded_dir: &str) -> String { + format!("{}?path={}", mkdir_route, encoded_dir) +} + const THEME_PICKER_CHOICES: &[(&str, &str)] = &[ ("Default (light/dark)", "default"), ("Squirrel (light)", "squirrel"), diff --git a/tests/create_directories.rs b/tests/create_directories.rs new file mode 100644 index 0000000..fa8f4b8 --- /dev/null +++ b/tests/create_directories.rs @@ -0,0 +1,163 @@ +mod fixtures; + +use fixtures::{server, Error, TestServer, DIRECTORIES}; +use reqwest::blocking::{multipart, Client}; +use rstest::rstest; +use select::document::Document; +use select::predicate::{Attr, Text}; +#[cfg(unix)] +use std::os::unix::fs::symlink as symlink_dir; +#[cfg(windows)] +use std::os::windows::fs::symlink_dir; + +/// This should work because the flags for uploading files and creating directories +/// are set, and the directory name and path are valid. +#[rstest] +fn creating_directories_works( + #[with(&["--upload-files", "--mkdir"])] server: TestServer, +) -> Result<(), Error> { + let test_directory_name = "hello"; + + // Before creating, check whether the directory does not yet exist. + let body = reqwest::blocking::get(server.url())?.error_for_status()?; + let parsed = Document::from_read(body)?; + assert!(parsed.find(Text).all(|x| x.text() != test_directory_name)); + + // Perform the actual creation. + let create_action = parsed + .find(Attr("id", "mkdir")) + .next() + .expect("Couldn't find element with id=mkdir") + .attr("action") + .expect("Directory form doesn't have action attribute"); + let form = multipart::Form::new(); + let part = multipart::Part::text(test_directory_name); + let form = form.part("mkdir", part); + + let client = Client::new(); + client + .post(server.url().join(create_action)?) + .multipart(form) + .send()? + .error_for_status()?; + + // After creating, check whether the directory is now getting listed. + let body = reqwest::blocking::get(server.url())?; + let parsed = Document::from_read(body)?; + assert!(parsed + .find(Text) + .any(|x| x.text() == test_directory_name.to_owned() + "/")); + + Ok(()) +} + +/// This should fail because the server does not allow for creating directories +/// as the flags for uploading files and creating directories are not set. +/// The directory name and path are valid. +#[rstest] +fn creating_directories_is_prevented(server: TestServer) -> Result<(), Error> { + let test_directory_name = "hello"; + + // Before creating, check whether the directory does not yet exist. + let body = reqwest::blocking::get(server.url())?.error_for_status()?; + let parsed = Document::from_read(body)?; + assert!(parsed.find(Text).all(|x| x.text() != test_directory_name)); + + // Ensure the directory creation form is not present + assert!(parsed.find(Attr("id", "mkdir")).next().is_none()); + + // Then try to create anyway + let form = multipart::Form::new(); + let part = multipart::Part::text(test_directory_name); + let form = form.part("mkdir", part); + + let client = Client::new(); + // This should fail + assert!(client + .post(server.url().join("/upload?path=/")?) + .multipart(form) + .send()? + .error_for_status() + .is_err()); + + // After creating, check whether the directory is now getting listed (shouldn't). + let body = reqwest::blocking::get(server.url())?; + let parsed = Document::from_read(body)?; + assert!(parsed + .find(Text) + .all(|x| x.text() != test_directory_name.to_owned() + "/")); + + Ok(()) +} + +/// This should fail because directory creation through symlinks should not be possible +/// when the the no symlinks flag is set. +#[rstest] +fn creating_directories_through_symlinks_is_prevented( + #[with(&["--upload-files", "--mkdir", "--no-symlinks"])] server: TestServer, +) -> Result<(), Error> { + // Make symlinks + let symlink_directory_str = "symlink"; + let symlink_directory = server.path().join(symlink_directory_str); + let symlinked_direcotry = server.path().join(DIRECTORIES[0]); + symlink_dir(symlinked_direcotry, symlink_directory).unwrap(); + + // Before attempting to create, ensure the symlink does not exist. + let body = reqwest::blocking::get(server.url())?.error_for_status()?; + let parsed = Document::from_read(body)?; + assert!(parsed.find(Text).all(|x| x.text() != symlink_directory_str)); + + // Attempt to perform directory creation. + let form = multipart::Form::new(); + let part = multipart::Part::text(symlink_directory_str); + let form = form.part("mkdir", part); + + // This should fail + assert!(Client::new() + .post( + server + .url() + .join(format!("/upload?path=/{}", symlink_directory_str).as_str())? + ) + .multipart(form) + .send()? + .error_for_status() + .is_err()); + + Ok(()) +} + +/// Test for path traversal vulnerability (CWE-22) in both path parameter of query string and in +/// mkdir name (Content-Disposition) +/// +/// see: https://github.com/svenstaro/miniserve/issues/518 +#[rstest] +#[case("foo", "bar", "foo/bar")] // Not CWE-22, but `foo` isn't a directory +#[case("/../foo", "bar", "foo/bar")] +#[case("/foo", "/../bar", "foo/bar")] +#[case("C:/foo", "C:/bar", if cfg!(windows) { "foo/bar" } else { "C:/foo/C:/bar" })] +#[case(r"C:\foo", r"C:\bar", if cfg!(windows) { "foo/bar" } else { r"C:\foo/C:\bar" })] +#[case(r"\foo", r"\..\bar", if cfg!(windows) { "foo/bar" } else { r"\foo/\..\bar" })] +fn prevent_path_transversal_attacks( + #[with(&["--upload-files", "--mkdir"])] server: TestServer, + #[case] path: &str, + #[case] dir_name: &'static str, + #[case] expected: &str, +) -> Result<(), Error> { + let expected_path = server.path().join(expected); + assert!(!expected_path.exists()); + + let form = multipart::Form::new(); + let part = multipart::Part::text(dir_name); + let form = form.part("mkdir", part); + + // This should fail + assert!(Client::new() + .post(server.url().join(&format!("/upload/path={}", path))?) + .multipart(form) + .send()? + .error_for_status() + .is_err()); + + Ok(()) +} |