use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::prelude::*;
use crate::salvo::FlowCtrl;
use crate::static_server::caching::{CacheMap, cache_runner, send_file, send_html};
const LOCAL_FRONTEND_DISTRIBUTABLE: &str = "dist";
const CONTAINER_FRONTEND_DISTRIBUTABLE: &str = "/usr/local/frontend-dist";
fn safe_path_under(base: &Path, rest_path: &str) -> Option<PathBuf> {
use std::path::Component;
let rel = Path::new(rest_path);
let mut clean = PathBuf::new();
for component in rel.components() {
match component {
Component::Normal(name) => {
let bytes = name.as_encoded_bytes();
if bytes.contains(&0) || bytes.contains(&b'\\') {
return None;
}
clean.push(name);
}
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
Some(base.join(clean))
}
async fn common_handle(
cacher: &Option<Arc<Mutex<CacheMap>>>,
dist_path: &Path,
filename: String,
filepath: PathBuf,
req: &mut Request,
depot: &mut Depot,
res: &mut Response,
) {
use salvo::Writer;
if filename.contains(".") {
match filename.split('.').collect::<Vec<_>>().last() {
Some(&"html") => {
if let Err(e) = send_html(cacher, req, depot, res, &filename, &filepath).await {
tracing::warn!(file = %filename, "html not found");
e.with_public("There is no such file!")
.with_404()
.write(req, depot, res)
.await;
}
}
_ => {
if let Err(e) = send_file(cacher, req, depot, res, &filename, &filepath).await {
tracing::warn!(file = %filename, "file not found");
e.with_public("There is no such file!")
.with_404()
.write(req, depot, res)
.await;
}
}
}
} else if filepath.exists() {
if let Err(e) = send_file(cacher, req, depot, res, &filename, &filepath).await {
tracing::warn!(file = %filename, "file not found");
e.with_public("There is no such file!")
.with_404()
.write(req, depot, res)
.await;
}
} else if let Err(e) = send_html(cacher, req, depot, res, "index.html", &dist_path.join("index.html")).await {
tracing::warn!(file = %filename, "index.html fallback not found");
e.with_public("There is no such file!")
.with_404()
.write(req, depot, res)
.await;
}
}
pub struct StaticRouter {
path: PathBuf,
cacher: Option<Arc<Mutex<CacheMap>>>,
}
pub struct NoRedirectStaticRouter {
path: PathBuf,
cacher: Option<Arc<Mutex<CacheMap>>>,
}
pub struct ProvidedRoutesStaticRouter {
path: PathBuf,
possible_routes: Vec<String>,
cacher: Option<Arc<Mutex<CacheMap>>>,
}
impl StaticRouter {
pub fn new(path: impl AsRef<Path>) -> MResult<Self> {
if !path.as_ref().exists() {
ServerError::from_private_str(format!("There is no such folder as {:?}!", path.as_ref()))
.with_500()
.bail()?;
}
Ok(Self {
path: path.as_ref().to_owned(),
cacher: None,
})
}
pub fn new_with_cacher(path: impl AsRef<Path>) -> MResult<Self> {
if !path.as_ref().exists() {
ServerError::from_private_str(format!("There is no such folder as {:?}!", path.as_ref()))
.with_500()
.bail()?;
}
let cacher = CacheMap::new();
tokio::task::spawn({
let path = path.as_ref().to_path_buf();
let cacher = cacher.clone();
async move {
if let Err(e) = cache_runner(&path, cacher).await {
tracing::error!("{e}");
}
}
});
Ok(Self {
path: path.as_ref().to_owned(),
cacher: Some(cacher),
})
}
pub fn with_no_redirect(self) -> NoRedirectStaticRouter {
NoRedirectStaticRouter {
path: self.path,
cacher: self.cacher,
}
}
pub fn with_routes_list(self, routes: Vec<String>) -> ProvidedRoutesStaticRouter {
ProvidedRoutesStaticRouter {
path: self.path,
possible_routes: routes,
cacher: self.cacher,
}
}
}
#[salvo::async_trait]
impl salvo::Handler for StaticRouter {
#[tracing::instrument(skip_all, fields(http.uri = req.uri().path(), http.method = req.method().as_str()))]
async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, _: &mut FlowCtrl) {
use salvo::Writer;
let mut filename = req.param::<String>("rest_path").unwrap_or(String::from("index.html"));
if filename.is_empty() {
filename = String::from("index.html");
}
let Some(filepath) = safe_path_under(&self.path, &filename) else {
tracing::warn!(file = %filename, "rejected static path traversal attempt; serving index.html");
let index_path = self.path.join("index.html");
if let Err(e) = send_html(&self.cacher, req, depot, res, "index.html", &index_path).await {
e.with_public("There is no such file!")
.with_404()
.write(req, depot, res)
.await;
}
return;
};
common_handle(&self.cacher, &self.path, filename, filepath, req, depot, res).await;
}
}
#[salvo::async_trait]
impl salvo::Handler for NoRedirectStaticRouter {
#[tracing::instrument(skip_all, fields(http.uri = req.uri().path(), http.method = req.method().as_str()))]
async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, flow: &mut salvo::FlowCtrl) {
let mut filename = req.param::<String>("rest_path").unwrap_or(String::from("index.html"));
if filename.is_empty() {
filename = String::from("index.html");
}
let Some(filepath) = safe_path_under(&self.path, &filename) else {
tracing::warn!(file = %filename, "rejected static path traversal attempt; falling through");
while flow.has_next() {
flow.call_next(req, depot, res).await;
}
return;
};
if !filepath.exists() {
tracing::debug!(file = %filename, "static asset missing, falling through");
while flow.has_next() {
flow.call_next(req, depot, res).await;
}
return;
}
common_handle(&self.cacher, &self.path, filename, filepath, req, depot, res).await;
}
}
#[salvo::async_trait]
impl salvo::Handler for ProvidedRoutesStaticRouter {
#[tracing::instrument(skip_all, fields(http.uri = req.uri().path(), http.method = req.method().as_str()))]
async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, flow: &mut salvo::FlowCtrl) {
let mut filename = req.param::<String>("rest_path").unwrap_or(String::from("index.html"));
if filename.is_empty() {
filename = String::from("index.html");
}
let Some(filepath) = safe_path_under(&self.path, &filename) else {
tracing::warn!(file = %filename, "rejected static path traversal attempt; falling through");
while flow.has_next() {
flow.call_next(req, depot, res).await;
}
return;
};
if !filepath.exists() || !self.possible_routes.iter().any(|pr| pr.as_str().eq(filename.as_str())) {
tracing::debug!(file = %filename, "static asset not in allow-list, falling through");
while flow.has_next() {
flow.call_next(req, depot, res).await;
}
return;
}
common_handle(&self.cacher, &self.path, filename, filepath, req, depot, res).await;
}
}
pub fn frontend_router_from_given_dist(dist: &Path) -> MResult<Router> {
Ok(Router::with_path("{**rest_path}").get(StaticRouter::new_with_cacher(dist)?))
}
pub fn frontend_router() -> MResult<Router> {
let dist = StaticRouter::new_with_cacher(LOCAL_FRONTEND_DISTRIBUTABLE)
.or(StaticRouter::new_with_cacher(CONTAINER_FRONTEND_DISTRIBUTABLE))?;
Ok(Router::with_path("{**rest_path}").get(dist))
}
pub fn assets_only_router_from(dist: &Path) -> MResult<Router> {
Ok(Router::with_path("{**rest_path}").get(StaticRouter::new_with_cacher(dist)?.with_no_redirect()))
}
#[cfg(test)]
mod tests {
use super::safe_path_under;
use std::path::Path;
#[test]
fn ok_simple_filename() {
let got = safe_path_under(Path::new("dist"), "index.html").unwrap();
assert_eq!(got, Path::new("dist/index.html"));
}
#[test]
fn ok_nested_filename() {
let got = safe_path_under(Path::new("dist"), "pkg/app.js").unwrap();
assert_eq!(got, Path::new("dist/pkg/app.js"));
}
#[test]
fn ok_current_dir_components_are_dropped() {
let got = safe_path_under(Path::new("dist"), "./pkg/./app.js").unwrap();
assert_eq!(got, Path::new("dist/pkg/app.js"));
}
#[test]
fn rejects_parent_dir_traversal() {
assert!(safe_path_under(Path::new("dist"), "../../../../etc/passwd").is_none());
assert!(safe_path_under(Path::new("dist"), "pkg/../../../etc/passwd").is_none());
assert!(safe_path_under(Path::new("dist"), "..").is_none());
}
#[test]
fn rejects_absolute_path() {
assert!(safe_path_under(Path::new("dist"), "/etc/passwd").is_none());
}
#[test]
fn rejects_backslash_separator() {
assert!(safe_path_under(Path::new("dist"), "..\\..\\etc\\passwd").is_none());
assert!(safe_path_under(Path::new("dist"), "pkg\\..\\secret").is_none());
}
#[test]
fn rejects_nul_byte() {
assert!(safe_path_under(Path::new("dist"), "good\0bad").is_none());
}
#[test]
fn rejects_jetty_style_leading_slash_traversal() {
assert!(safe_path_under(Path::new("dist"), "////../../etc/passwd").is_none());
assert!(safe_path_under(Path::new("dist"), "/../etc/passwd").is_none());
assert!(safe_path_under(Path::new("dist"), "pkg//../../etc/passwd").is_none());
}
#[test]
fn double_encoded_dotdot_does_not_escape() {
let got = safe_path_under(Path::new("dist"), "..%2F..%2Fetc%2Fpasswd").unwrap();
assert_eq!(got, Path::new("dist/..%2F..%2Fetc%2Fpasswd"));
assert!(!got.exists());
}
#[test]
fn dotdot_variants_are_filenames_not_traversal() {
let got = safe_path_under(Path::new("dist"), "..../etc/passwd").unwrap();
assert_eq!(got, Path::new("dist/..../etc/passwd"));
let got = safe_path_under(Path::new("dist"), "....//etc/passwd").unwrap();
assert_eq!(got, Path::new("dist/..../etc/passwd"));
}
}