flexd 0.2.3

Hardened web server and reverse proxy: TLS/HTTP3, ACME, rate limiting, host policy, GeoIP, A/B splitting, privilege dropping
Documentation
use bytes::Bytes;
use http::{Response, StatusCode};
use http_body_util::Full;
use std::path::{Path, PathBuf};

pub async fn serve_file(
    root: &str,
    uri_path: &str,
) -> Result<Response<Full<Bytes>>, StatusCode> {
    let decoded = url_decode(uri_path);
    let sanitized = sanitize_path(&decoded);

    if sanitized.contains("..") || sanitized.starts_with('/') {
        return Err(StatusCode::FORBIDDEN);
    }

    let file_path = PathBuf::from(root).join(&sanitized);
    let canonical = file_path
        .canonicalize()
        .map_err(|_| StatusCode::NOT_FOUND)?;

    let root_canonical = PathBuf::from(root)
        .canonicalize()
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if !canonical.starts_with(&root_canonical) {
        return Err(StatusCode::FORBIDDEN);
    }

    if !canonical.is_file() {
        return Err(StatusCode::NOT_FOUND);
    }

    let content = tokio::fs::read(&canonical)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let mime_type = guess_mime(&canonical);

    Response::builder()
        .status(StatusCode::OK)
        .header(http::header::CONTENT_TYPE, mime_type)
        .header(http::header::CONTENT_LENGTH, content.len())
        .body(Full::new(Bytes::from(content)))
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

fn url_decode(input: &str) -> String {
    let mut result = String::with_capacity(input.len());
    let mut chars = input.bytes();
    while let Some(b) = chars.next() {
        if b == b'%' {
            let h = chars.next();
            let l = chars.next();
            if let (Some(h), Some(l)) = (h, l) {
                if let Ok(byte) = u8::from_str_radix(
                    &String::from_utf8_lossy(&[h, l]),
                    16,
                ) {
                    result.push(byte as char);
                    continue;
                }
            }
            result.push('%');
        } else if b == b'+' {
            result.push(' ');
        } else {
            result.push(b as char);
        }
    }
    result
}

fn sanitize_path(path: &str) -> String {
    path.replace('\\', "/")
        .replace('\u{2044}', "/")
        .replace('\u{2215}', "/")
        .trim_start_matches('/')
        .to_string()
}

fn guess_mime(path: &Path) -> &'static str {
    match path.extension().and_then(|e| e.to_str()) {
        Some("html") | Some("htm") => "text/html; charset=utf-8",
        Some("css") => "text/css; charset=utf-8",
        Some("js") => "application/javascript; charset=utf-8",
        Some("json") => "application/json; charset=utf-8",
        Some("png") => "image/png",
        Some("jpg") | Some("jpeg") => "image/jpeg",
        Some("gif") => "image/gif",
        Some("svg") => "image/svg+xml",
        Some("ico") => "image/x-icon",
        Some("txt") => "text/plain; charset=utf-8",
        Some("xml") => "application/xml; charset=utf-8",
        Some("pdf") => "application/pdf",
        Some("wasm") => "application/wasm",
        _ => "application/octet-stream",
    }
}