Skip to content
rusthacks.dev
Security

Path Traversal in Rust File Servers and Safe Joins

A user-supplied filename like ../../etc/passwd escapes your upload directory. Reject components, canonicalize and verify the prefix, and see why Path::join with an absolute path is a trap.

3 min read
Summarize in AI
Flat illustration for the post “Path Traversal in Rust File Servers and Safe Joins”, composed around security.

A download endpoint takes a filename and serves it from ./uploads. The request GET /files/..%2f..%2fetc%2fpasswd asks for something else. The classic path traversal bug is simple, and it keeps appearing because Path::join happily accepts what it is given. This post shows the two traps, .. and absolute paths, and a validation function with two layers. It belongs to the input handling risks in security for Rust developers, and relates to the routing wildcards in Axum routing.

Rust 1.90, edition 2024.

The two traps

rust
use std::path::Path;

fn main() {
    let base = Path::new("/srv/uploads");
    println!("{}", base.join("report.pdf").display());
    println!("{}", base.join("../secret.txt").display());
    println!("{}", base.join("/etc/passwd").display());
}

It prints /srv/uploads/report.pdf, then /srv/uploads/../secret.txt and then /etc/passwd. The second is a .. escape that the file system resolves when opening. The third shows that join with an absolute path replaces the base: the documented behavior, and a surprise nobody expects once.

Layer 1: reject by components

rust
use std::path::{Component, Path, PathBuf};

pub fn safe_join(base: &Path, user: &str) -> Option<PathBuf> {
    let rel = Path::new(user);
    if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
        return None;
    }
    Some(base.join(rel))
}

components() parses the path into pieces without touching the file system. Component::Normal is an ordinary name. ParentDir (..), RootDir (/), CurDir (.) and a Windows Prefix (C:) are all rejected. safe_join(base, "a/b.txt") returns the joined path, and safe_join(base, "../x") and safe_join(base, "/etc/passwd") return None. This check is cheap, deterministic and independent of what exists on disk.

Layer 2: canonicalize and check the prefix

Symlinks inside the directory can still point outside. After building the path, resolve it and confirm:

rust
pub fn open_inside(base: &Path, user: &str) -> std::io::Result<std::fs::File> {
    let candidate = safe_join(base, user)
        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "bad path"))?;
    let real = candidate.canonicalize()?;
    let root = base.canonicalize()?;
    if !real.starts_with(&root) {
        return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "outside base"));
    }
    std::fs::File::open(real)
}

starts_with on Path compares components, not strings, so /srv/uploads-evil does not match /srv/uploads. A plain str::starts_with would be wrong here. Return the same error for "not found" and "denied" if existence is sensitive.

The remaining race

Between canonicalize and open, someone with write access to the directory can swap a file for a symlink (a TOCTOU race). On a directory only your service writes, the risk is small. Where it is not, use cap-std or openat2 with RESOLVE_BENEATH, which make the kernel enforce the boundary during the open.

Archives

Unpacking zip or tar files has the same bug under another name ("zip slip"): an entry named ../../x writes outside the target. Apply the same component check to every entry name. The tar crate's unpack refuses .. by default, but check your version's behavior and tests.

Web specifics

Decode the URL once and validate the result. In Axum, a {*path} wildcard gives you the decoded remainder as a String, which you pass to safe_join. Prefer tower_http::services::ServeDir, which implements these checks and handles caching headers.

Limits

Component checks do not decide what a user may read within the base directory; that is authorization. Windows has reserved names (CON, NUL) and alternate data streams that need their own handling. OWASP describes the class in its Path Traversal page.

Also inWeb
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.