aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md6
-rw-r--r--Cargo.lock4
-rw-r--r--Cargo.toml3
-rw-r--r--src/args.rs17
-rw-r--r--src/config.rs18
-rw-r--r--src/errors.rs6
-rw-r--r--src/file_upload.rs13
-rw-r--r--src/listing.rs17
-rw-r--r--src/renderer.rs10
-rw-r--r--tests/readme.rs22
-rw-r--r--tests/upload_files.rs91
11 files changed, 177 insertions, 30 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0be364..dc1f0ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased] - ReleaseDate
- Faster QR code generation [#848](https://github.com/svenstaro/miniserve/pull/848/files) (thanks @cyqsimon)
+- Make `--readme` support not only `README.md` but also `README` and `README.txt` rendered as
+ plaintext [#911](https://github.com/svenstaro/miniserve/pull/911) (thanks @Atreyagaurav)
+- Change `-u/--upload-files` slightly in the sense that it can now either be provided by itself as
+ before or receive a file path to restrict uploading to only that path. Can be provided multiple
+ times for multiple allowed paths [#858](https://github.com/svenstaro/miniserve/pull/858) (thanks
+ @jonasdiemer)
## [0.21.0] - 2022-09-15
- Fix bug where static files would be served incorrectly when using `--random-route` [#835](https://github.com/svenstaro/miniserve/pull/835) (thanks @solarknight)
diff --git a/Cargo.lock b/Cargo.lock
index 04da9d7..a04b34c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -839,9 +839,9 @@ dependencies = [
[[package]]
name = "fast_qr"
-version = "0.3.1"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b798dfd6e29b85c0bcf434272db4cde0100ab4d82c5db0a4f422e77b30d0b4e4"
+checksum = "c6ca492f7dae5b7705f8997b042fcf7a665addb13181908d1fa7cb6d9bc1c0e6"
dependencies = [
"wasm-bindgen",
]
diff --git a/Cargo.toml b/Cargo.toml
index 8372342..16c6bbb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,7 +32,7 @@ clap = { version = "3.2", features = ["derive", "cargo", "wrap_help"] }
clap_complete = "3.2.3"
clap_mangen = "0.1"
comrak = "0.14.0"
-fast_qr = "0.3.1"
+fast_qr = "0.5.1"
futures = "0.3"
get_if_addrs = "0.5"
hex = "0.4"
@@ -45,6 +45,7 @@ mime = "0.3"
nanoid = "0.4"
percent-encoding = "2"
port_check = "0.1"
+regex = "1"
rustls = { version = "0.20", optional = true }
rustls-pemfile = { version = "1.0", optional = true }
serde = { version = "1", features = ["derive"] }
diff --git a/src/args.rs b/src/args.rs
index a8718bd..8757bc8 100644
--- a/src/args.rs
+++ b/src/args.rs
@@ -107,23 +107,28 @@ pub struct CliArgs {
#[clap(short = 'q', long = "qrcode")]
pub qrcode: bool,
- /// Enable file uploading
- #[clap(short = 'u', long = "upload-files")]
- pub file_upload: bool,
+ /// Enable file uploading (and optionally specify for which directory)
+ #[clap(short = 'u', long = "upload-files", value_hint = ValueHint::FilePath, min_values = 0)]
+ pub allowed_upload_dir: Option<Vec<PathBuf>>,
/// Enable creating directories
- #[clap(short = 'U', long = "mkdir", requires = "file-upload")]
+ #[clap(short = 'U', long = "mkdir", requires = "allowed-upload-dir")]
pub mkdir_enabled: bool,
/// Specify uploadable media types
- #[clap(arg_enum, short = 'm', long = "media-type", requires = "file-upload")]
+ #[clap(
+ arg_enum,
+ short = 'm',
+ long = "media-type",
+ requires = "allowed-upload-dir"
+ )]
pub media_type: Option<Vec<MediaType>>,
/// Directly specify the uploadable media type expression
#[clap(
short = 'M',
long = "raw-media-type",
- requires = "file-upload",
+ requires = "allowed-upload-dir",
conflicts_with = "media-type"
)]
pub media_type_raw: Option<String>,
diff --git a/src/config.rs b/src/config.rs
index 5bcbd62..7ca0693 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -16,6 +16,7 @@ use rustls_pemfile as pemfile;
use crate::{
args::{CliArgs, MediaType},
auth::RequiredAuth,
+ file_upload::sanitize_path,
};
/// Possible characters for random routes
@@ -87,6 +88,9 @@ pub struct MiniserveConfig {
/// Enable file upload
pub file_upload: bool,
+ /// List of allowed upload directories
+ pub allowed_upload_dir: Vec<String>,
+
/// HTML accept attribute value
pub uploadable_media_type: Option<String>,
@@ -247,7 +251,19 @@ impl MiniserveConfig {
overwrite_files: args.overwrite_files,
show_qrcode: args.qrcode,
mkdir_enabled: args.mkdir_enabled,
- file_upload: args.file_upload,
+ file_upload: args.allowed_upload_dir.is_some(),
+ allowed_upload_dir: args
+ .allowed_upload_dir
+ .unwrap_or_default()
+ .iter()
+ .map(|x| {
+ sanitize_path(x, false)
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .replace('\\', "/")
+ })
+ .collect(),
uploadable_media_type,
tar_enabled: args.enable_tar,
tar_gz_enabled: args.enable_tar_gz,
diff --git a/src/errors.rs b/src/errors.rs
index b2ed459..06569d3 100644
--- a/src/errors.rs
+++ b/src/errors.rs
@@ -22,6 +22,10 @@ pub enum ContextualError {
#[error("File already exists, and the overwrite_files option has not been set")]
DuplicateFileError,
+ /// Upload not allowed
+ #[error("Upload not allowed to this directory")]
+ UploadForbiddenError,
+
/// Any error related to an invalid path (failed to retrieve entry name, unexpected entry type, etc)
#[error("Invalid path\ncaused by: {0}")]
InvalidPathError(String),
@@ -88,6 +92,8 @@ impl ResponseError for ContextualError {
Self::InsufficientPermissionsError(_) => StatusCode::FORBIDDEN,
Self::InvalidHttpCredentials => StatusCode::UNAUTHORIZED,
Self::InvalidHttpRequestError(_) => StatusCode::BAD_REQUEST,
+ Self::DuplicateFileError => StatusCode::FORBIDDEN,
+ Self::UploadForbiddenError => StatusCode::FORBIDDEN,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
diff --git a/src/file_upload.rs b/src/file_upload.rs
index 6643c68..cf214b8 100644
--- a/src/file_upload.rs
+++ b/src/file_upload.rs
@@ -171,6 +171,17 @@ pub async fn upload_file(
ContextualError::IoError("Failed to resolve path served by miniserve".to_string(), e)
})?;
+ // Disallow paths outside of allowed directories
+ let upload_allowed = conf.allowed_upload_dir.is_empty()
+ || conf
+ .allowed_upload_dir
+ .iter()
+ .any(|s| upload_path.starts_with(s));
+
+ if !upload_allowed {
+ return Err(ContextualError::UploadForbiddenError);
+ }
+
// 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
@@ -207,7 +218,7 @@ pub async fn upload_file(
/// and optionally prevent traversing hidden directories.
///
/// See the unit tests tests::test_sanitize_path* for examples
-fn sanitize_path(path: &Path, traverse_hidden: bool) -> Option<PathBuf> {
+pub fn sanitize_path(path: &Path, traverse_hidden: bool) -> Option<PathBuf> {
let mut buf = PathBuf::new();
for comp in path.components() {
diff --git a/src/listing.rs b/src/listing.rs
index 7477599..fbee8de 100644
--- a/src/listing.rs
+++ b/src/listing.rs
@@ -9,6 +9,7 @@ use actix_web::{HttpMessage, HttpRequest, HttpResponse};
use bytesize::ByteSize;
use comrak::{markdown_to_html, ComrakOptions};
use percent_encoding::{percent_decode_str, utf8_percent_encode};
+use regex::Regex;
use serde::Deserialize;
use strum_macros::{Display, EnumString};
@@ -224,6 +225,7 @@ pub fn directory_listing(
let mut entries: Vec<Entry> = Vec::new();
let mut readme: Option<(String, String)> = None;
+ let readme_rx: Regex = Regex::new("^readme([.](md|txt))?$").unwrap();
for entry in dir.path.read_dir()? {
if dir.is_visible(&entry) || conf.show_hidden {
@@ -274,13 +276,18 @@ pub fn directory_listing(
last_modification_date,
symlink_dest,
));
- if conf.readme && file_name.to_lowercase() == "readme.md" {
+ if conf.readme && readme_rx.is_match(&file_name.to_lowercase()) {
+ let ext = file_name.split('.').last().unwrap().to_lowercase();
readme = Some((
file_name.to_string(),
- markdown_to_html(
- &std::fs::read_to_string(entry.path())?,
- &ComrakOptions::default(),
- ),
+ if ext == "md" {
+ markdown_to_html(
+ &std::fs::read_to_string(entry.path())?,
+ &ComrakOptions::default(),
+ )
+ } else {
+ format!("<pre>{}</pre>", &std::fs::read_to_string(entry.path())?)
+ },
));
}
}
diff --git a/src/renderer.rs b/src/renderer.rs
index c8958fe..6aad5bd 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -40,6 +40,12 @@ pub fn page(
let title_path = breadcrumbs_to_path_string(breadcrumbs);
+ let upload_allowed = conf.allowed_upload_dir.is_empty()
+ || conf
+ .allowed_upload_dir
+ .iter()
+ .any(|x| encoded_dir.starts_with(&format!("/{}", x)));
+
html! {
(DOCTYPE)
html {
@@ -120,7 +126,7 @@ pub fn page(
}
}
div.toolbar_box_group {
- @if conf.file_upload {
+ @if conf.file_upload && upload_allowed {
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" }
@@ -235,7 +241,7 @@ fn qr_code_svg(url: impl AsRef<str>, margin: usize) -> Result<String, QRCodeErro
let qr = QRBuilder::new(url.as_ref().into())
.ecl(consts::QR_EC_LEVEL)
.build()?;
- let svg = SvgBuilder::new().margin(margin).build_qr(qr);
+ let svg = SvgBuilder::default().margin(margin).to_str(&qr);
Ok(svg)
}
diff --git a/tests/readme.rs b/tests/readme.rs
index 56a3afd..3fcbe29 100644
--- a/tests/readme.rs
+++ b/tests/readme.rs
@@ -64,7 +64,11 @@ fn show_root_readme_contents(
case("readme.md"),
case("README.md"),
case("README.MD"),
- case("ReAdMe.Md")
+ case("ReAdMe.Md"),
+ case("Readme.txt"),
+ case("README.txt"),
+ case("README"),
+ case("ReAdMe")
)]
fn show_nested_readme_contents(
#[with(&["--readme"])] server: TestServer,
@@ -113,13 +117,11 @@ fn assert_readme_contents(parsed_dom: &Document, filename: &str) {
.find(Attr("id", "readme-contents"))
.next()
.is_some());
- assert!(
- parsed_dom
- .find(Attr("id", "readme-contents"))
- .next()
- .unwrap()
- .text()
- .trim()
- == format!("Contents of {}", filename)
- );
+ assert!(parsed_dom
+ .find(Attr("id", "readme-contents"))
+ .next()
+ .unwrap()
+ .text()
+ .trim()
+ .contains(&format!("Contents of {}", filename)));
}
diff --git a/tests/upload_files.rs b/tests/upload_files.rs
index 71fcbc4..196f3cd 100644
--- a/tests/upload_files.rs
+++ b/tests/upload_files.rs
@@ -6,6 +6,8 @@ use reqwest::blocking::{multipart, Client};
use rstest::rstest;
use select::document::Document;
use select::predicate::{Attr, Text};
+use std::fs::create_dir_all;
+use std::path::Path;
#[rstest]
fn uploading_files_works(#[with(&["-u"])] server: TestServer) -> Result<(), Error> {
@@ -72,7 +74,7 @@ fn uploading_files_is_prevented(server: TestServer) -> Result<(), Error> {
.error_for_status()
.is_err());
- // After uploading, check whether the uploaded file is now getting listed.
+ // After uploading, check whether the uploaded file is NOT getting listed.
let body = reqwest::blocking::get(server.url())?;
let parsed = Document::from_read(body)?;
assert!(!parsed.find(Text).any(|x| x.text() == test_file_name));
@@ -80,6 +82,92 @@ fn uploading_files_is_prevented(server: TestServer) -> Result<(), Error> {
Ok(())
}
+/// This test runs the server with --allowed-upload-dir argument and
+/// checks that file upload to a different directory is actually prevented.
+#[rstest]
+#[case(server_no_stderr(&["-u", "someDir"]))]
+#[case(server_no_stderr(&["-u", "someDir/some_sub_dir"]))]
+fn uploading_files_is_restricted(#[case] server: TestServer) -> Result<(), Error> {
+ let test_file_name = "uploaded test file.txt";
+
+ // Then try to upload file to root directory (which is not the --allowed-upload-dir)
+ let form = multipart::Form::new();
+ let part = multipart::Part::text("this should not be uploaded")
+ .file_name(test_file_name)
+ .mime_str("text/plain")?;
+ let form = form.part("file_to_upload", part);
+
+ let client = Client::new();
+ // Ensure uploading fails and returns an error
+ assert_eq!(
+ 403,
+ client
+ .post(server.url().join("/upload?path=/")?)
+ .multipart(form)
+ .send()?
+ .status()
+ );
+
+ // After uploading, check whether the uploaded file is NOT getting listed.
+ let body = reqwest::blocking::get(server.url())?;
+ let parsed = Document::from_read(body)?;
+ assert!(!parsed.find(Text).any(|x| x.text() == test_file_name));
+
+ Ok(())
+}
+
+/// This tests that we can upload files to the directory specified by --allow-upload-dir
+#[rstest]
+#[case(server(&["-u", "someDir"]), vec!["someDir"])]
+#[case(server(&["-u", "./-someDir"]), vec!["./-someDir"])]
+#[case(server(&["-u", Path::new("someDir/some_sub_dir").to_str().unwrap()]),
+ vec!["someDir/some_sub_dir"])]
+#[case(server(&["-u", Path::new("someDir/some_sub_dir").to_str().unwrap(),
+ "-u", Path::new("someDir/some_other_dir").to_str().unwrap()]),
+ vec!["someDir/some_sub_dir", "someDir/some_other_dir"])]
+fn uploading_files_to_allowed_dir_works(
+ #[case] server: TestServer,
+ #[case] upload_dirs: Vec<&str>,
+) -> Result<(), Error> {
+ let test_file_name = "uploaded test file.txt";
+
+ for upload_dir in upload_dirs {
+ // Create test directory
+ create_dir_all(server.path().join(Path::new(upload_dir))).unwrap();
+
+ // Before uploading, check whether the uploaded file does not yet exist.
+ let body = reqwest::blocking::get(server.url().join(upload_dir)?)?.error_for_status()?;
+ let parsed = Document::from_read(body)?;
+ assert!(parsed.find(Text).all(|x| x.text() != test_file_name));
+
+ // Perform the actual upload.
+ let upload_action = parsed
+ .find(Attr("id", "file_submit"))
+ .next()
+ .expect("Couldn't find element with id=file_submit")
+ .attr("action")
+ .expect("Upload form doesn't have action attribute");
+ let form = multipart::Form::new();
+ let part = multipart::Part::text("this should be uploaded")
+ .file_name(test_file_name)
+ .mime_str("text/plain")?;
+ let form = form.part("file_to_upload", part);
+
+ let client = Client::new();
+ client
+ .post(server.url().join(upload_action)?)
+ .multipart(form)
+ .send()?
+ .error_for_status()?;
+
+ // After uploading, check whether the uploaded file is now getting listed.
+ let body = reqwest::blocking::get(server.url().join(upload_dir)?)?;
+ let parsed = Document::from_read(body)?;
+ assert!(parsed.find(Text).any(|x| x.text() == test_file_name));
+ }
+ Ok(())
+}
+
/// Test for path traversal vulnerability (CWE-22) in both path parameter of query string and in
/// file name (Content-Disposition)
///
@@ -98,7 +186,6 @@ fn prevent_path_traversal_attacks(
#[case] expected: &str,
) -> Result<(), Error> {
// Create test directories
- use std::fs::create_dir_all;
create_dir_all(server.path().join("foo")).unwrap();
if !cfg!(windows) {
for dir in &["C:/foo/C:", r"C:\foo", r"\foo"] {