rust 110 lines · 3 tabs

Safely Serving Static Files with Path Traversal Protection in Rust

Shared by codesnips Aug 2026
3 tabs
use std::path::{Component, Path, PathBuf};

#[derive(Debug)]
pub enum ResolveError {
    Traversal,
    NotFound,
}

pub fn resolve(root: &Path, requested: &str) -> Result<PathBuf, ResolveError> {
    let rel = Path::new(requested);

    for component in rel.components() {
        match component {
            Component::Normal(_) => {}
            Component::CurDir => {}
            _ => return Err(ResolveError::Traversal),
        }
    }

    let candidate = root.join(rel);
    let canonical = candidate.canonicalize().map_err(|_| ResolveError::NotFound)?;

    if !canonical.starts_with(root) {
        return Err(ResolveError::Traversal);
    }

    Ok(canonical)
}

pub fn content_type_for(path: &Path) -> String {
    mime_guess::from_path(path)
        .first_raw()
        .unwrap_or("application/octet-stream")
        .to_string()
}
3 files · rust Explain with highlit

This snippet shows how a small static file server is built in Rust with axum and tokio, focusing on the two things that consistently go wrong in hand-rolled static handlers: resolving an untrusted request path safely, and returning a correct Content-Type header.

In path_resolver.rs, the core defense is path canonicalization. A raw request like /../../etc/passwd decodes to path segments that could escape the intended web root, so resolve rejects anything containing .. or absolute components up front, then joins the remaining segments onto a pre-canonicalized root. After building the candidate path it calls canonicalize again and verifies the result still starts_with the root. This two-step check matters because symlinks inside the root could otherwise point outside it — checking only the textual path is not enough. The function returns a Result with a small ResolveError enum so callers can map failures to distinct HTTP statuses instead of leaking filesystem details.

The content_type_for helper derives a MIME type from the file extension using the mime_guess crate, falling back to application/octet-stream. Guessing from the extension is the pragmatic default for a static server; sniffing file contents is slower and rarely necessary when the files are trusted assets.

In handler.rs, serve_file ties it together. It extracts the wildcard path via axum's Path extractor, resolves it, and opens the file with tokio::fs::File. Rather than reading the whole file into memory, it wraps the handle in a ReaderStream and hands that to Body::from_stream, so large files are streamed chunk by chunk with bounded memory use. It also sets Content-Length from the file metadata, which lets clients show progress and enables range-free but well-behaved downloads.

Each ResolveError variant maps to a specific StatusCode403 for traversal attempts, 404 for missing files — via IntoResponse, keeping the handler readable. The router in main.rs mounts the handler under a /files/*path wildcard and injects the canonical root as shared State. Reaching for this pattern is appropriate when a service needs to expose a directory of assets without pulling in a full framework like tower-http's ServeDir, or when custom auth or logging must wrap each request.


Related snips

Share this code

Here's the card — post it anywhere.

Safely Serving Static Files with Path Traversal Protection in Rust — share card
Link copied