aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSven-Hendrik Haase <svenstaro@gmail.com>2023-09-24 11:21:29 +0000
committerGitHub <noreply@github.com>2023-09-24 11:21:29 +0000
commitfa15976c1b4b070ad1bb8cecff23b7d571959852 (patch)
tree8a6e756e84f4d301d0829db5cd3976d888030215
parentMerge pull request #1237 from svenstaro/fix-ci (diff)
parentFix clippy complaints (diff)
downloadminiserve-fa15976c1b4b070ad1bb8cecff23b7d571959852.tar.gz
miniserve-fa15976c1b4b070ad1bb8cecff23b7d571959852.zip
Merge pull request #1228 from cyqsimon/upload-refactor
Minor refactor on upload code
-rw-r--r--src/config.rs30
-rw-r--r--src/file_op.rs (renamed from src/file_upload.rs)152
-rw-r--r--src/file_utils.rs84
-rw-r--r--src/listing.rs13
-rw-r--r--src/main.rs5
-rw-r--r--src/renderer.rs4
-rw-r--r--tests/readme.rs72
7 files changed, 192 insertions, 168 deletions
diff --git a/src/config.rs b/src/config.rs
index 1b5e07f..8a8a876 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -16,7 +16,7 @@ use rustls_pemfile as pemfile;
use crate::{
args::{parse_auth, CliArgs, MediaType},
auth::RequiredAuth,
- file_upload::sanitize_path,
+ file_utils::sanitize_path,
renderer::ThemeSlug,
};
@@ -252,6 +252,21 @@ impl MiniserveConfig {
})
});
+ let allowed_upload_dir = args
+ .allowed_upload_dir
+ .as_ref()
+ .map(|v| {
+ v.iter()
+ .map(|p| {
+ sanitize_path(p, false)
+ .map(|p| p.display().to_string().replace('\\', "/"))
+ .ok_or(anyhow!("Illegal path {p:?}: upward traversal not allowed"))
+ })
+ .collect()
+ })
+ .transpose()?
+ .unwrap_or_default();
+
Ok(MiniserveConfig {
verbose: args.verbose,
path: args.path.unwrap_or_else(|| PathBuf::from(".")),
@@ -273,18 +288,7 @@ impl MiniserveConfig {
show_qrcode: args.qrcode,
mkdir_enabled: args.mkdir_enabled,
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(),
+ allowed_upload_dir,
uploadable_media_type,
tar_enabled: args.enable_tar,
tar_gz_enabled: args.enable_tar_gz,
diff --git a/src/file_upload.rs b/src/file_op.rs
index 2275c73..d9786c4 100644
--- a/src/file_upload.rs
+++ b/src/file_op.rs
@@ -1,13 +1,18 @@
+//! Handlers for file upload and removal
+
use std::{
io::Write,
path::{Component, Path, PathBuf},
};
-use actix_web::{http::header, HttpRequest, HttpResponse};
+use actix_web::{http::header, web, HttpRequest, HttpResponse};
use futures::TryStreamExt;
+use serde::Deserialize;
-use crate::errors::ContextualError;
-use crate::listing;
+use crate::{
+ config::MiniserveConfig, errors::ContextualError, file_utils::contains_symlink,
+ file_utils::sanitize_path,
+};
/// Saves file data from a multipart form field (`field`) to `file_path`, optionally overwriting
/// existing file.
@@ -110,10 +115,16 @@ async fn handle_multipart(
})?;
// Ensure there are no illegal symlinks
- if !allow_symlinks && contains_symlink(&absolute_path) {
- return Err(ContextualError::InsufficientPermissionsError(
- user_given_path.display().to_string(),
- ));
+ if !allow_symlinks {
+ match contains_symlink(&absolute_path) {
+ Err(err) => Err(ContextualError::InsufficientPermissionsError(
+ err.to_string(),
+ ))?,
+ Ok(true) => Err(ContextualError::InsufficientPermissionsError(format!(
+ "{user_given_path:?} traverses through a symlink"
+ )))?,
+ Ok(false) => (),
+ }
}
std::fs::create_dir_all(&absolute_path).map_err(|e| {
@@ -135,15 +146,27 @@ async fn handle_multipart(
})?;
// Ensure there are no illegal symlinks in the file upload path
- if !allow_symlinks && contains_symlink(&path) {
- return Err(ContextualError::InsufficientPermissionsError(
- filename.to_string(),
- ));
+ if !allow_symlinks {
+ match contains_symlink(&path) {
+ Err(err) => Err(ContextualError::InsufficientPermissionsError(
+ err.to_string(),
+ ))?,
+ Ok(true) => Err(ContextualError::InsufficientPermissionsError(format!(
+ "{path:?} traverses through a symlink"
+ )))?,
+ Ok(false) => (),
+ }
}
save_file(field, path.join(filename_path), overwrite_files).await
}
+/// Query parameters used by upload and rm APIs
+#[derive(Deserialize, Default)]
+pub struct FileOpQueryParameters {
+ path: PathBuf,
+}
+
/// 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
@@ -151,23 +174,13 @@ async fn handle_multipart(
/// This method returns future.
pub async fn upload_file(
req: HttpRequest,
- payload: actix_web::web::Payload,
+ query: web::Query<FileOpQueryParameters>,
+ payload: web::Payload,
) -> Result<HttpResponse, ContextualError> {
- let conf = req.app_data::<crate::MiniserveConfig>().unwrap();
- let return_path = if let Some(header) = req.headers().get(header::REFERER) {
- header.to_str().unwrap_or("/").to_owned()
- } else {
- "/".to_string()
- };
-
- let query_params = listing::extract_query_parameters(&req);
- let upload_path = query_params.path.as_ref().ok_or_else(|| {
- ContextualError::InvalidHttpRequestError("Missing query parameter 'path'".to_string())
- })?;
- let upload_path = sanitize_path(upload_path, conf.show_hidden).ok_or_else(|| {
+ let conf = req.app_data::<MiniserveConfig>().unwrap();
+ let upload_path = sanitize_path(&query.path, conf.show_hidden).ok_or_else(|| {
ContextualError::InvalidPathError("Invalid value for 'path' parameter".to_string())
})?;
-
let app_root_dir = conf.path.canonicalize().map_err(|e| {
ContextualError::IoError("Failed to resolve path served by miniserve".to_string(), e)
})?;
@@ -210,90 +223,13 @@ pub async fn upload_file(
.try_collect::<Vec<u64>>()
.await?;
+ let return_path = req
+ .headers()
+ .get(header::REFERER)
+ .and_then(|h| h.to_str().ok())
+ .unwrap_or("/");
+
Ok(HttpResponse::SeeOther()
.append_header((header::LOCATION, return_path))
.finish())
}
-
-/// Guarantee that the path is relative and cannot traverse back to parent directories
-/// and optionally prevent traversing hidden directories.
-///
-/// See the unit tests tests::test_sanitize_path* for examples
-pub fn sanitize_path(path: &Path, traverse_hidden: bool) -> Option<PathBuf> {
- let mut buf = PathBuf::new();
-
- for comp in path.components() {
- match comp {
- Component::Normal(name) => buf.push(name),
- Component::ParentDir => {
- buf.pop();
- }
- _ => (),
- }
- }
-
- // Double-check that all components are Normal and check for hidden dirs
- for comp in buf.components() {
- match comp {
- Component::Normal(_) if traverse_hidden => (),
- Component::Normal(name) if !name.to_str()?.starts_with('.') => (),
- _ => return None,
- }
- }
-
- 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::*;
- use pretty_assertions::assert_eq;
- use rstest::rstest;
-
- #[rstest]
- #[case("/foo", "foo")]
- #[case("////foo", "foo")]
- #[case("C:/foo", if cfg!(windows) { "foo" } else { "C:/foo" })]
- #[case("../foo", "foo")]
- #[case("../foo/../bar/abc", "bar/abc")]
- fn test_sanitize_path(#[case] input: &str, #[case] output: &str) {
- assert_eq!(
- sanitize_path(Path::new(input), true).unwrap(),
- Path::new(output)
- );
- assert_eq!(
- sanitize_path(Path::new(input), false).unwrap(),
- Path::new(output)
- );
- }
-
- #[rstest]
- #[case(".foo")]
- #[case("/.foo")]
- #[case("foo/.bar/foo")]
- fn test_sanitize_path_no_hidden_files(#[case] input: &str) {
- assert_eq!(sanitize_path(Path::new(input), false), None);
- }
-}
diff --git a/src/file_utils.rs b/src/file_utils.rs
new file mode 100644
index 0000000..114a08f
--- /dev/null
+++ b/src/file_utils.rs
@@ -0,0 +1,84 @@
+use std::{
+ io,
+ path::{Component, Path, PathBuf},
+};
+
+/// Guarantee that the path is relative and cannot traverse back to parent directories
+/// and optionally prevent traversing hidden directories.
+///
+/// See the unit tests tests::test_sanitize_path* for examples
+pub fn sanitize_path(path: impl AsRef<Path>, traverse_hidden: bool) -> Option<PathBuf> {
+ let mut buf = PathBuf::new();
+
+ for comp in path.as_ref().components() {
+ match comp {
+ Component::Normal(name) => buf.push(name),
+ Component::ParentDir => {
+ buf.pop();
+ }
+ _ => (),
+ }
+ }
+
+ // Double-check that all components are Normal and check for hidden dirs
+ for comp in buf.components() {
+ match comp {
+ Component::Normal(_) if traverse_hidden => (),
+ Component::Normal(name) if !name.to_str()?.starts_with('.') => (),
+ _ => return None,
+ }
+ }
+
+ Some(buf)
+}
+
+/// Checks if any segment of the path is a symlink.
+///
+/// This function fails if [`std::fs::symlink_metadata`] fails, which usually
+/// means user has no permission to access the path.
+pub fn contains_symlink(path: impl AsRef<Path>) -> io::Result<bool> {
+ let contains_symlink = path
+ .as_ref()
+ .ancestors()
+ // On Windows, `\\?\` won't exist even though it's the root, but there's no need to check it
+ // So we filter it out
+ .filter(|p| p.exists())
+ .map(|p| p.symlink_metadata())
+ .collect::<Result<Vec<_>, _>>()?
+ .into_iter()
+ .any(|p| p.file_type().is_symlink());
+
+ Ok(contains_symlink)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ #[case("/foo", "foo")]
+ #[case("////foo", "foo")]
+ #[case("C:/foo", if cfg!(windows) { "foo" } else { "C:/foo" })]
+ #[case("../foo", "foo")]
+ #[case("../foo/../bar/abc", "bar/abc")]
+ fn test_sanitize_path(#[case] input: &str, #[case] output: &str) {
+ assert_eq!(
+ sanitize_path(Path::new(input), true).unwrap(),
+ Path::new(output)
+ );
+ assert_eq!(
+ sanitize_path(Path::new(input), false).unwrap(),
+ Path::new(output)
+ );
+ }
+
+ #[rstest]
+ #[case(".foo")]
+ #[case("/.foo")]
+ #[case("foo/.bar/foo")]
+ fn test_sanitize_path_no_hidden_files(#[case] input: &str) {
+ assert_eq!(sanitize_path(Path::new(input), false), None);
+ }
+}
diff --git a/src/listing.rs b/src/listing.rs
index e360368..a8feeb4 100644
--- a/src/listing.rs
+++ b/src/listing.rs
@@ -1,6 +1,6 @@
#![allow(clippy::format_push_string)]
use std::io;
-use std::path::{Component, Path, PathBuf};
+use std::path::{Component, Path};
use std::time::SystemTime;
use actix_web::{dev::ServiceResponse, web::Query, HttpMessage, HttpRequest, HttpResponse};
@@ -28,10 +28,9 @@ mod percent_encode_sets {
pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'\\');
}
-/// Query parameters
+/// Query parameters used by listing APIs
#[derive(Deserialize, Default)]
-pub struct QueryParameters {
- pub path: Option<PathBuf>,
+pub struct ListingQueryParameters {
pub sort: Option<SortingMethod>,
pub order: Option<SortingOrder>,
pub raw: Option<bool>,
@@ -393,13 +392,13 @@ pub fn directory_listing(
}
}
-pub fn extract_query_parameters(req: &HttpRequest) -> QueryParameters {
- match Query::<QueryParameters>::from_query(req.query_string()) {
+pub fn extract_query_parameters(req: &HttpRequest) -> ListingQueryParameters {
+ match Query::<ListingQueryParameters>::from_query(req.query_string()) {
Ok(Query(query_params)) => query_params,
Err(e) => {
let err = ContextualError::ParseError("query parameters".to_string(), e.to_string());
errors::log_error_chain(err.to_string());
- QueryParameters::default()
+ ListingQueryParameters::default()
}
}
}
diff --git a/src/main.rs b/src/main.rs
index 78e8472..6bbebd7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -22,7 +22,8 @@ mod auth;
mod config;
mod consts;
mod errors;
-mod file_upload;
+mod file_op;
+mod file_utils;
mod listing;
mod pipe;
mod renderer;
@@ -366,7 +367,7 @@ fn configure_app(app: &mut web::ServiceConfig, conf: &MiniserveConfig) {
} else {
if conf.file_upload {
// Allow file upload
- app.service(web::resource("/upload").route(web::post().to(file_upload::upload_file)));
+ app.service(web::resource("/upload").route(web::post().to(file_op::upload_file)));
}
// Handle directories
app.service(dir_service());
diff --git a/src/renderer.rs b/src/renderer.rs
index 028925b..5a591f0 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -15,7 +15,7 @@ use strum::{Display, IntoEnumIterator};
use crate::auth::CurrentUser;
use crate::consts;
-use crate::listing::{Breadcrumb, Entry, QueryParameters, SortingMethod, SortingOrder};
+use crate::listing::{Breadcrumb, Entry, ListingQueryParameters, SortingMethod, SortingOrder};
use crate::{archive::ArchiveMethod, MiniserveConfig};
#[allow(clippy::too_many_arguments)]
@@ -25,7 +25,7 @@ pub fn page(
readme: Option<(String, String)>,
abs_uri: &Uri,
is_root: bool,
- query_params: QueryParameters,
+ query_params: ListingQueryParameters,
breadcrumbs: &[Breadcrumb],
encoded_dir: &str,
conf: &MiniserveConfig,
diff --git a/tests/readme.rs b/tests/readme.rs
index 7faea7b..c8138b4 100644
--- a/tests/readme.rs
+++ b/tests/readme.rs
@@ -8,6 +8,42 @@ use std::fs::{remove_file, File};
use std::io::Write;
use std::path::PathBuf;
+fn write_readme_contents(path: PathBuf, filename: &str) -> PathBuf {
+ let readme_path = path.join(filename);
+ let mut readme_file = File::create(&readme_path).unwrap();
+ readme_file
+ .write_all(format!("Contents of {filename}").as_bytes())
+ .expect("Couldn't write readme");
+ readme_path
+}
+
+fn assert_readme_contents(parsed_dom: &Document, filename: &str) {
+ assert!(parsed_dom.find(Attr("id", "readme")).next().is_some());
+ assert!(parsed_dom
+ .find(Attr("id", "readme-filename"))
+ .next()
+ .is_some());
+ assert!(
+ parsed_dom
+ .find(Attr("id", "readme-filename"))
+ .next()
+ .unwrap()
+ .text()
+ == filename
+ );
+ assert!(parsed_dom
+ .find(Attr("id", "readme-contents"))
+ .next()
+ .is_some());
+ assert!(parsed_dom
+ .find(Attr("id", "readme-contents"))
+ .next()
+ .unwrap()
+ .text()
+ .trim()
+ .contains(&format!("Contents of {filename}")));
+}
+
/// Do not show readme contents by default
#[rstest]
fn no_readme_contents(server: TestServer) -> Result<(), Error> {
@@ -89,39 +125,3 @@ fn show_nested_readme_contents(
}
Ok(())
}
-
-fn write_readme_contents(path: PathBuf, filename: &str) -> PathBuf {
- let readme_path = path.join(filename);
- let mut readme_file = File::create(&readme_path).unwrap();
- readme_file
- .write_all(format!("Contents of {filename}").as_bytes())
- .expect("Couldn't write readme");
- readme_path
-}
-
-fn assert_readme_contents(parsed_dom: &Document, filename: &str) {
- assert!(parsed_dom.find(Attr("id", "readme")).next().is_some());
- assert!(parsed_dom
- .find(Attr("id", "readme-filename"))
- .next()
- .is_some());
- assert!(
- parsed_dom
- .find(Attr("id", "readme-filename"))
- .next()
- .unwrap()
- .text()
- == filename
- );
- assert!(parsed_dom
- .find(Attr("id", "readme-contents"))
- .next()
- .is_some());
- assert!(parsed_dom
- .find(Attr("id", "readme-contents"))
- .next()
- .unwrap()
- .text()
- .trim()
- .contains(&format!("Contents of {filename}")));
-}