
Preventing Directory Traversal in Rust: Safe Filesystem Access for User-Supplied Paths
What directory traversal looks like
Directory traversal occurs when an attacker uses path segments such as .., absolute paths, or symlink tricks to access files outside an allowed directory.
Common examples include:
- Download endpoints that accept a filename
- Upload handlers that write files into a user directory
- Archive extractors that unpack ZIP or TAR entries
- Template engines that load files by name
- Admin tools that browse logs or reports by path
A vulnerable implementation often looks harmless:
use std::fs;
use std::path::Path;
fn read_user_file(base_dir: &Path, user_input: &str) -> std::io::Result<String> {
let path = base_dir.join(user_input);
fs::read_to_string(path)
}If user_input is ../../secrets.txt, the resulting path may escape base_dir. Even if you reject .. segments, symlinks can still redirect access elsewhere.
Why simple string checks are not enough
A common mistake is to validate paths using string operations:
- Rejecting
".."substrings - Rejecting leading
/ - Allowing only a “safe” extension
- Replacing backslashes with slashes
These checks are incomplete because filesystem semantics are richer than strings. On Unix, symlinks can redirect traversal. On Windows, drive prefixes, UNC paths, and alternate separators complicate validation. Even a path that looks relative can resolve outside the intended directory after normalization or symlink resolution.
The right approach is to combine:
- Path parsing
- Canonicalization or resolution
- Base-directory confinement
- Open-time checks that avoid symlink surprises
A practical defense strategy
The safest pattern is to treat user input as a relative name, not a filesystem path. Then resolve it against a trusted base directory and verify that the final target stays inside that base.
Recommended workflow
| Step | Goal | Notes |
|---|---|---|
| Parse | Convert input into a Path | Reject absolute paths early |
| Normalize | Remove . and .. where appropriate | Do not rely on string replacement |
| Resolve | Join with a trusted base directory | Use a directory you control |
| Verify | Ensure the resolved path is still inside the base | Check canonicalized paths carefully |
| Open safely | Avoid following unexpected symlinks | Prefer APIs that reduce TOCTOU risk |
Validating a relative path
Start by rejecting inputs that are clearly unsafe. This is not sufficient by itself, but it reduces obvious abuse.
use std::path::{Component, Path, PathBuf};
fn sanitize_relative_path(input: &str) -> Result<PathBuf, &'static str> {
let path = Path::new(input);
if path.is_absolute() {
return Err("absolute paths are not allowed");
}
let mut clean = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(part) => clean.push(part),
Component::CurDir => {}
Component::ParentDir => return Err("parent directory traversal is not allowed"),
_ => return Err("unsupported path component"),
}
}
if clean.as_os_str().is_empty() {
return Err("empty path is not allowed");
}
Ok(clean)
}This function rejects absolute paths and .. segments. It also rejects prefixes and other platform-specific components that should not appear in user-controlled relative names.
However, this still does not protect you from symlinks inside the base directory.
Confine access to a trusted base directory
Suppose your application stores documents under /srv/app/uploads. A user should only be able to access files under that directory, not anywhere else on the system.
A common technique is:
- Canonicalize the base directory.
- Resolve the candidate path.
- Canonicalize the candidate if it exists.
- Verify that the resolved candidate starts with the canonical base.
use std::fs;
use std::path::{Path, PathBuf};
fn resolve_inside_base(base_dir: &Path, user_input: &str) -> std::io::Result<PathBuf> {
let safe_rel = sanitize_relative_path(user_input)
.map_err(|msg| std::io::Error::new(std::io::ErrorKind::InvalidInput, msg))?;
let base_canon = fs::canonicalize(base_dir)?;
let candidate = base_dir.join(safe_rel);
let candidate_canon = fs::canonicalize(&candidate)?;
if !candidate_canon.starts_with(&base_canon) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path escapes base directory",
));
}
Ok(candidate_canon)
}This works well when the file already exists. But if you are creating a new file, canonicalize will fail because the target does not yet exist. In that case, you need a slightly different strategy.
Safely creating new files
When creating a file, resolve and verify the parent directory rather than the final file path. Then create the file in a way that avoids overwriting or following symlinks where possible.
use std::fs::{self, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
fn create_inside_base(base_dir: &Path, user_input: &str) -> io::Result<std::fs::File> {
let safe_rel = sanitize_relative_path(user_input)
.map_err(|msg| io::Error::new(io::ErrorKind::InvalidInput, msg))?;
let candidate = base_dir.join(&safe_rel);
let parent = candidate
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing parent directory"))?;
let base_canon = fs::canonicalize(base_dir)?;
let parent_canon = fs::canonicalize(parent)?;
if !parent_canon.starts_with(&base_canon) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"parent directory escapes base directory",
));
}
OpenOptions::new()
.write(true)
.create_new(true)
.open(candidate)
}Using create_new(true) prevents overwriting an existing file, which reduces the risk of clobbering important data. This does not eliminate every race condition, but it is much safer than blindly opening a path.
Handling symlinks carefully
Symlinks are one of the most common ways traversal checks fail. An attacker who can place a symlink inside the allowed directory may point it to a sensitive location elsewhere.
For example:
/srv/app/uploads/report.txtis actually a symlink to/etc/shadow- Your validation sees a file under
/srv/app/uploads - The open call follows the symlink and accesses the target
Best practices for symlinks
- Do not trust path checks alone
- Canonicalize the parent directory before creating files
- Prefer opening files with options that reduce symlink following
- Avoid allowing users to control directories that can contain attacker-created symlinks
- Consider storing uploaded files in a directory with restricted permissions
On Unix, lower-level APIs can help if you need stronger guarantees. For example, you may need openat-style patterns or crate support that opens files relative to a directory handle. This is especially useful when you want to avoid time-of-check/time-of-use races.
Avoiding TOCTOU bugs
A time-of-check/time-of-use bug happens when you validate a path and then open it later. An attacker may swap a file or symlink between those two operations.
This is a real risk in concurrent systems. A path that was safe during validation may become unsafe by the time it is opened.
Safer patterns
- Keep the validation and open steps as close together as possible
- Open files with restrictive flags such as
create_new - Use directory-relative file operations when available
- Avoid reusing user-supplied paths after validation
- Do not store “validated” paths and assume they remain safe forever
If your threat model includes local attackers or shared directories, consider using OS-specific APIs or crates that support file-descriptor-based traversal.
Example: secure file lookup service
The following example implements a simple read-only file lookup under a trusted base directory.
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
fn sanitize_relative_path(input: &str) -> Result<PathBuf, &'static str> {
let path = Path::new(input);
if path.is_absolute() {
return Err("absolute paths are not allowed");
}
let mut clean = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(part) => clean.push(part),
Component::CurDir => {}
Component::ParentDir => return Err("parent directory traversal is not allowed"),
_ => return Err("unsupported path component"),
}
}
if clean.as_os_str().is_empty() {
return Err("empty path is not allowed");
}
Ok(clean)
}
fn read_document(base_dir: &Path, user_input: &str) -> io::Result<String> {
let rel = sanitize_relative_path(user_input)
.map_err(|msg| io::Error::new(io::ErrorKind::InvalidInput, msg))?;
let base_canon = fs::canonicalize(base_dir)?;
let candidate = base_dir.join(rel);
let candidate_canon = fs::canonicalize(&candidate)?;
if !candidate_canon.starts_with(&base_canon) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"access outside base directory denied",
));
}
fs::read_to_string(candidate_canon)
}This pattern is appropriate for reading existing files. It rejects absolute paths, blocks .., and verifies that the canonical target remains under the canonical base directory.
When to use a crate
For more advanced path confinement, you may want a crate that provides directory-scoped file access or safer path utilities. This is useful when:
- You need to open many files relative to one directory
- You want stronger protection against symlink races
- You are building archive extractors or sandboxed file browsers
- You need cross-platform abstractions for secure file access
A crate can reduce boilerplate, but it should not replace a clear security model. Always understand whether it validates strings, resolves paths, or uses file-descriptor-based traversal.
Additional hardening tips
Restrict permissions
Even perfect path validation is not enough if the process can read too much.
- Run the service with a dedicated low-privilege user
- Store sensitive files outside writable directories
- Use restrictive filesystem permissions
- Separate upload storage from application configuration
Limit accepted filenames
If your use case only needs simple names, define a narrow allowlist:
- ASCII letters and digits
-,_, and.- No path separators
- No leading dots if hidden files are not needed
This is often better than trying to support arbitrary paths.
Log rejected attempts
Track invalid path attempts for monitoring and abuse detection. Be careful not to log raw attacker-controlled strings without escaping, especially if logs are parsed by other tools.
Test with malicious inputs
Include tests for:
../secret.txt../../../../etc/passwd/etc/passwd.\..\secreton Windows-style inputs- Empty strings
- Symlink-based escapes in integration tests
Security bugs often hide in edge cases that are easy to miss in happy-path testing.
Summary
Directory traversal is a filesystem security problem, not just a string-validation problem. In Rust, the safest approach is to treat user input as an untrusted relative name, validate its components, resolve it against a trusted base directory, and verify that the final target remains confined to that base. For file creation, validate the parent directory and use restrictive open options to reduce race conditions and overwrite risks.
Rust gives you the tools to build secure filesystem code, but the security boundary must be designed explicitly. If your application touches user-controlled paths, make confinement a first-class requirement.
