Repository: svenstaro/miniserve
Pull request #781 (2022-04-23 17:03:34)

Create directory
Addresses #768.
Directories can be created with the `/upload` endpoint, with a `mkdir` multipart form key.
Attached is a video of it in use.

https://user-images.githubusercontent.com/73724119/164916100-4c537b0f-d305-4686-887d-5c2805f96e63.mp4

svenstaro: From a quick look, this look like great work! I'll take a more thorough look later.
sheepy0125: Sorry about the failed checks, hopefully it should work now...
svenstaro: Also make sure to resolve the conflicts/rebase. :)
sheepy0125: I have no clue how to rebase... I don't think I did it right
svenstaro: I'm afraid this doesn't look right :) You have two weird merges in there right now. Make sure your local `master` is up to date with upstream. Then, let's try an interactive rebase onto it while at the same time getting rid of the merges in there. I'd try to start something with `git rebase -i master~3`. There are plenty of great interactive tutorials on this.
svenstaro: While trying this out I noticed something: It's possible to specify `-U` without `-u` which will actually render a button but that button can't be used due to error 405. I think perhaps we should specify `-U` to require `-u`?
sheepy0125: Hmm... since it is using the `/upload` endpoint, it'd be easier to require `-U` to also have `-u`. I think that's a good idea. What should happen if `-U` is set without `-u`, though?
svenstaro: > What should happen if `-U` is set without `-u`, though?

Just let [clap deal with it](https://docs.rs/clap/latest/clap/struct.Arg.html#method.requires) and show the user an error.

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 10183ba..5cff4b1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -543,9 +543,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "3.2.5"
+version = "3.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d53da17d37dba964b9b3ecb5c5a1f193a2762c700e6829201e645b9381c99dc7"
+checksum = "9f1fe12880bae935d142c8702d500c63a4e8634b6c3c57ad72bf978fc7b6249a"
 dependencies = [
  "atty",
  "bitflags",
@@ -561,18 +561,18 @@ dependencies = [
 
 [[package]]
 name = "clap_complete"
-version = "3.2.1"
+version = "3.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f6ebaab5f25e4f0312dfa07cb30a755204b96e6531457c2cfdecfdf5f2adf40"
+checksum = "c09fbb00fb6e20e92f785598ecbf0c118f269737490c57af28b1ed07f392be16"
 dependencies = [
- "clap 3.2.5",
+ "clap 3.2.6",
 ]
 
 [[package]]
 name = "clap_derive"
-version = "3.2.5"
+version = "3.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c11d40217d16aee8508cc8e5fde8b4ff24639758608e5374e731b53f85749fb9"
+checksum = "ed6db9e867166a43a53f7199b5e4d1f522a1e5bd626654be263c999ce59df39a"
 dependencies = [
  "heck",
  "proc-macro-error",
@@ -592,11 +592,11 @@ dependencies = [
 
 [[package]]
 name = "clap_mangen"
-version = "0.1.8"
+version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b009eb8517c57e54db8b50748684f84af57ff0390287f132970bf783b7d7ec7"
+checksum = "e16658e2f46d5269f95e4ec0f16594524cfc1e51637af40b2e5118c7c71a9fe1"
 dependencies = [
- "clap 3.2.5",
+ "clap 3.2.6",
  "roff",
 ]
 
@@ -1405,7 +1405,7 @@ dependencies = [
  "bytesize",
  "chrono",
  "chrono-humanize",
- "clap 3.2.5",
+ "clap 3.2.6",
  "clap_complete",
  "clap_mangen",
  "futures",
diff --git a/Cargo.toml b/Cargo.toml
index 56667ac..f27770d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,7 +30,7 @@ bytesize = "1"
 nanoid = "0.4"
 alphanumeric-sort = "1"
 clap = { version = "3.2", features = ["derive", "cargo", "wrap_help"] }
-clap_complete = "3.1.3"
+clap_complete = "3.2.2"
 clap_mangen = "0.1"
 chrono = "0.4"
 chrono-humanize = "0.2"
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"),


Test patch:
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(())
+}