What path traversal looks like

Path traversal happens when untrusted input is used to construct a filesystem path without proper validation. A malicious value such as ../../etc/passwd can move resolution outside the intended directory.

Typical attack surfaces include:

  • Download endpoints that serve files by name
  • Upload handlers that write files to disk
  • Archive extractors
  • Template or report generators that read files by user-supplied names
  • CLI tools that accept relative paths from scripts or automation

The core defense is simple: never trust a path string just because it looks relative. You must verify that the final resolved location stays within an allowed root.

Why Rust still needs explicit path validation

Rust prevents memory corruption, but it does not automatically prevent logic flaws in path handling. The standard library gives you safe path manipulation APIs, yet these APIs do not enforce security boundaries.

For example, Path::join does not sanitize traversal segments:

use std::path::Path;

let base = Path::new("/var/app/data");
let user_input = "../../etc/passwd";
let candidate = base.join(user_input);

assert_eq!(candidate, Path::new("/var/app/data/../../etc/passwd"));

That path still contains traversal segments. Whether it becomes dangerous depends on how it is later used. If you open it directly, the operating system resolves .. and may escape the base directory.

The secure pattern: resolve, then verify containment

The safest approach is to:

  1. Define a trusted base directory.
  2. Combine it with the untrusted input.
  3. Canonicalize or otherwise resolve the path.
  4. Verify that the resolved path is still inside the base directory.
  5. Only then open or read the file.

Important caveat

canonicalize() resolves symlinks and requires the path to exist. That is useful for validation, but it is not always enough for file creation workflows because the target may not exist yet. In those cases, you need a different strategy, such as validating the parent directory and using secure file creation flags.

A practical containment check

The following helper validates that a user-supplied relative path stays under a base directory. It rejects absolute paths, traversal attempts, and paths that escape through symlinks.

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

fn safe_resolve(base: &Path, user_path: &Path) -> io::Result<PathBuf> {
    // Reject absolute paths early.
    if user_path.is_absolute() {
        return Err(io::Error::new(io::ErrorKind::InvalidInput, "absolute paths are not allowed"));
    }

    // Reject traversal components before joining.
    for component in user_path.components() {
        match component {
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid path component"));
            }
            _ => {}
        }
    }

    let joined = base.join(user_path);

    // Resolve symlinks and normalize the final path.
    let canonical_base = base.canonicalize()?;
    let canonical_joined = joined.canonicalize()?;

    if !canonical_joined.starts_with(&canonical_base) {
        return Err(io::Error::new(io::ErrorKind::PermissionDenied, "path escapes base directory"));
    }

    Ok(canonical_joined)
}

Why this works

  • components() lets you inspect the path structure without trusting raw string content.
  • Rejecting ParentDir blocks obvious .. traversal.
  • canonicalize() resolves symlinks, which prevents a symlink inside the base directory from pointing elsewhere.
  • starts_with() checks that the final resolved path is still under the trusted root.

This is a strong baseline for reading existing files.

Handling file reads safely

Suppose you want to serve documents from /srv/app/docs. A user requests a filename, and you need to read it.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

fn read_document(base: &Path, name: &str) -> io::Result<String> {
    let path = safe_resolve(base, Path::new(name))?;
    fs::read_to_string(path)
}

This is safe only if safe_resolve enforces the containment boundary. Do not skip validation and assume read_to_string will protect you; it will not.

Avoid string-based path checks

Do not validate paths with substring checks such as:

  • if input.contains("..")
  • if input.starts_with("/var/app/data")
  • if input.ends_with(".txt")

These are brittle and can be bypassed with normalization quirks, symlinks, or platform-specific separators. Always use path-aware APIs.

Writing files securely

Writing files is trickier than reading because the target may not exist yet. If you accept a filename from a user and write directly to it, an attacker may try to overwrite sensitive files or exploit symlink races.

Safer write strategy

  1. Validate the parent directory, not just the final path.
  2. Create files with exclusive creation when possible.
  3. Avoid following symlinks where the platform supports it.
  4. Use temporary files and atomic rename for updates.

Here is a simple pattern for creating a new file under a trusted directory:

use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

fn create_new_report(base: &Path, name: &str, content: &str) -> io::Result<PathBuf> {
    let target = base.join(name);

    let canonical_base = base.canonicalize()?;
    let parent = target.parent().ok_or_else(|| {
        io::Error::new(io::ErrorKind::InvalidInput, "missing parent directory")
    })?;

    let canonical_parent = parent.canonicalize()?;
    if !canonical_parent.starts_with(&canonical_base) {
        return Err(io::Error::new(io::ErrorKind::PermissionDenied, "parent escapes base directory"));
    }

    let mut file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(&target)?;

    file.write_all(content.as_bytes())?;
    Ok(target)
}

Why create_new(true) matters

create_new(true) ensures the file does not already exist. This reduces the risk of overwriting an attacker-controlled file or following a pre-existing symlink in a race condition. It is not a complete defense by itself, but it is a valuable control.

Comparing common approaches

ApproachGood forWeakness
Raw join()Building paths internallyDoes not validate untrusted input
String filteringQuick prototypesEasy to bypass with encoding, separators, or symlinks
canonicalize() + starts_with()Reading existing filesRequires the path to exist
Parent-directory validation + create_new(true)Creating new filesNeeds careful race-aware design
Temporary file + atomic renameSafe updatesMore code, platform nuances

For security-sensitive code, prefer path-aware validation and explicit containment checks over ad hoc string logic.

Dealing with symlinks

Symlinks are a common source of traversal issues because they can redirect a seemingly safe path to an unsafe location. A directory like /srv/app/data may contain a symlink named reports that points to /etc.

If you validate only the textual path, data/reports/today.txt appears safe. If you resolve symlinks, it may escape the base directory.

Best practices for symlinks

  • Canonicalize the base directory before use.
  • Canonicalize the final target when reading existing files.
  • Reject paths that resolve outside the base.
  • Avoid allowing user-controlled symlink creation inside trusted directories.
  • When creating files, prefer exclusive creation and secure directory permissions.

If your application must process directories supplied by users, treat symlinks as untrusted objects and inspect them explicitly.

Platform-specific considerations

Path handling differs across operating systems.

Unix-like systems

  • / is the path separator.
  • .. and symlinks are the main traversal concerns.
  • Permissions and ownership are critical for protecting trusted directories.

Windows

  • Paths may include drive prefixes such as C:\.
  • Alternate separators and prefix components need special handling.
  • UNC paths and device paths can behave unexpectedly if you rely on string checks.

Using Path::components() is especially important because it interprets platform-specific path structure correctly.

Additional defenses

Path validation should be part of a broader design, not the only control.

Use least privilege

Run the process with the minimum filesystem permissions required. If the application cannot read /etc, a traversal bug is less damaging.

Separate trusted and untrusted storage

Keep user-generated content in a dedicated directory with restrictive permissions. Do not mix application assets, secrets, and uploads in the same tree.

Log rejected attempts carefully

Record invalid path attempts for detection and incident response, but avoid logging raw untrusted input without escaping or quoting. A malicious path may contain control characters.

Prefer allowlists

When possible, map user choices to known filenames instead of accepting arbitrary paths. For example, use an enum or ID-to-file lookup table rather than a free-form path parameter.

A robust design for file download endpoints

A secure download endpoint should avoid exposing filesystem paths directly. Instead of accepting /download?path=..., accept a logical identifier such as a document ID.

Example flow:

  1. Look up the document metadata in a database.
  2. Retrieve the stored relative filename.
  3. Validate the resolved path against the document storage root.
  4. Read the file and stream it to the client.

This design reduces the attack surface because the client never controls the filesystem path directly.

Common mistakes to avoid

  • Trusting PathBuf just because it is not a string
  • Using contains("..") as the only check
  • Validating before joining but not after resolution
  • Forgetting about symlinks
  • Allowing absolute paths from user input
  • Writing files without exclusive creation or parent validation
  • Assuming canonicalize() works for non-existent paths

A secure implementation should be explicit about what is allowed, not just what is forbidden.

Summary

Path traversal prevention in Rust is about disciplined path resolution, not just safe syntax. The key idea is to constrain all file access to a trusted base directory and verify that the final resolved path stays inside it.

Use path-aware APIs, canonicalize when appropriate, reject absolute and parent-directory components, and prefer secure file creation patterns. Combined with least privilege and careful storage design, these practices make filesystem access much harder to abuse.

Learn more with useful resources