FlawOpen

flawopen.com/path-traversal/Rust

● CWE-22 · High
FlawOpen Security Research

Path Traversal in Rust (std::path, tar)

Path::join and lexical starts_with checks do not confine filesystem access, while canonicalize-then-open can race with symlink changes. This guide uses server-issued IDs and cap-std directory capabilities for reads, then imports only bounded regular tar entries under generated names into a private staging directory.

💡 Plain English Explainer (ELI5)

Think of a clerk assigned a locked cabinet: a visitor cannot hand over a note naming any filesystem path. The service first checks that the visitor is allowed to access a particular file, then gives the clerk a server-issued ticket. The clerk’s cabinet key only opens files inside that cabinet, and archive imports go into a separate private staging cabinet with strict size and count limits.

Core Concepts & Subsystem Terms

Path::join with an absolute path
If the argument is absolute, join returns it unchanged: Path::new("/srv/uploads").join("/etc/hostname") is /etc/hostname. This is documented behaviour, so join alone never confines a path.
Path::starts_with is lexical
starts_with compares whole components, which stops /srv/uploads-old, but it does not resolve ... /srv/uploads/../../etc/hostname still starts with /srv/uploads even though the kernel opens /etc/hostname.
Path::components() and Component::Normal
components() splits a path into Normal names, ParentDir (..), RootDir, CurDir and, on Windows, drive Prefixes. Requiring every component to be Normal rejects .., absolute paths and drive letters before any file-system access.
Directory capability and TOCTOU
cap_std::fs::Dir is an already-open directory capability. Opening a relative name through it keeps the operation anchored to that directory, avoiding a separate canonicalize-then-open check. The capability does not replace server-side authorization or private ownership of the directory.
tar Entry::unpack vs unpack_in
Entry::unpack(path) writes the member exactly where you tell it, so dest.join(entry.path()?) is Zip Slip for tar files. Entry::unpack_in(dest) skips members with .. or absolute paths and returns Ok(false) when it does.

Framework mappings

  • CWE reference for the weakness described in this guide: CWE-22

Step-by-Step Attack Flow

Step 1

Request value reaches a file API

An Axum handler extracts file from Query<FileQuery>, or reads member names from an uploaded tar archive, and joins it onto the upload directory.

Step 2

The value leaves the intended folder

It is absolute, contains .. components, or reaches a symbolic link that points elsewhere. The benign test value ../../etc/hostname illustrates the read case.

Step 3

The kernel opens the resolved path

tokio::fs::read or Entry::unpack passes the path to the operating system, which resolves every .. and link.

Step 4

The attacker reads or overwrites files

Reads expose configuration, keys and environment files; archive writes can replace binaries or config that the service loads on restart.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// files.rs: each handler lets the request pick a path outside UPLOAD_DIR
use axum::extract::Query;
use axum::http::StatusCode;
use serde::Deserialize;
use std::fs::File;
use std::path::Path;

const UPLOAD_DIR: &str = "/srv/uploads";

#[derive(Deserialize)]
pub struct FileQuery {
    file: String,
}

// an absolute file name replaces UPLOAD_DIR; .. walks out of it
pub async fn download(Query(q): Query<FileQuery>) -> Result<Vec<u8>, StatusCode> {
    tokio::fs::read(Path::new(UPLOAD_DIR).join(&q.file))
        .await
        .map_err(|_| StatusCode::NOT_FOUND)
}

// starts_with does not resolve .., so /srv/uploads/../../etc passes
pub async fn preview(Query(q): Query<FileQuery>) -> Result<String, StatusCode> {
    let path = Path::new(UPLOAD_DIR).join(&q.file);
    if !path.starts_with(UPLOAD_DIR) {
        return Err(StatusCode::NOT_FOUND);
    }
    tokio::fs::read_to_string(path).await.map_err(|_| StatusCode::NOT_FOUND)
}

// unpack writes to the joined path, wherever the member name points
pub fn import_archive(archive: File, dest: &Path) -> std::io::Result<()> {
    let mut tar = tar::Archive::new(archive);
    for entry in tar.entries()? {
        let mut entry = entry?;
        let target = dest.join(entry.path()?);
        entry.unpack(&target)?;
    }
    Ok(())
}
HARDENED SECURE PATCH
// files.rs: capability-relative opens and bounded archive import
use cap_std::ambient_authority;
use cap_std::fs::{Dir, OpenOptions};
use std::fs::File;
use std::io::{self, Read, Write};
use std::path::{Component, Path};
use tar::Archive;
use tokio::io::AsyncReadExt;
use uuid::Uuid;

const UPLOAD_DIR: &str = "/srv/uploads";
const MAX_ARCHIVE_BYTES: u64 = 25_000_000;
const MAX_ENTRY_BYTES: u64 = 10_000_000;
const MAX_TOTAL_BYTES: u64 = 100_000_000;
const MAX_ENTRIES: usize = 1_000;

// Implement the authenticated Axum extractor in this same module. It must parse
// the ID and check the owner/tenant ACL before constructing this type. Keep the
// field private so sibling modules cannot bypass that authorization boundary.
pub struct AuthorizedFileId(Uuid);

// Client input is an opaque server-issued ID, never a filesystem path.
fn open_upload(file_id: &str) -> io::Result<File> {
    let id = Uuid::parse_str(file_id)
        .map_err(|_| io::Error::new(io::ErrorKind::NotFound, "unknown file"))?;
    if id.to_string() != file_id {
        return Err(io::Error::new(io::ErrorKind::NotFound, "unknown file"));
    }

    // Dir holds a capability; open() resolves only within this directory.
    let root = Dir::open_ambient_dir(UPLOAD_DIR, ambient_authority())?;
    let opened = root.open(format!("{id}.bin"))?;
    if !opened.metadata()?.is_file() {
        return Err(io::Error::new(io::ErrorKind::NotFound, "unknown file"));
    }
    Ok(opened.into_std())
}

pub async fn download(authorized: AuthorizedFileId) -> io::Result<Vec<u8>> {
    let file_id = authorized.0.to_string();
    let file = tokio::fs::File::from_std(open_upload(&file_id)?);
    let mut bytes = Vec::new();
    file.take(MAX_ENTRY_BYTES + 1).read_to_end(&mut bytes).await?;
    if bytes.len() as u64 > MAX_ENTRY_BYTES {
        return Err(io::Error::new(io::ErrorKind::InvalidData, "file too large"));
    }
    Ok(bytes)
}

// `staging` must be a fresh, service-owned private directory. Discard the
// whole staging directory if this function returns an error.
pub fn import_archive(mut archive: File, staging: &Dir) -> io::Result<()> {
    if archive.metadata()?.len() > MAX_ARCHIVE_BYTES {
        return Err(io::Error::new(io::ErrorKind::InvalidData, "archive too large"));
    }

    let mut tar = Archive::new(archive);
    let mut count = 0usize;
    let mut total = 0u64;
    for item in tar.entries()? {
        let mut entry = item?;
        count += 1;
        if count > MAX_ENTRIES || !entry.header().entry_type().is_file() {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported archive entry"));
        }

        let declared = entry.header().size()?;
        total = total.checked_add(declared)
            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "archive too large"))?;
        if declared > MAX_ENTRY_BYTES || total > MAX_TOTAL_BYTES {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "archive too large"));
        }

        // Validate a single normal member name, but never use it as output path.
        let member = entry.path()?.into_owned();
        let mut parts = member.components();
        if !matches!(parts.next(), Some(Component::Normal(_))) || parts.next().is_some() {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid member name"));
        }

        let output_name = format!("{}.bin", Uuid::new_v4());
        let mut options = OpenOptions::new();
        options.write(true).create_new(true);
        let mut output = staging.open_with(output_name, &options)?;
        let copied = io::copy(&mut entry.by_ref().take(MAX_ENTRY_BYTES + 1), &mut output)?;
        if copied != declared {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "truncated archive entry"));
        }
        output.flush()?;
    }
    Ok(())
}

Engineering & System Hardening Checklist

Sources