Static Web Server is vulnerable to symbolic link Path Traversal
Description
Static Web Server (SWS) is a production-ready web server suitable for static web files or assets. Versions 2.40.0 and below contain symbolic links (symlinks) which can be used to access files or directories outside the intended web root folder. SWS generally does not prevent symlinks from escaping the web server’s root directory. Therefore, if a malicious actor gains access to the web server’s root directory, they could create symlinks to access other files outside the designated web root folder either by URL or via the directory listing. This issue is fixed in version 2.40.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
static-web-servercrates.io | < 2.40.1 | 2.40.1 |
Affected products
1- Range: < 2.40.1
Patches
1308f0d26ceb9Merge commit from fork
17 files changed · +914 −647
src/directory_listing/autoindex/html.rs+197 −0 added@@ -0,0 +1,197 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// This file is part of Static Web Server. +// See https://static-web-server.net/ for more information +// Copyright (C) 2019-present Jose Quintana <joseluisq.net> + +use percent_encoding::percent_decode_str; + +use crate::directory_listing::file::{DATETIME_FORMAT_LOCAL, FileEntry}; +use crate::directory_listing::sort::sort_file_entries; +use crate::directory_listing::style::STYLES; + +#[cfg(feature = "directory-listing-download")] +use crate::directory_listing_download::{DOWNLOAD_PARAM_KEY, DirDownloadFmt}; + +/// Create an auto index in HTML format. +pub(crate) fn html_auto_index<'a>( + base_path: &'a str, + dirs_count: usize, + files_count: usize, + entries: &'a mut [FileEntry], + order_code: u8, + #[cfg(feature = "directory-listing-download")] download: &'a [DirDownloadFmt], +) -> String { + use maud::{DOCTYPE, html}; + + let sort_attrs = sort_file_entries(entries, order_code); + let current_path = percent_decode_str(base_path).decode_utf8_lossy(); + + #[cfg(feature = "directory-listing-download")] + let download_directory_elem = match download.is_empty() { + true => html! {}, + false => html! { + ", " a href={ "?" (DOWNLOAD_PARAM_KEY) } { + "download tar.gz" + } + }, + }; + #[cfg(not(feature = "directory-listing-download"))] + let download_directory_elem = html! {}; + + let styles = STYLES.replace("\n", "").replace(" ", ""); + html! { + (DOCTYPE) + html { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"; + title { + "Index of " (current_path) + } + style { + (styles) + } + } + body { + h1 { + "Index of " (current_path) + } + p { + small { + "directories: " (dirs_count) ", files: " (files_count) (download_directory_elem) + } + } + hr; + div style="overflow-x: auto;" { + table { + thead { + tr { + th { + a href={ "?sort=" (sort_attrs.name) } { + "Name" + } + } + th style="width:10rem;" { + a href={ "?sort=" (sort_attrs.last_modified) } { + "Last modified" + } + } + th style="width:6rem;text-align:right;" { + a href={ "?sort=" (sort_attrs.size) } { + "Size" + } + } + } + } + + @if base_path != "/" { + tr { + td colspan="3" { + a href="../" { + "../" + } + } + } + } + + @for entry in entries { + tr { + td { + a href=(entry.uri) { + (entry.name.to_string_lossy()) + @if entry.is_dir() { + "/" + } + } + } + td { + (entry.mtime.map_or("-".to_owned(), |local_dt| { + local_dt.format(DATETIME_FORMAT_LOCAL).to_string() + })) + } + td align="right" { + (entry.size.map(format_file_size).unwrap_or("-".into())) + } + } + } + } + } + hr; + footer { + small { + "Powered by Static Web Server (SWS) / static-web-server.net" + } + } + } + } + }.into() +} + +/// Formats the file size in bytes to a human-readable string +fn format_file_size(size: u64) -> String { + const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let mut size_tmp = size; + + if size_tmp < 1024 { + // return the size with Byte + return format!("{} {}", size_tmp, UNITS[0]); + } + + for unit in &UNITS[1..UNITS.len() - 1] { + if size_tmp < 1024 * 1024 { + // return the size divided by 1024 with the unit + return format!("{:.2} {}", size_tmp as f64 / 1024.0, unit); + } + size_tmp >>= 10; + } + + // if size is too large, return the largest unit + format!("{:.2} {}", size_tmp as f64 / 1024.0, UNITS[UNITS.len() - 1]) +} + +#[cfg(test)] +mod tests { + use super::format_file_size; + + #[test] + fn handle_byte() { + let size = 128; + assert_eq!("128 B", format_file_size(size)) + } + + #[test] + fn handle_kibibyte() { + let size = 1024; + assert_eq!("1.00 KiB", format_file_size(size)) + } + + #[test] + fn handle_mebibyte() { + let size = 1048576; + assert_eq!("1.00 MiB", format_file_size(size)) + } + + #[test] + fn handle_gibibyte() { + let size = 1073741824; + assert_eq!("1.00 GiB", format_file_size(size)) + } + + #[test] + fn handle_tebibyte() { + let size = 1099511627776; + assert_eq!("1.00 TiB", format_file_size(size)) + } + + #[test] + fn handle_pebibyte() { + let size = 1125899906842624; + assert_eq!("1.00 PiB", format_file_size(size)) + } + + #[test] + fn handle_large() { + let size = u64::MAX; + assert_eq!("16384.00 PiB", format_file_size(size)) + } +}
src/directory_listing/autoindex/json.rs+15 −0 added@@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// This file is part of Static Web Server. +// See https://static-web-server.net/ for more information +// Copyright (C) 2019-present Jose Quintana <joseluisq.net> + +use crate::Result; +use crate::directory_listing::file::FileEntry; +use crate::directory_listing::sort::sort_file_entries; + +/// Create an auto index in JSON format. +pub(crate) fn json_auto_index(entries: &mut [FileEntry], order_code: u8) -> Result<String> { + sort_file_entries(entries, order_code); + + Ok(serde_json::to_string(entries)?) +}
src/directory_listing/autoindex/mod.rs+84 −0 added@@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// This file is part of Static Web Server. +// See https://static-web-server.net/ for more information +// Copyright (C) 2019-present Jose Quintana <joseluisq.net> + +mod html; +mod json; + +pub(crate) use html::html_auto_index; +pub(crate) use json::json_auto_index; + +use hyper::{Body, Response, StatusCode}; +use std::io; + +use crate::Result; +use crate::directory_listing::dir::{DirEntryOpts, DirListOpts, read_dir_entries}; +use crate::http_ext::MethodExt; + +/// Provides directory listing support for the current request. +/// Note that this function highly depends on `static_files::composed_file_metadata()` function +/// which must be called first. See `static_files::handle()` for more details. +pub fn auto_index(opts: DirListOpts<'_>) -> Result<Response<Body>, StatusCode> { + // Note: it's safe to call `parent()` here since `filepath` + // value always refer to a path with file ending and under + // a root directory boundary. + // See `composed_file_metadata()` function which sanitizes the requested + // path before to be delegated here. + let filepath = opts.filepath; + let parent = filepath.parent().unwrap_or(filepath); + + match std::fs::read_dir(parent) { + Ok(dir_reader) => { + let dir_opts = DirEntryOpts { + root_path: opts.root_path, + dir_reader, + base_path: opts.current_path, + uri_query: opts.uri_query, + is_head: opts.method.is_head(), + order_code: opts.dir_listing_order, + content_format: opts.dir_listing_format, + ignore_hidden_files: opts.ignore_hidden_files, + disable_symlinks: opts.disable_symlinks, + #[cfg(feature = "directory-listing-download")] + download: opts.dir_listing_download, + }; + match read_dir_entries(dir_opts) { + Ok(resp) => Ok(resp), + Err(err) => { + tracing::error!("error after try to read directory entries: {:?}", err); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } + } + Err(err) => { + let status = match err.kind() { + io::ErrorKind::NotFound => { + tracing::debug!( + "entry file not found (path: {}): {:?}", + filepath.display(), + err + ); + StatusCode::NOT_FOUND + } + io::ErrorKind::PermissionDenied => { + tracing::error!( + "entry file permission denied (path: {}): {:?}", + filepath.display(), + err + ); + StatusCode::FORBIDDEN + } + _ => { + tracing::error!( + "unable to read parent directory (parent={}): {:?}", + parent.display(), + err + ); + StatusCode::INTERNAL_SERVER_ERROR + } + }; + Err(status) + } + } +}
src/directory_listing/dir.rs+257 −0 added@@ -0,0 +1,257 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// This file is part of Static Web Server. +// See https://static-web-server.net/ for more information +// Copyright (C) 2019-present Jose Quintana <joseluisq.net> + +use chrono::{DateTime, Local}; +use clap::ValueEnum; +use headers::{ContentLength, ContentType, HeaderMapExt}; +use hyper::Method; +use hyper::{Body, Response}; +use mime_guess::mime; +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_encode}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +use crate::directory_listing::autoindex::{html_auto_index, json_auto_index}; +use crate::directory_listing::file::{FileEntry, FileType}; +use crate::{Context, Result}; + +#[cfg(feature = "directory-listing-download")] +use crate::directory_listing_download::DirDownloadFmt; + +/// Non-alphanumeric characters to be percent-encoded +/// excluding the "unreserved characters" because allowed in a URI. +/// See 2.3. Unreserved Characters - <https://www.ietf.org/rfc/rfc3986.txt> +const PERCENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'_') + .remove(b'-') + .remove(b'.') + .remove(b'~'); + +/// Directory listing output format for file entries. +#[derive(Debug, Serialize, Deserialize, Clone, ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum DirListFmt { + /// HTML format to display (default). + Html, + /// JSON format to display. + Json, +} + +/// Directory listing options. +pub struct DirListOpts<'a> { + /// Request method. + pub root_path: &'a Path, + /// Request method. + pub method: &'a Method, + /// Current Request path. + pub current_path: &'a str, + /// URI Request query + pub uri_query: Option<&'a str>, + /// Request file path. + pub filepath: &'a Path, + /// Directory listing order. + pub dir_listing_order: u8, + /// Directory listing format. + pub dir_listing_format: &'a DirListFmt, + #[cfg(feature = "directory-listing-download")] + /// Directory listing download. + pub dir_listing_download: &'a [DirDownloadFmt], + /// Ignore hidden files (dotfiles). + pub ignore_hidden_files: bool, + /// Prevent following symlinks for files and directories. + pub disable_symlinks: bool, +} + +/// Defines read directory entries. +pub(crate) struct DirEntryOpts<'a> { + pub(crate) root_path: &'a Path, + pub(crate) dir_reader: std::fs::ReadDir, + pub(crate) base_path: &'a str, + pub(crate) uri_query: Option<&'a str>, + pub(crate) is_head: bool, + pub(crate) order_code: u8, + pub(crate) content_format: &'a DirListFmt, + pub(crate) ignore_hidden_files: bool, + pub(crate) disable_symlinks: bool, + #[cfg(feature = "directory-listing-download")] + pub(crate) download: &'a [DirDownloadFmt], +} + +/// It reads a list of directory entries and create an index page content. +/// Otherwise it returns a status error. +pub(crate) fn read_dir_entries(mut opt: DirEntryOpts<'_>) -> Result<Response<Body>> { + let mut dirs_count: usize = 0; + let mut files_count: usize = 0; + let mut file_entries: Vec<FileEntry> = vec![]; + let root_path_abs = opt.root_path.canonicalize()?; + + for dir_entry in opt.dir_reader { + let dir_entry = dir_entry.with_context(|| "unable to read directory entry")?; + let meta = match dir_entry.metadata() { + Ok(m) => m, + Err(err) => { + tracing::error!( + "unable to resolve metadata for file or directory entry (skipped): {:?}", + err + ); + continue; + } + }; + + let name = dir_entry.file_name(); + + // Check and ignore the current hidden file/directory (dotfile) if feature enabled + if opt.ignore_hidden_files && name.as_encoded_bytes().first().is_some_and(|c| *c == b'.') { + continue; + } + + let (r#type, size) = if meta.is_dir() { + dirs_count += 1; + (FileType::Directory, None) + } else if meta.is_file() { + files_count += 1; + (FileType::File, Some(meta.len())) + } else if !opt.disable_symlinks && meta.file_type().is_symlink() { + // NOTE: we resolve the symlink path below to just know if is a directory or not. + // However, we are still showing the symlink name but not the resolved name. + + let symlink_path = dir_entry.path(); + let symlink_path = match symlink_path.canonicalize() { + Ok(v) => v, + Err(err) => { + tracing::error!( + "unable resolve symlink path for `{}` (skipped): {:?}", + symlink_path.display(), + err, + ); + continue; + } + }; + if !symlink_path.starts_with(&root_path_abs) { + tracing::warn!( + "unable to follow symlink {}, access denied", + symlink_path.display() + ); + continue; + } + let symlink_meta = match std::fs::symlink_metadata(&symlink_path) { + Ok(v) => v, + Err(err) => { + tracing::error!( + "unable to resolve metadata for `{}` symlink (skipped): {:?}", + symlink_path.display(), + err, + ); + continue; + } + }; + if symlink_meta.is_dir() { + dirs_count += 1; + (FileType::Directory, None) + } else { + files_count += 1; + (FileType::File, Some(symlink_meta.len())) + } + } else { + continue; + }; + + let name_encoded = percent_encode(name.as_encoded_bytes(), PERCENT_ENCODE_SET).to_string(); + + // NOTE: Use relative paths by default independently of + // the "redirect trailing slash" feature. + // However, when "redirect trailing slash" is disabled + // and a request path doesn't contain a trailing slash then + // entries should contain the "parent/entry-name" as a link format. + // Otherwise, we just use the "entry-name" as a link (default behavior). + // Note that in both cases, we add a trailing slash if the entry is a directory. + let mut uri = if !opt.base_path.ends_with('/') && !opt.base_path.is_empty() { + let parent = opt + .base_path + .rsplit_once('/') + .map(|(_, parent)| parent) + .unwrap_or(opt.base_path); + format!("{parent}/{name_encoded}") + } else { + name_encoded + }; + + if r#type == FileType::Directory { + uri.push('/'); + } + + let mtime = meta.modified().ok().map(DateTime::<Local>::from); + + let entry = FileEntry { + name, + mtime, + size, + r#type, + uri, + }; + file_entries.push(entry); + } + + // Check the query request uri for a sorting type. E.g https://blah/?sort=5 + if let Some(q) = opt.uri_query { + let mut parts = form_urlencoded::parse(q.as_bytes()); + if parts.count() > 0 { + // NOTE: we just pick up the first value (pair) + if let Some(sort) = parts.next() { + if sort.0 == "sort" && !sort.1.trim().is_empty() { + match sort.1.parse::<u8>() { + Ok(code) => opt.order_code = code, + Err(err) => { + tracing::error!( + "sorting: query value error when converting to u8: {:?}", + err + ); + } + } + } + } + } + } + + let mut resp = Response::new(Body::empty()); + + // Handle directory listing content format + let content = match opt.content_format { + DirListFmt::Json => { + // JSON + resp.headers_mut() + .typed_insert(ContentType::from(mime::APPLICATION_JSON)); + + json_auto_index(&mut file_entries, opt.order_code)? + } + // HTML (default) + _ => { + resp.headers_mut() + .typed_insert(ContentType::from(mime::TEXT_HTML_UTF_8)); + + html_auto_index( + opt.base_path, + dirs_count, + files_count, + &mut file_entries, + opt.order_code, + #[cfg(feature = "directory-listing-download")] + opt.download, + ) + } + }; + + resp.headers_mut() + .typed_insert(ContentLength(content.len() as u64)); + + // We skip the body for HEAD requests + if opt.is_head { + return Ok(resp); + } + + *resp.body_mut() = Body::from(content); + + Ok(resp) +}
src/directory_listing/file.rs+58 −0 added@@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// This file is part of Static Web Server. +// See https://static-web-server.net/ for more information +// Copyright (C) 2019-present Jose Quintana <joseluisq.net> + +use chrono::{DateTime, Local, Utc}; +use serde::{Serialize, Serializer}; +use std::ffi::{OsStr, OsString}; + +pub(crate) const DATETIME_FORMAT_UTC: &str = "%FT%TZ"; +pub(crate) const DATETIME_FORMAT_LOCAL: &str = "%F %T"; + +#[derive(Serialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum FileType { + Directory, + File, +} + +/// Defines a file entry and its properties. +#[derive(Serialize)] +pub(crate) struct FileEntry { + #[serde(serialize_with = "serialize_name")] + pub(crate) name: OsString, + #[serde(serialize_with = "serialize_mtime")] + pub(crate) mtime: Option<DateTime<Local>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) size: Option<u64>, + pub(crate) r#type: FileType, + #[serde(skip_serializing)] + pub(crate) uri: String, +} + +impl FileEntry { + pub(crate) fn is_dir(&self) -> bool { + self.r#type == FileType::Directory + } +} + +/// Serialize FileEntry::name +fn serialize_name<S: Serializer>(name: &OsStr, serializer: S) -> Result<S::Ok, S::Error> { + serializer.serialize_str(&name.to_string_lossy()) +} + +/// Serialize FileEntry::mtime field +fn serialize_mtime<S: Serializer>( + mtime: &Option<DateTime<Local>>, + serializer: S, +) -> Result<S::Ok, S::Error> { + match mtime { + Some(dt) => serializer.serialize_str( + &dt.with_timezone(&Utc) + .format(DATETIME_FORMAT_UTC) + .to_string(), + ), + None => serializer.serialize_str(""), + } +}
src/directory_listing/mod.rs+33 −0 added@@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// This file is part of Static Web Server. +// See https://static-web-server.net/ for more information +// Copyright (C) 2019-present Jose Quintana <joseluisq.net> + +//! Module that provides directory listing and auto-index support. +//! + +mod autoindex; +mod dir; +mod file; +mod sort; +mod style; + +pub(crate) use autoindex::*; +pub use dir::*; + +use crate::handler::RequestHandlerOpts; + +/// Initializes directory listings. +pub fn init(enabled: bool, order: u8, format: DirListFmt, handler_opts: &mut RequestHandlerOpts) { + handler_opts.dir_listing = enabled; + tracing::info!("directory listing: enabled={enabled}"); + + handler_opts.dir_listing_order = order; + tracing::info!("directory listing order code: {order}"); + + handler_opts.dir_listing_format = format; + tracing::info!( + "directory listing format: {:?}", + handler_opts.dir_listing_format + ); +}
src/directory_listing.rs+0 −624 removed@@ -1,624 +0,0 @@ -// SPDX-License-Identifier: MIT OR Apache-2.0 -// This file is part of Static Web Server. -// See https://static-web-server.net/ for more information -// Copyright (C) 2019-present Jose Quintana <joseluisq.net> - -//! It provides directory listing and auto-index support. -//! - -use chrono::{DateTime, Local, Utc}; -use clap::ValueEnum; -use headers::{ContentLength, ContentType, HeaderMapExt}; -use hyper::{Body, Method, Response, StatusCode}; -use mime_guess::mime; -use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, percent_encode}; -use serde::{Serialize, Serializer}; -use std::ffi::{OsStr, OsString}; -use std::io; -use std::path::Path; - -#[cfg(feature = "directory-listing-download")] -use crate::directory_listing_download::{DOWNLOAD_PARAM_KEY, DirDownloadFmt}; - -use crate::{Context, Result, handler::RequestHandlerOpts, http_ext::MethodExt}; - -/// Non-alphanumeric characters to be percent-encoded -/// excluding the "unreserved characters" because allowed in a URI. -/// See 2.3. Unreserved Characters - <https://www.ietf.org/rfc/rfc3986.txt> -const PERCENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC - .remove(b'_') - .remove(b'-') - .remove(b'.') - .remove(b'~'); - -#[derive(Debug, Serialize, Deserialize, Clone, ValueEnum)] -#[serde(rename_all = "lowercase")] -/// Directory listing output format for file entries. -pub enum DirListFmt { - /// HTML format to display (default). - Html, - /// JSON format to display. - Json, -} - -/// Directory listing options. -pub struct DirListOpts<'a> { - /// Request method. - pub method: &'a Method, - /// Current Request path. - pub current_path: &'a str, - /// URI Request query - pub uri_query: Option<&'a str>, - /// Request file path. - pub filepath: &'a Path, - /// Directory listing order. - pub dir_listing_order: u8, - /// Directory listing format. - pub dir_listing_format: &'a DirListFmt, - #[cfg(feature = "directory-listing-download")] - /// Directory listing download. - pub dir_listing_download: &'a [DirDownloadFmt], - /// Ignore hidden files (dotfiles). - pub ignore_hidden_files: bool, - /// Prevent following symlinks for files and directories. - pub disable_symlinks: bool, -} - -/// Initializes directory listings. -pub fn init(enabled: bool, order: u8, format: DirListFmt, handler_opts: &mut RequestHandlerOpts) { - handler_opts.dir_listing = enabled; - tracing::info!("directory listing: enabled={enabled}"); - - handler_opts.dir_listing_order = order; - tracing::info!("directory listing order code: {order}"); - - handler_opts.dir_listing_format = format; - tracing::info!( - "directory listing format: {:?}", - handler_opts.dir_listing_format - ); -} - -/// Provides directory listing support for the current request. -/// Note that this function highly depends on `static_files::composed_file_metadata()` function -/// which must be called first. See `static_files::handle()` for more details. -pub fn auto_index(opts: DirListOpts<'_>) -> Result<Response<Body>, StatusCode> { - // Note: it's safe to call `parent()` here since `filepath` - // value always refer to a path with file ending and under - // a root directory boundary. - // See `composed_file_metadata()` function which sanitizes the requested - // path before to be delegated here. - let filepath = opts.filepath; - let parent = filepath.parent().unwrap_or(filepath); - - match std::fs::read_dir(parent) { - Ok(dir_reader) => { - let dir_opts = DirEntryOpts { - dir_reader, - base_path: opts.current_path, - uri_query: opts.uri_query, - is_head: opts.method.is_head(), - order_code: opts.dir_listing_order, - content_format: opts.dir_listing_format, - ignore_hidden_files: opts.ignore_hidden_files, - disable_symlinks: opts.disable_symlinks, - #[cfg(feature = "directory-listing-download")] - download: opts.dir_listing_download, - }; - match read_dir_entries(dir_opts) { - Ok(resp) => Ok(resp), - Err(err) => { - tracing::error!("error after try to read directory entries: {:?}", err); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } - } - Err(err) => { - let status = match err.kind() { - io::ErrorKind::NotFound => { - tracing::debug!( - "entry file not found (path: {}): {:?}", - filepath.display(), - err - ); - StatusCode::NOT_FOUND - } - io::ErrorKind::PermissionDenied => { - tracing::error!( - "entry file permission denied (path: {}): {:?}", - filepath.display(), - err - ); - StatusCode::FORBIDDEN - } - _ => { - tracing::error!( - "unable to read parent directory (parent={}): {:?}", - parent.display(), - err - ); - StatusCode::INTERNAL_SERVER_ERROR - } - }; - Err(status) - } - } -} - -const DATETIME_FORMAT_UTC: &str = "%FT%TZ"; -const DATETIME_FORMAT_LOCAL: &str = "%F %T"; - -#[derive(Serialize, PartialEq)] -#[serde(rename_all = "lowercase")] -enum FileType { - Directory, - File, -} - -/// Defines a file entry and its properties. -#[derive(Serialize)] -struct FileEntry { - #[serde(serialize_with = "serialize_name")] - name: OsString, - #[serde(serialize_with = "serialize_mtime")] - mtime: Option<DateTime<Local>>, - #[serde(skip_serializing_if = "Option::is_none")] - size: Option<u64>, - r#type: FileType, - #[serde(skip_serializing)] - uri: String, -} - -impl FileEntry { - fn is_dir(&self) -> bool { - self.r#type == FileType::Directory - } -} - -/// Defines sorting attributes for file entries. -struct SortingAttr<'a> { - name: &'a str, - last_modified: &'a str, - size: &'a str, -} - -/// Defines read directory entries. -struct DirEntryOpts<'a> { - dir_reader: std::fs::ReadDir, - base_path: &'a str, - uri_query: Option<&'a str>, - is_head: bool, - order_code: u8, - content_format: &'a DirListFmt, - ignore_hidden_files: bool, - disable_symlinks: bool, - #[cfg(feature = "directory-listing-download")] - download: &'a [DirDownloadFmt], -} - -/// It reads a list of directory entries and create an index page content. -/// Otherwise it returns a status error. -fn read_dir_entries(mut opt: DirEntryOpts<'_>) -> Result<Response<Body>> { - let mut dirs_count: usize = 0; - let mut files_count: usize = 0; - let mut file_entries: Vec<FileEntry> = vec![]; - - for dir_entry in opt.dir_reader { - let dir_entry = dir_entry.with_context(|| "unable to read directory entry")?; - let meta = match dir_entry.metadata() { - Ok(m) => m, - Err(err) => { - tracing::error!( - "unable to resolve metadata for file or directory entry (skipped): {:?}", - err - ); - continue; - } - }; - - let name = dir_entry.file_name(); - - // Check and ignore the current hidden file/directory (dotfile) if feature enabled - if opt.ignore_hidden_files && name.as_encoded_bytes().first().is_some_and(|c| *c == b'.') { - continue; - } - - let (r#type, size) = if meta.is_dir() { - dirs_count += 1; - (FileType::Directory, None) - } else if meta.is_file() { - files_count += 1; - (FileType::File, Some(meta.len())) - } else if !opt.disable_symlinks && meta.file_type().is_symlink() { - // NOTE: we resolve the symlink path below to just know if is a directory or not. - // However, we are still showing the symlink name but not the resolved name. - - let symlink = dir_entry.path(); - let symlink = match symlink.canonicalize() { - Ok(v) => v, - Err(err) => { - tracing::error!( - "unable to resolve `{}` symlink path (skipped): {:?}", - symlink.display(), - err, - ); - continue; - } - }; - - let symlink_meta = match std::fs::symlink_metadata(&symlink) { - Ok(v) => v, - Err(err) => { - tracing::error!( - "unable to resolve metadata for `{}` symlink (skipped): {:?}", - symlink.display(), - err, - ); - continue; - } - }; - if symlink_meta.is_dir() { - dirs_count += 1; - (FileType::Directory, None) - } else { - files_count += 1; - (FileType::File, Some(symlink_meta.len())) - } - } else { - continue; - }; - - let name_encoded = percent_encode(name.as_encoded_bytes(), PERCENT_ENCODE_SET).to_string(); - - // NOTE: Use relative paths by default independently of - // the "redirect trailing slash" feature. - // However, when "redirect trailing slash" is disabled - // and a request path doesn't contain a trailing slash then - // entries should contain the "parent/entry-name" as a link format. - // Otherwise, we just use the "entry-name" as a link (default behavior). - // Note that in both cases, we add a trailing slash if the entry is a directory. - let mut uri = if !opt.base_path.ends_with('/') && !opt.base_path.is_empty() { - let parent = opt - .base_path - .rsplit_once('/') - .map(|(_, parent)| parent) - .unwrap_or(opt.base_path); - format!("{parent}/{name_encoded}") - } else { - name_encoded - }; - - if r#type == FileType::Directory { - uri.push('/'); - } - - let mtime = meta.modified().ok().map(DateTime::<Local>::from); - - let entry = FileEntry { - name, - mtime, - size, - r#type, - uri, - }; - file_entries.push(entry); - } - - // Check the query request uri for a sorting type. E.g https://blah/?sort=5 - if let Some(q) = opt.uri_query { - let mut parts = form_urlencoded::parse(q.as_bytes()); - if parts.count() > 0 { - // NOTE: we just pick up the first value (pair) - if let Some(sort) = parts.next() { - if sort.0 == "sort" && !sort.1.trim().is_empty() { - match sort.1.parse::<u8>() { - Ok(code) => opt.order_code = code, - Err(err) => { - tracing::error!( - "sorting: query value error when converting to u8: {:?}", - err - ); - } - } - } - } - } - } - - let mut resp = Response::new(Body::empty()); - - // Handle directory listing content format - let content = match opt.content_format { - DirListFmt::Json => { - // JSON - resp.headers_mut() - .typed_insert(ContentType::from(mime::APPLICATION_JSON)); - - json_auto_index(&mut file_entries, opt.order_code)? - } - // HTML (default) - _ => { - resp.headers_mut() - .typed_insert(ContentType::from(mime::TEXT_HTML_UTF_8)); - - html_auto_index( - opt.base_path, - dirs_count, - files_count, - &mut file_entries, - opt.order_code, - #[cfg(feature = "directory-listing-download")] - opt.download, - ) - } - }; - - resp.headers_mut() - .typed_insert(ContentLength(content.len() as u64)); - - // We skip the body for HEAD requests - if opt.is_head { - return Ok(resp); - } - - *resp.body_mut() = Body::from(content); - - Ok(resp) -} - -/// Create an auto index in JSON format. -fn json_auto_index(entries: &mut [FileEntry], order_code: u8) -> Result<String> { - sort_file_entries(entries, order_code); - - Ok(serde_json::to_string(entries)?) -} - -/// Serialize FileEntry::name -fn serialize_name<S: Serializer>(name: &OsStr, serializer: S) -> Result<S::Ok, S::Error> { - serializer.serialize_str(&name.to_string_lossy()) -} - -/// Serialize FileEntry::mtime field -fn serialize_mtime<S: Serializer>( - mtime: &Option<DateTime<Local>>, - serializer: S, -) -> Result<S::Ok, S::Error> { - match mtime { - Some(dt) => serializer.serialize_str( - &dt.with_timezone(&Utc) - .format(DATETIME_FORMAT_UTC) - .to_string(), - ), - None => serializer.serialize_str(""), - } -} - -/// Create an auto index in HTML format. -fn html_auto_index<'a>( - base_path: &'a str, - dirs_count: usize, - files_count: usize, - entries: &'a mut [FileEntry], - order_code: u8, - #[cfg(feature = "directory-listing-download")] download: &'a [DirDownloadFmt], -) -> String { - use maud::{DOCTYPE, html}; - - let sort_attrs = sort_file_entries(entries, order_code); - let current_path = percent_decode_str(base_path).decode_utf8_lossy(); - - #[cfg(feature = "directory-listing-download")] - let download_directory_elem = match download.is_empty() { - true => html! {}, - false => html! { - ", " a href={ "?" (DOWNLOAD_PARAM_KEY) } { - "download tar.gz" - } - }, - }; - #[cfg(not(feature = "directory-listing-download"))] - let download_directory_elem = html! {}; - - html! { - (DOCTYPE) - html { - head { - meta charset="utf-8"; - meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"; - title { - "Index of " (current_path) - } - style { - "html{background-color:#fff;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;min-width:20rem;text-rendering:optimizeLegibility;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%}:after,:before{box-sizing:border-box;}body{padding:1rem;font-family:Consolas,'Liberation Mono',Menlo,monospace;font-size:.75rem;max-width:70rem;margin:0 auto;color:#4a4a4a;font-weight:400;line-height:1.5}h1{margin:0;padding:0;font-size:1rem;line-height:1.25;margin-bottom:0.5rem;}table{width:100%;table-layout:fixed;border-spacing: 0;}hr{border-style: none;border-bottom: solid 1px gray;}table th,table td{padding:.15rem 0;white-space:nowrap;vertical-align:top}table th a,table td a{display:inline-block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:95%;vertical-align:top;}table tr:hover td{background-color:#f5f5f5}footer{padding-top:0.5rem}table tr th{text-align:left;}@media (max-width:30rem){table th:first-child{width:20rem;}}" - } - } - body { - h1 { - "Index of " (current_path) - } - p { - small { - "directories: " (dirs_count) ", files: " (files_count) (download_directory_elem) - } - } - hr; - div style="overflow-x: auto;" { - table { - thead { - tr { - th { - a href={ "?sort=" (sort_attrs.name) } { - "Name" - } - } - th style="width:10rem;" { - a href={ "?sort=" (sort_attrs.last_modified) } { - "Last modified" - } - } - th style="width:6rem;text-align:right;" { - a href={ "?sort=" (sort_attrs.size) } { - "Size" - } - } - } - } - - @if base_path != "/" { - tr { - td colspan="3" { - a href="../" { - "../" - } - } - } - } - - @for entry in entries { - tr { - td { - a href=(entry.uri) { - (entry.name.to_string_lossy()) - @if entry.is_dir() { - "/" - } - } - } - td { - (entry.mtime.map_or("-".to_owned(), |local_dt| { - local_dt.format(DATETIME_FORMAT_LOCAL).to_string() - })) - } - td align="right" { - (entry.size.map(format_file_size).unwrap_or("-".into())) - } - } - } - } - } - hr; - footer { - small { - "Powered by Static Web Server (SWS) / static-web-server.net" - } - } - } - } - }.into() -} - -/// Sort a list of file entries by a specific order code. -fn sort_file_entries(files: &mut [FileEntry], order_code: u8) -> SortingAttr<'_> { - // Default sorting type values - let mut name = "0"; - let mut last_modified = "2"; - let mut size = "4"; - - match order_code { - 0 | 1 => { - // Name (asc, desc) - files.sort_by_cached_key(|f| f.name.to_string_lossy().to_lowercase()); - if order_code == 1 { - files.reverse(); - } else { - name = "1"; - } - } - 2 | 3 => { - // Modified (asc, desc) - files.sort_by_key(|f| f.mtime); - if order_code == 3 { - files.reverse(); - } else { - last_modified = "3"; - } - } - 4 | 5 => { - // File size (asc, desc) - files.sort_by_key(|f| f.size); - if order_code == 5 { - files.reverse(); - } else { - size = "5"; - } - } - _ => { - // Unsorted - } - } - - SortingAttr { - name, - last_modified, - size, - } -} - -/// Formats the file size in bytes to a human-readable string -fn format_file_size(size: u64) -> String { - const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; - let mut size_tmp = size; - - if size_tmp < 1024 { - // return the size with Byte - return format!("{} {}", size_tmp, UNITS[0]); - } - - for unit in &UNITS[1..UNITS.len() - 1] { - if size_tmp < 1024 * 1024 { - // return the size divided by 1024 with the unit - return format!("{:.2} {}", size_tmp as f64 / 1024.0, unit); - } - size_tmp >>= 10; - } - - // if size is too large, return the largest unit - format!("{:.2} {}", size_tmp as f64 / 1024.0, UNITS[UNITS.len() - 1]) -} - -#[cfg(test)] -mod tests { - use super::format_file_size; - - #[test] - fn handle_byte() { - let size = 128; - assert_eq!("128 B", format_file_size(size)) - } - - #[test] - fn handle_kibibyte() { - let size = 1024; - assert_eq!("1.00 KiB", format_file_size(size)) - } - - #[test] - fn handle_mebibyte() { - let size = 1048576; - assert_eq!("1.00 MiB", format_file_size(size)) - } - - #[test] - fn handle_gibibyte() { - let size = 1073741824; - assert_eq!("1.00 GiB", format_file_size(size)) - } - - #[test] - fn handle_tebibyte() { - let size = 1099511627776; - assert_eq!("1.00 TiB", format_file_size(size)) - } - - #[test] - fn handle_pebibyte() { - let size = 1125899906842624; - assert_eq!("1.00 PiB", format_file_size(size)) - } - - #[test] - fn handle_large() { - let size = u64::MAX; - assert_eq!("16384.00 PiB", format_file_size(size)) - } -}
src/directory_listing/sort.rs+60 −0 added@@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// This file is part of Static Web Server. +// See https://static-web-server.net/ for more information +// Copyright (C) 2019-present Jose Quintana <joseluisq.net> + +use crate::directory_listing::file::FileEntry; + +/// Defines sorting attributes for file entries. +pub(crate) struct SortingAttr<'a> { + pub(crate) name: &'a str, + pub(crate) last_modified: &'a str, + pub(crate) size: &'a str, +} + +/// Sort a list of file entries by a specific order code. +pub(crate) fn sort_file_entries(files: &mut [FileEntry], order_code: u8) -> SortingAttr<'_> { + // Default sorting type values + let mut name = "0"; + let mut last_modified = "2"; + let mut size = "4"; + + match order_code { + 0 | 1 => { + // Name (asc, desc) + files.sort_by_cached_key(|f| f.name.to_string_lossy().to_lowercase()); + if order_code == 1 { + files.reverse(); + } else { + name = "1"; + } + } + 2 | 3 => { + // Modified (asc, desc) + files.sort_by_key(|f| f.mtime); + if order_code == 3 { + files.reverse(); + } else { + last_modified = "3"; + } + } + 4 | 5 => { + // File size (asc, desc) + files.sort_by_key(|f| f.size); + if order_code == 5 { + files.reverse(); + } else { + size = "5"; + } + } + _ => { + // Unsorted + } + } + + SortingAttr { + name, + last_modified, + size, + } +}
src/directory_listing/style.rs+66 −0 added@@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// This file is part of Static Web Server. +// See https://static-web-server.net/ for more information +// Copyright (C) 2019-present Jose Quintana <joseluisq.net> + +pub(crate) const STYLES: &str = r#" +:after, :before { box-sizing: border-box; } +html { + color-scheme: light dark; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + min-width: 20rem; + text-rendering: optimizeLegibility; + -webkit-text-size-adjust: 100%; + -moz-text-size-adjust: 100%; + text-size-adjust: 100% +} +body { + padding: 1rem; + font-family: Consolas, 'Liberation Mono', Menlo, monospace; + font-size: .75rem; + max-width: 70rem; + margin: 0 auto; + font-weight: 400; + line-height: 1.5 +} +h1 { + margin: 0; + padding: 0; + font-size: 1rem; + line-height: 1.25; + margin-bottom: 0.5rem; +} +table { + width: 100%; + table-layout: fixed; + border-spacing: 0; +} +hr { border-style: none; border-bottom: solid 1px gray; } +table th, table td { + padding: .15rem 0; + white-space: nowrap; + vertical-align: top +} +table th a, table td a { + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 95%; + vertical-align: top; +} +table tr:hover td { background-color: rgba(200, 200, 200, 0.2); } +footer { padding-top: 0.5rem; } +table tr th { text-align: left; } + +@media (max-width:30rem) { + table th:first-child { width: 20rem; } +} +@media (prefers-color-scheme: dark) { + table tr:hover td { background-color: rgba(0, 0, 0, 0.2); } +} +@media (prefers-color-scheme: light) { + table tr:hover td { background-color: rgba(200, 200, 200, 0.2); } +} +"#;
src/fs/meta.rs+1 −1 modified@@ -33,7 +33,7 @@ pub(crate) fn try_metadata(file_path: &Path) -> Result<(Metadata, bool), StatusC match std::fs::metadata(file_path) { Ok(meta) => { let is_dir = meta.is_dir(); - tracing::trace!("file found: {:?}", file_path); + tracing::trace!("file found: {:?}; is_dir: {is_dir}", file_path); Ok((meta, is_dir)) } Err(err) => {
src/static_files.rs+38 −11 modified@@ -130,6 +130,43 @@ pub async fn handle(opts: &HandleOpts<'_>) -> Result<StaticFileResponse, StatusC } } + // Prevent symlinks access if option is enabled + if file_path.is_symlink() { + if opts.disable_symlinks { + tracing::warn!( + "file path {} is a symlink, access denied", + file_path.display() + ); + return Err(StatusCode::FORBIDDEN); + } + + let symlink_file_path = file_path.canonicalize().map_err(|err| { + tracing::error!( + "unable to resolve `{}` symlink path: {}", + file_path.display(), + err, + ); + StatusCode::NOT_FOUND + })?; + + let base_path = opts.base_path.canonicalize().map_err(|err| { + tracing::error!( + "unable to resolve `{}` symlink path: {}", + file_path.display(), + err, + ); + StatusCode::NOT_FOUND + })?; + + if !symlink_file_path.starts_with(base_path) { + tracing::error!( + "file path {} is a symlink, access denied", + symlink_file_path.display() + ); + return Err(StatusCode::NOT_FOUND); + } + } + let FileMetadata { file_path, metadata, @@ -140,7 +177,6 @@ pub async fn handle(opts: &HandleOpts<'_>) -> Result<StaticFileResponse, StatusC headers_opt, opts.compression_static, opts.index_files, - opts.disable_symlinks, )?; // Check for a hidden file/directory (dotfile) and ignore it if feature enabled @@ -229,6 +265,7 @@ pub async fn handle(opts: &HandleOpts<'_>) -> Result<StaticFileResponse, StatusC } let resp = directory_listing::auto_index(DirListOpts { + root_path: opts.base_path.as_path(), method, current_path: uri_path, uri_query: opts.uri_query, @@ -299,19 +336,9 @@ fn get_composed_file_metadata<'a>( _headers: &'a HeaderMap<HeaderValue>, _compression_static: bool, mut index_files: &'a [&'a str], - disable_symlinks: bool, ) -> Result<FileMetadata<'a>, StatusCode> { tracing::trace!("getting metadata for file {}", file_path.display()); - // Prevent symlinks access if option is enabled - if disable_symlinks && file_path.is_symlink() { - tracing::warn!( - "file path {} is a symlink, access denied", - file_path.display() - ); - return Err(StatusCode::FORBIDDEN); - } - // Try to find the file path on the file system match try_metadata(file_path) { Ok((mut metadata, is_dir)) => {
tests/compression_static.rs+1 −1 modified@@ -167,8 +167,8 @@ mod tests { } } - #[cfg(feature = "directory-listing")] #[tokio::test] + #[cfg(feature = "directory-listing")] async fn compression_static_index_file() { let opts = fixture_settings("toml/handler_fixtures.toml"); let general = General {
tests/dir_listing.rs+2 −2 modified@@ -436,8 +436,8 @@ mod tests { } } - #[cfg(feature = "directory-listing-download")] #[tokio::test] + #[cfg(feature = "directory-listing-download")] async fn dir_listing_has_download_link_when_enabled() { for method in METHODS { match static_files::handle(&HandleOpts { @@ -484,8 +484,8 @@ mod tests { } } - #[cfg(feature = "directory-listing-download")] #[tokio::test] + #[cfg(feature = "directory-listing-download")] async fn dir_listing_has_no_download_link_when_disabled() { for method in METHODS { match static_files::handle(&HandleOpts {
tests/fixtures/public/readme.md+1 −0 added@@ -0,0 +1 @@ +../../../README.md \ No newline at end of file
tests/fixtures/symlink/file.txt+1 −0 added@@ -0,0 +1 @@ +a
tests/fixtures/symlink/unknown.md+1 −0 added@@ -0,0 +1 @@ +unknown.md \ No newline at end of file
tests/static_files.rs+99 −8 modified@@ -8,6 +8,7 @@ mod tests { use bytes::Bytes; use headers::HeaderMap; use http::{Method, StatusCode}; + use static_web_server::http_ext::MethodExt; use std::fs; use std::path::PathBuf; @@ -283,7 +284,6 @@ mod tests { } } - // FIX #[tokio::test] async fn handle_append_index_on_dir() { let buf = fs::read(root_dir().join("assets/index.html")) @@ -742,14 +742,14 @@ mod tests { } } + #[tokio::test] #[cfg(any( feature = "compression", feature = "compression-deflate", feature = "compression-gzip", feature = "compression-brotli", feature = "compression-zstd" ))] - #[tokio::test] async fn handle_file_compressions() { let encodings = [ #[cfg(any(feature = "compression", feature = "compression-gzip"))] @@ -1639,13 +1639,104 @@ mod tests { let res = result.resp; assert_eq!(res.status(), 200); } - Err(err) => { - match method { - // The handle only accepts HEAD or GET request methods - Method::GET | Method::HEAD => { - panic!("unexpected an error response {err}") + Err(status) => match method { + Method::GET | Method::HEAD => { + panic!("unexpected error response with status {status}") + } + _ => assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED), + }, + } + } + } + + #[tokio::test] + async fn handle_symlinks_paths() { + let root_dir_rel = PathBuf::from("tests/fixtures/public/"); + let root_dir_abs = root_dir_rel.canonicalize().unwrap(); + let headers = HeaderMap::new(); + + for root_dir in [root_dir_rel, root_dir_abs] { + for method in METHODS { + match static_files::handle(&HandleOpts { + method: &method, + headers: &headers, + base_path: &root_dir, + uri_path: "/readme.md", + uri_query: None, + #[cfg(feature = "experimental")] + memory_cache: None, + #[cfg(feature = "directory-listing")] + dir_listing: false, + #[cfg(feature = "directory-listing")] + dir_listing_order: 6, + #[cfg(feature = "directory-listing")] + dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], + redirect_trailing_slash: true, + compression_static: true, + ignore_hidden_files: true, + disable_symlinks: false, + index_files: &["index.htm", "index.htm"], + }) + .await + { + Ok(_) => { + panic!("unexpected successful response") + } + Err(status) => { + if method.is_allowed() { + assert_eq!(status, StatusCode::NOT_FOUND) + } else { + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED) + } + } + } + } + } + } + + #[tokio::test] + async fn handle_symlinks_skip_broken_path() { + let root_dir_rel = PathBuf::from("tests/fixtures/symlink/"); + let root_dir_abs = root_dir_rel.canonicalize().unwrap(); + let headers = HeaderMap::new(); + + for root_dir in [root_dir_rel, root_dir_abs] { + for method in METHODS { + match static_files::handle(&HandleOpts { + method: &method, + headers: &headers, + base_path: &root_dir, + uri_path: "/unknown.md", + uri_query: None, + #[cfg(feature = "experimental")] + memory_cache: None, + #[cfg(feature = "directory-listing")] + dir_listing: false, + #[cfg(feature = "directory-listing")] + dir_listing_order: 6, + #[cfg(feature = "directory-listing")] + dir_listing_format: &DirListFmt::Html, + #[cfg(feature = "directory-listing-download")] + dir_listing_download: &[], + redirect_trailing_slash: true, + compression_static: true, + ignore_hidden_files: true, + disable_symlinks: false, + index_files: &["index.htm", "index.htm"], + }) + .await + { + Ok(_) => { + panic!("unexpected successful response") + } + Err(status) => { + if method.is_allowed() { + assert_eq!(status, StatusCode::NOT_FOUND) + } else { + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED) } - _ => assert_eq!(err, StatusCode::METHOD_NOT_ALLOWED), } } }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-459f-x8vq-xjjmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-67487ghsaADVISORY
- github.com/static-web-server/static-web-server/commit/308f0d26ceb9c2c8bd219315d0f53914763357f2ghsax_refsource_MISCWEB
- github.com/static-web-server/static-web-server/security/advisories/GHSA-459f-x8vq-xjjmghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.