Skip to content

Commit

Permalink
Merge pull request #1228 from cyqsimon/upload-refactor
Browse files Browse the repository at this point in the history
Minor refactor on upload code
  • Loading branch information
svenstaro authored Sep 24, 2023
2 parents aaffeeb + e193705 commit fa15976
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 168 deletions.
30 changes: 17 additions & 13 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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(".")),
Expand All @@ -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,
Expand Down
152 changes: 44 additions & 108 deletions src/file_upload.rs → src/file_op.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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| {
Expand All @@ -135,39 +146,41 @@ 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
/// invalid.
/// 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)
})?;
Expand Down Expand Up @@ -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);
}
}
84 changes: 84 additions & 0 deletions src/file_utils.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 6 additions & 7 deletions src/listing.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -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()
}
}
}
5 changes: 3 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
Loading

0 comments on commit fa15976

Please sign in to comment.