
Preventing Path Injection in Rust: Safely Building File Paths from User Input
What path injection looks like
Path injection happens when untrusted input influences a filesystem path in a way that breaks your intended security boundary. Common examples include:
../traversal to escape a base directory- Absolute paths such as
/etc/passwdorC:\Windows\System32 - Windows-specific prefixes like
\\?\or drive-relative paths - Symlink tricks that redirect a seemingly safe path elsewhere
- Filename confusion caused by separators, dot segments, or normalization differences
A vulnerable pattern often looks harmless:
use std::fs;
use std::path::PathBuf;
fn read_user_file(base_dir: &str, name: &str) -> std::io::Result<String> {
let path = PathBuf::from(base_dir).join(name);
fs::read_to_string(path)
}If name is ../../secret.txt, the resulting path may escape base_dir. If name is absolute, join may ignore the base directory entirely.
Security goal: constrain, don’t just sanitize
The safest approach is not “cleaning” paths until they look acceptable. Instead, define a strict policy:
- Only allow paths relative to a known base directory.
- Reject absolute paths and parent-directory references.
- Resolve the final path and verify it stays inside the base directory.
- Avoid following symlinks when security boundaries matter.
- Prefer identifiers or opaque filenames over user-controlled path fragments.
This is especially important for multi-tenant services, upload handlers, template loaders, and backup/export features.
A practical safe pattern
A robust implementation usually combines input validation with canonicalization and prefix checks. The exact strategy depends on whether the target file must already exist.
Example: reading a file from a fixed directory
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
fn safe_read(base_dir: &Path, user_name: &str) -> io::Result<String> {
let candidate = Path::new(user_name);
// Reject absolute paths and paths with parent components.
if candidate.is_absolute() || candidate.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "invalid path"));
}
let joined = base_dir.join(candidate);
// Canonicalize the base directory and the target path.
// This follows symlinks, so verify the final location carefully.
let base_canon = fs::canonicalize(base_dir)?;
let target_canon = fs::canonicalize(&joined)?;
if !target_canon.starts_with(&base_canon) {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "path escapes base directory"));
}
fs::read_to_string(target_canon)
}This pattern blocks obvious traversal attempts and verifies the resolved target remains under the intended directory.
Why this works
components()lets you inspect the path structurally rather than as a raw string.- Rejecting
ParentDirprevents..traversal. canonicalize()resolves symlinks and normalizes the path.starts_with()on canonicalized paths checks the final filesystem location.
Important caveat: canonicalization can be dangerous if used incorrectly
Canonicalization is useful, but it is not a complete defense by itself.
| Technique | Strengths | Weaknesses |
|---|---|---|
| String replacement | Simple | Easy to bypass with encoding, separators, or platform quirks |
| Component inspection | Good for rejecting .. and absolute paths | Does not resolve symlinks |
canonicalize() + prefix check | Verifies final resolved location | Requires path to exist; can race with filesystem changes |
| Open-by-handle / directory-relative APIs | Strongest for boundary enforcement | More complex, platform-specific |
The main issue is time-of-check to time-of-use (TOCTOU). An attacker may swap a file or symlink between validation and use. If the path points to a location in a writable directory, a race can undermine your checks.
Preventing symlink escapes
Symlinks are a common way to bypass naive path validation. For example, a file named reports/today.txt may look safe, but if reports is a symlink to /etc, your code may read or write outside the intended directory.
Mitigations include:
- Canonicalize the base directory and target path before use.
- Use directory file descriptors or OS-specific “open relative to directory” APIs when available.
- Avoid storing user-controlled files in directories where attackers can create symlinks.
- For uploads, create a dedicated directory with restrictive permissions and ownership.
If you are writing a service that accepts file uploads, do not trust the uploaded filename as the storage path. Generate your own internal filename and store the original name only as metadata.
Safer filename handling
Often the best defense is to stop treating user input as a path at all. Instead, treat it as a label and map it to a safe internal name.
Good approach: opaque storage names
use std::path::{Path, PathBuf};
use uuid::Uuid;
fn storage_path(upload_dir: &Path, original_name: &str) -> PathBuf {
let ext = Path::new(original_name)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let file_id = Uuid::new_v4().to_string();
if ext.is_empty() {
upload_dir.join(file_id)
} else {
upload_dir.join(format!("{file_id}.{ext}"))
}
}This avoids path traversal entirely because the user does not control the directory structure. If you preserve the extension, validate it separately and treat it as a display or content-type hint, not a security control.
Validation rules that actually help
Validation should be narrow and explicit. Good rules depend on your use case, but common safe constraints include:
- Allow only a single filename, not a path
- Reject separators such as
/and\ - Reject
.and..as standalone names - Limit length to a reasonable maximum
- Restrict to a known character set if the filename is user-visible
- Normalize Unicode only if you understand the implications
A simple filename validator might look like this:
fn is_safe_filename(name: &str) -> bool {
if name.is_empty() || name.len() > 128 {
return false;
}
if name == "." || name == ".." {
return false;
}
!name.contains('/') && !name.contains('\\') && !name.chars().any(|c| c.is_control())
}This is not enough for every application, but it is a strong baseline for single-file names.
Writing files safely
Writing is often more dangerous than reading because it can overwrite sensitive data or create files in unexpected locations.
Recommended practices
- Create the target directory yourself with restrictive permissions.
- Use generated filenames rather than user-controlled paths.
- Open files with flags that prevent accidental overwrite when appropriate.
- Avoid following symlinks if the platform and API allow it.
- Ensure the process runs with minimal filesystem privileges.
For example, if you are creating a new file, prefer an API that fails if the file already exists rather than silently overwriting:
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::Path;
fn create_report(dir: &Path, name: &str, contents: &str) -> io::Result<()> {
let path = dir.join(name);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
file.write_all(contents.as_bytes())
}create_new(true) helps prevent accidental overwrite, but it does not solve traversal by itself. Use it only after you have already constrained the path.
Platform-specific pitfalls
Rust’s Path API is cross-platform, but the underlying filesystem rules are not.
Unix
- Leading
/indicates an absolute path. - Multiple slashes may be normalized by the OS.
- Symlinks are common and can redirect access.
- Case sensitivity depends on the filesystem.
Windows
C:\...is absolute.\foomay be drive-relative.\\server\shareis a UNC path.\\?\can disable path normalization and bypass assumptions.- Backslashes and forward slashes may both be accepted in some contexts.
Because of these differences, avoid string-based path checks like “does it start with base_dir?” unless you are comparing canonicalized Path values. Even then, verify behavior on the target platform.
Testing your defenses
Security-sensitive path code deserves dedicated tests. Include cases for:
../secret.txtsubdir/../../secret.txt/absolute/pathC:\absolute\pathsubdir\..\secret.txt- Empty strings
- Very long names
- Symlinked directories, if your tests can create them
A good test suite should confirm both rejection and correct handling of valid inputs. If your application runs on multiple platforms, run tests on each target OS or use platform-specific test cases.
When to use a library
You can implement many checks yourself with std::path, but libraries may help with edge cases and readability. Consider a crate that provides path confinement or safe path joining when:
- You need consistent cross-platform behavior
- You work with untrusted paths frequently
- You want stronger guarantees around directory confinement
- You need to reduce the chance of subtle mistakes
Even with a library, still understand the underlying policy. Security bugs often come from using the right tool with the wrong assumptions.
A practical checklist
Before shipping code that uses user input in paths, verify the following:
- The input is treated as a filename or identifier, not a raw path
- Absolute paths are rejected
- Parent-directory components are rejected
- The final resolved path is verified against a trusted base directory
- Symlink behavior is considered
- Writes use safe creation flags where appropriate
- The process has minimal filesystem privileges
- Tests cover traversal, absolute paths, and platform-specific cases
Conclusion
Path injection is a filesystem boundary problem, not just a string-processing problem. In Rust, the safest designs avoid user-controlled paths altogether. When that is not possible, validate path structure, constrain access to a trusted base directory, and verify the resolved location before reading or writing.
The most reliable solutions combine careful API use, restrictive filesystem permissions, and a design that minimizes how much control the user has over the path in the first place.
