flawopen.com/path-traversal/Rust
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.
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,
joinreturns it unchanged:Path::new("/srv/uploads").join("/etc/hostname")is/etc/hostname. This is documented behaviour, sojoinalone never confines a path. Path::starts_with is lexicalstarts_withcompares whole components, which stops/srv/uploads-old, but it does not resolve.../srv/uploads/../../etc/hostnamestill starts with/srv/uploadseven though the kernel opens/etc/hostname.Path::components() and Component::Normalcomponents()splits a path intoNormalnames,ParentDir(..),RootDir,CurDirand, on Windows, drivePrefixes. Requiring every component to beNormalrejects.., absolute paths and drive letters before any file-system access.Directory capability and TOCTOUcap_std::fs::Diris 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_inEntry::unpack(path)writes the member exactly where you tell it, sodest.join(entry.path()?)is Zip Slip for tar files.Entry::unpack_in(dest)skips members with..or absolute paths and returnsOk(false)when it does.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-22
Step-by-Step Attack Flow
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.
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.
The kernel opens the resolved path
tokio::fs::read or Entry::unpack passes the path to the operating system, which resolves every .. and link.
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
// 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(())
}
// 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
- ✓Implement the authenticated Axum extractor in the same module as
AuthorizedFileId; validate the server-issued ID and enforce the user's owner/tenant authorization before constructing it. UUID unpredictability is not authorization. - ✓Open files relative to a held
cap_std::fs::Dirand accept only canonical server-issued IDs; do not canonicalize a request path and open it later. - ✓Import only regular tar entries with limits on archive bytes, entry count, per-entry bytes, and total bytes. Use
Entry::unpack_inonly when the destination is fresh, private, and cannot be modified by an untrusted process during extraction. - ✓Use a new private staging directory for each import and discard it on any error; publish completed files only after validation succeeds.
- ✓Store uploads under generated IDs and run the service with write access only to its upload directory.
Sources
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
- Rust std — Path::join
- Rust std — Path::starts_with
- Rust std — std::path::Component
- tower-http — ServeDir
- OWASP — Path Traversal
- cap-std — capability-based filesystem API
- tar crate — security notes for archive extraction