Safe Path Handling: Why Secure Filesystem Operations Are Harder Than You Think
Path traversal vulnerabilities keep showing up. The standard library file I/O functions that most languages provide were designed for convenience, not for adversarial environments. When your code runs in a sandbox, a shared system, or alongside untrusted input, that gap becomes a security boundary violation.
Snyk's security research team has encountered this pattern repeatedly across different ecosystems:
High-severity vulnerabilities in Incus that combined newline injection with symlink attacks to achieve arbitrary file writes and host-level root privilege escalation
A privilege escalation chain in NixOS that exploited race conditions in the Nix store to retain write access to paths that should have been immutable
Ubuntu 24.04 privilege escalation from the default user to root by chaining filesystem manipulation with privileged component bugs
These aren't exotic attack techniques, but rather the result of code that checks a path in one step and uses it in another, with a window in between where an attacker can swap a safe target for a malicious one.
The three vulnerability classes
Path traversal
The classic. User-supplied input like ../../etc/passwd escapes the intended directory. Most developers know to watch for this, but it still shows up regularly because path validation is harder than it looks. Simple string checks fail to account for URL encoding, null bytes, Unicode normalization, and platform-specific path separators.
Symlink attacks
An attacker creates a symbolic link that points from a location your code expects to access to a location it shouldn't. If your code follows symlinks (which is the default behavior on most systems), it will read from or write to the attacker's chosen target.
This is particularly dangerous in shared environments, temporary directories, and container/sandbox boundaries where an attacker may have limited write access to the filesystem.
TOCTOU race conditions
Time-of-Check to Time-of-Use. Your code validates a path, then operates on it. Between those two operations, an attacker swaps the target. The check passes, but the operation hits a different file.
The fundamental problem is that path-based operations are inherently racy on multi-user or multi-process systems. Between any two syscalls that reference the same path, the filesystem state can change.
The solution: File descriptor-based operations
The correct approach uses a combination of openat(), O_NOFOLLOW, and the f* family of syscalls (fstat, fchmod, fchown). The key insight is: once you have an open file descriptor, it refers to a specific filesystem object regardless of what happens to the path.
The pattern works like this:
Start with a known-safe directory (like
/or a sandbox root)Open each path component individually using
openat()with the previous directory's file descriptorUse
O_NOFOLLOWto reject symlinks at each stepValidate each component (no
.., no symlinks, correct permissions)Use the final file descriptor for all subsequent operations
This is TOCTOU-proof because you never use the path again after opening. Every operation goes through the file descriptor, which the kernel guarantees refers to the same inode regardless of path changes.
How languages handle this
C/C++: full control available
C and C++ provide direct access to openat() and O_NOFOLLOW, making correct implementation straightforward (if verbose). Google's solution for ChromeOS (libbrillo/brillo/files/safe_fd.cc) is a good reference implementation.
Go: standard library support (since Go 1.24)
Go added os.Root to the standard library, which provides a safe, race-free way to access files within a directory tree. This is the gold standard for language-level support.
Go's blog post on the design of os.Root is worth reading for the rationale and edge cases they considered.
Rust: good primitives, manual assembly
Rust exposes openat via the nix crate and supports O_NOFOLLOW. The cap-std crate provides capability-based filesystem access that prevents path traversal by construction.
Python: partial support
Python 3.11+ has os.open() with O_NOFOLLOW and dir_fd parameters, but building the full safe traversal pattern requires manual work. There's no standard library equivalent to Go's os.Root.
Node.js: effectively impossible?
This is where things get difficult. Node.js does not expose openat() or O_NOFOLLOW in its standard library. The fs module operates exclusively with paths, not file descriptors. There is no way to perform TOCTOU-safe directory traversal in pure Node.js without native addons.
The best you can do is fs.realpath() followed by a prefix check, but this is inherently racy. A native addon using N-API could expose openat(), but this adds compilation requirements and platform-specific complexity.
This is a significant gap for a runtime that's widely used in CLI tools, build systems, and containerized environments where path safety matters.
When does this matter?
Not every application needs TOCTOU-safe file operations. If your code only opens files from hardcoded paths or only runs in a single-user environment with no untrusted input, the standard library functions are fine.
You should care about safe path handling when:
Your code runs in a sandbox or container, and filesystem operations cross trust boundaries
Your code processes user-supplied file paths (uploads, configuration files, plugin directories)
Your code runs with elevated privileges and accesses paths influenced by unprivileged users
Your code operates in shared environments (multi-tenant systems, CI/CD pipelines, build systems)
Your code handles temporary files in world-writable directories like
/tmp
Recommended actions
Audit your codebase for path-based file operations that take user input
Replace path-based checks (like
startswith()afterrealpath()) with file descriptor-based traversal where possibleIn Go 1.24+, use
os.Rootfor any sandboxed file accessIn Rust, consider the
cap-stdcrate for capability-based filesystem accessIn Node.js, acknowledge the limitation and implement defense-in-depth (input validation, chroot, AppArmor/SELinux profiles)
Use Snyk Code to detect path traversal patterns in your codebase during development
Are hidden file-handling flaws like path traversal quietly exposing your applications to deeper compromise? Download The AI Security Crisis in Your Python Environment whitepaper to learn how modern teams are preventing these risks in AI-generated and developer-written code alike.
WHITEPAPER
The AI Security Crisis in Your Python Environment
As development velocity skyrockets, do you actually know what your AI environment can access?