Safeguard
Industry Analysis

Path Traversal Prevention in Rust with fs::canonicalize

fs::canonicalize resolves `..` and symlinks into one absolute path, but it can't fix TOCTOU races, missing files, or Windows prefix quirks on its own. Here's the safe pattern.

Aman Khan
AppSec Engineer
7 min read

Path traversal ranked eighth on MITRE's 2023 CWE Top 25 Most Dangerous Software Weaknesses list, sitting alongside SQL injection and cross-site scripting as one of the most exploited bug classes in production software. That ranking should worry Rust teams specifically, because Rust's headline pitch — memory safety without a garbage collector — says nothing about logic errors like CWE-22. A service that never segfaults can still hand an attacker /etc/passwd, a .env file, or another customer's private upload if a single PathBuf::join call trusts user input unchecked. std::fs::canonicalize is the function most Rust developers reach for to close that gap, and used correctly it works well. Used carelessly — which happens more often than the documentation implies — it creates a false sense of security while leaving symlink races, non-existent-path errors, and Windows path quirks wide open. Here's what canonicalize actually does, where it breaks down, and how to validate paths safely in production Rust code.

What is path traversal, and why doesn't Rust's memory safety stop it?

Path traversal (CWE-22) happens when attacker-controlled input reaches a filesystem operation without being validated against an intended directory boundary, and it is a logic bug, not a memory bug, so Rust's borrow checker has nothing to say about it. The classic payload is a filename like ../../../../etc/passwd appended to a "safe" base directory:

fn read_user_file(base: &str, filename: &str) -> std::io::Result<Vec<u8>> {
    let path = format!("{}/{}", base, filename);
    std::fs::read(path)
}

If filename is attacker-supplied and never checked, read_user_file("/srv/app/uploads", "../../../../etc/passwd") reads a file well outside /srv/app/uploads. Rust's ownership model guarantees that path won't dangle or alias memory unsafely — it says nothing about which bytes end up in that String. The same class of bug shows up in file uploads, static file servers, template loaders, log exporters, and archive extraction, and it compiles cleanly every time because cargo check has no concept of "trusted" versus "untrusted" strings.

How does fs::canonicalize actually stop a .. payload?

fs::canonicalize stops traversal by resolving every ., .., and symlink component into one absolute, normalized path in a single OS call, turning the problem from "pattern-match untrusted text" into "compare two fully-resolved paths." On Linux and macOS it wraps the libc realpath(3) function; on Windows it calls GetFinalPathNameByHandleW, which means canonicalizing a path on Windows actually opens a handle to the target to resolve it. The standard safe pattern is to canonicalize both the base directory and the requested path, then confirm one is a prefix of the other:

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

fn safe_join(base: &Path, user_input: &str) -> io::Result<PathBuf> {
    let candidate = base.join(user_input);
    let canonical_base = base.canonicalize()?;
    let canonical_candidate = candidate.canonicalize()?;

    if canonical_candidate.starts_with(&canonical_base) {
        Ok(canonical_candidate)
    } else {
        Err(io::Error::new(
            io::ErrorKind::PermissionDenied,
            "path escapes base directory",
        ))
    }
}

Because the comparison happens after both symlinks and .. segments are fully resolved, an attacker cannot hide a traversal inside a symlink, a mixed-case path, or a redundant ./ segment — the OS collapses all of it before Rust ever compares strings.

Where does fs::canonicalize fall short on its own?

fs::canonicalize has three well-documented gaps that turn "I called canonicalize" into a false sense of security. First, it requires the target to already exist — calling it on a file that's about to be created (a fresh upload, a new cache entry) returns an io::ErrorKind::NotFound error, so teams often skip validation entirely for write paths. The fix is to canonicalize the parent directory instead and rejoin the final filename after checking it contains no separators. Second, canonicalize creates a textbook time-of-check-to-time-of-use (TOCTOU) race: nothing stops an attacker from swapping a symlink between the moment canonicalize resolves it and the moment your code later calls File::open on the same path, because the check returns a plain PathBuf, not a locked handle. Third, on Windows, canonicalize returns paths prefixed with \\?\ (the "verbatim" or extended-length prefix), and comparing that against a string literal or a non-canonicalized base with starts_with silently fails — a mismatch that has generated recurring confusion in the Rust community since the function shipped in Rust 1.0.

How have real Rust projects gotten this wrong?

The most common real-world failure mode is archive extraction, the pattern popularized by Snyk's 2018 "Zip Slip" research, which showed that extracting a .zip or .tar file entry-by-entry without validating each entry's resolved destination lets a malicious archive drop files anywhere on disk that the process can write to. RustSec (rustsec.org), the advisory database maintained by the Rust Secure Code working group, has cataloged several hundred advisories across the crates.io ecosystem, and path-handling issues in archive and file-serving crates are a recurring category — because tar and zip entries can legally contain ../ segments or absolute paths, and a naive extractor that does dest_dir.join(entry.path()) inherits the traversal for free. A second, Rust-specific footgun compounds this: Path::join silently discards the base entirely when the second argument is an absolute path. PathBuf::from("/srv/uploads").join("/etc/passwd") evaluates to /etc/passwd, not /srv/uploads/etc/passwd — no .. required. Teams that only defend against .. and never check for a leading / or a Windows drive letter ship a bypass on day one.

What's the safe pattern for validating user-controlled paths in Rust today?

The safe pattern combines four checks instead of relying on canonicalize alone: reject absolute input up front, canonicalize the base once, canonicalize the resolved candidate (or its parent directory for not-yet-created files), and compare with starts_with on both canonicalized values.

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

fn safe_path_for_write(base: &Path, user_input: &str) -> io::Result<PathBuf> {
    let input_path = Path::new(user_input);
    if input_path.is_absolute() {
        return Err(io::Error::new(io::ErrorKind::InvalidInput, "absolute paths rejected"));
    }

    let candidate = base.join(input_path);
    let parent = candidate.parent().unwrap_or(base);
    std::fs::create_dir_all(parent)?;

    let canonical_base = base.canonicalize()?;
    let canonical_parent = parent.canonicalize()?;

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

    Ok(canonical_parent.join(candidate.file_name().unwrap_or_default()))
}

For the TOCTOU gap, canonicalize alone can't fix it — closing it requires either the cap-std crate, which does capability-based filesystem access by opening every file relative to a directory handle that physically cannot resolve outside its root, or the same-file crate, which compares open file handles (inode/device on Unix, file index on Windows) instead of path strings after the fact. path-clean is useful earlier in the pipeline for lexical normalization (collapsing .. and . without touching the filesystem) when you need to reject obviously malformed input before doing any I/O at all.

Does fixing the code once satisfy audit and compliance requirements?

Fixing one call site does not satisfy SOC 2 or supply-chain security requirements, because those frameworks ask for continuous evidence that a control stays in place as dependencies change, not a one-time patch. A codebase can be clean of unsafe PathBuf::join calls today and still ship a path traversal bug next quarter through a new tar, zip, or static-file-serving dependency that a teammate pulls in without checking its advisory history. Path traversal in Rust is therefore as much a dependency-management problem as a coding-pattern problem: the safe primitive (fs::canonicalize used correctly) has to be paired with visibility into which crates in the dependency graph touch the filesystem with user-influenced input at all.

How Safeguard Helps

Safeguard treats path traversal the way it treats every other supply-chain risk: as something that has to be caught before merge and re-checked continuously, not verified once and forgotten. Safeguard's software composition analysis scans a Rust project's full dependency tree — including transitive crates — against RustSec advisories, so a tar, zip, or static-file crate with a known path-handling issue is flagged before it ships, not after an incident. Safeguard's Gold CVE and package intelligence search lets engineers check a crate's advisory history before adding it to Cargo.toml, closing the gap where a vulnerable dependency gets pulled in simply because nobody thought to look. SCM-integrated scans surface risky filesystem patterns — raw path concatenation, Path::join calls fed by unchecked input, missing canonicalize-plus-starts_with pairing — directly in pull requests, so the review that would normally require a security engineer's attention happens automatically at commit time. And because Safeguard continuously monitors production dependency graphs rather than scanning once at release, a crate that's clean today but receives a new path-traversal advisory tomorrow gets flagged the moment the advisory is published, giving teams the audit trail SOC 2 change-management controls require and the lead time to patch before an attacker finds it first.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.