aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSheepy <sheepy404@gmail.com>2022-06-26 00:02:02 +0000
committerGitHub <noreply@github.com>2022-06-26 00:02:02 +0000
commit5bcfa4ac832a9457ed32ff377febf6e284c5e1d5 (patch)
treef5c3eb3eab367f16d24535109955134f9c591e5c
parentBump clap_mangen from 0.1.8 to 0.1.9 (#826) (diff)
downloadminiserve-5bcfa4ac832a9457ed32ff377febf6e284c5e1d5.tar.gz
miniserve-5bcfa4ac832a9457ed32ff377febf6e284c5e1d5.zip
Create directory (#781)
* Add ability to make directory Frontend for making directories Fix potential security vulnerability (CWE-23) Add tests Update README.md Disallow using parent directories altogether Fix formatting Fix clippy warnings Address review comments Update README.md Change `making` to `creation` Co-authored-by: Sven-Hendrik Haase <svenstaro@gmail.com> Have make directory flag require file upload flag Address review comments * Disallow uploading files and making directories through symlinks when disabled * Add test * Clippy formatting changes * Add test doc comment
Diffstat (limited to '')
-rw-r--r--README.md13
-rw-r--r--data/style.scss35
-rw-r--r--src/args.rs4
-rw-r--r--src/config.rs4
-rw-r--r--src/errors.rs6
-rw-r--r--src/file_upload.rs138
-rw-r--r--src/listing.rs1
-rw-r--r--src/renderer.rs55
-rw-r--r--tests/create_directories.rs163
9 files changed, 380 insertions, 39 deletions
diff --git a/README.md b/README.md
index 3ad0fbf..4a1d22c 100644
--- a/README.md
+++ b/README.md
@@ -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(())
+}