The Go Blog

Traversal-resistant file APIs

Damien Neil
12 March 2025

A path traversal vulnerability arises when an attacker can trick a program into opening a file other than the one it intended. This post explains this class of vulnerability, some existing defenses against it, and describes how the new os.Root API added in Go 1.24 provides a simple and robust defense against unintentional path traversal.

Path traversal attacks

“Path traversal” covers a number of related attacks following a common pattern: A program attempts to open a file in some known location, but an attacker causes it to open a file in a different location.

If the attacker controls part of the filename, they may be able to use relative directory components ("..") to escape the intended location:

f, err := os.Open(filepath.Join(trustedLocation, "../../../../etc/passwd"))

On Windows systems, some names have special meaning:

// f will print to the console.
f, err := os.Create(filepath.Join(trustedLocation, "CONOUT$"))

If the attacker controls part of the local filesystem, they may be able to use symbolic links to cause a program to access the wrong file:

// Attacker links /home/user/.config to /home/otheruser/.config:
err := os.WriteFile("/home/user/.config/foo", config, 0o666)

If the program defends against symlink traversal by first verifying that the intended file does not contain any symlinks, it may still be vulnerable to time-of-check/time-of-use (TOCTOU) races, where the attacker creates a symlink after the program’s check:

// Validate the path before use.
cleaned, err := filepath.EvalSymlinks(unsafePath)
if err != nil {
  return err
}
if !filepath.IsLocal(cleaned) {
  return errors.New("unsafe path")
}

// Attacker replaces part of the path with a symlink.
// The Open call follows the symlink:
f, err := os.Open(cleaned)

Another variety of TOCTOU race involves moving a directory that forms part of a path mid-traversal. For example, the attacker provides a path such as “a/b/c/../../etc/passwd”, and renames “a/b/c” to “a/b” while the open operation is in progress.

Path sanitization

Before we tackle path traversal attacks in general, let’s start with path sanitization. When a program’s threat model does not include attackers with access to the local file system, it can be sufficient to validate untrusted input paths before use.

Unfortunately, sanitizing paths can be surprisingly tricky, especially for portable programs that must handle both Unix and Windows paths. For example, on Windows filepath.IsAbs(`\foo`) reports false, because the path “\foo” is relative to the current drive.

In Go 1.20, we added the path/filepath.IsLocal function, which reports whether a path is “local”. A “local” path is one which:

  • does not escape the directory in which it is evaluated ("../etc/passwd" is not allowed);
  • is not an absolute path ("/etc/passwd" is not allowed);
  • is not empty ("" is not allowed);
  • on Windows, is not a reserved name (“COM1” is not allowed).

In Go 1.23, we added the path/filepath.Localize function, which converts a /-separated path into a local operating system path.

Programs that accept and operate on potentially attacker-controlled paths should almost always use filepath.IsLocal or filepath.Localize to validate or sanitize those paths.

Beyond sanitization

Path sanitization is not sufficient when attackers may have access to part of the local filesystem.

Multi-user systems are uncommon these days, but attacker access to the filesystem can still occur in a variety of ways. An unarchiving utility that extracts a tar or zip file may be induced to extract a symbolic link and then extract a file name that traverses that link. A container runtime may give untrusted code access to a portion of the local filesystem.

Programs may defend against unintended symlink traversal by using the path/filepath.EvalSymlinks function to resolve links in untrusted names before validation, but as described above this two-step process is vulnerable to TOCTOU races.

Before Go 1.24, the safer option was to use a package such as github.com/google/safeopen, that provides path traversal-resistant functions for opening a potentially-untrusted filename within a specific directory.

Introducing os.Root

In Go 1.24, we are introducing new APIs in the os package to safely open a file in a location in a traversal-resistent fashion.

The new os.Root type represents a directory somewhere in the local filesystem. Open a root with the os.OpenRoot function:

root, err := os.OpenRoot("/some/root/directory")
if err != nil {
  return err
}
defer root.Close()

Root provides methods to operate on files within the root. These methods all accept filenames relative to the root, and disallow any operations that would escape from the root either using relative path components ("..") or symlinks.

f, err := root.Open("path/to/file")

Root permits relative path components and symlinks that do not escape the root. For example, root.Open("a/../b") is permitted. Filenames are resolved using the semantics of the local platform: On Unix systems, this will follow any symlink in “a” (so long as that link does not escape the root); while on Windows systems this will open “b” (even if “a” does not exist).

Root currently provides the following set of operations:

func (*Root) Create(string) (*File, error)
func (*Root) Lstat(string) (fs.FileInfo, error)
func (*Root) Mkdir(string, fs.FileMode) error
func (*Root) Open(string) (*File, error)
func (*Root) OpenFile(string, int, fs.FileMode) (*File, error)
func (*Root) OpenRoot(string) (*Root, error)
func (*Root) Remove(string) error
func (*Root) Stat(string) (fs.FileInfo, error)

In addition to the Root type, the new os.OpenInRoot function provides a simple way to open a potentially-untrusted filename within a specific directory:

f, err := os.OpenInRoot("/some/root/directory", untrustedFilename)

The Root type provides a simple, safe, portable API for operating with untrusted filenames.

Caveats and considerations

Unix

On Unix systems, Root is implemented using the openat family of system calls. A Root contains a file descriptor referencing its root directory and will track that directory across renames or deletion.

Root defends against symlink traversal but does not limit traversal of mount points. For example, Root does not prevent traversal of Linux bind mounts. Our threat model is that Root defends against filesystem constructs that may be created by ordinary users (such as symlinks), but does not handle ones that require root privileges to create (such as bind mounts).

Windows

On Windows, Root opens a handle referencing its root directory. The open handle prevents that directory from being renamed or deleted until the Root is closed.

Root prevents access to reserved Windows device names such as NUL and COM1.

WASI

On WASI, the os package uses the WASI preview 1 filesystem API, which are intended to provide traversal-resistent filesystem access. Not all WASI implementations fully support filesystem sandboxing, however, and Root’s defense against traversal is limited to that provided by the WASI impementation.

GOOS=js

When GOOS=js, the os package uses the Node.js file system API. This API does not include the openat family of functions, and so os.Root is vulnerable to TOCTOU (time-of-check-time-of-use) races in symlink validation on this platform.

When GOOS=js, a Root references a directory name rather than a file descriptor, and does not track directories across renames.

Plan 9

Plan 9 does not have symlinks. On Plan 9, a Root references a directory name and performs lexical sanitization of filenames.

Performance

Root operations on filenames containing many directory components can be much more expensive than the equivalent non-Root operation. Resolving “..” components can also be expensive. Programs that want to limit the cost of filesystem operations can use filepath.Clean to remove “..” components from input filenames, and may want to limit the number of directory components.

Who should use os.Root?

You should use os.Root or os.OpenInRoot if:

  • you are opening a file in a directory; AND
  • the operation should not access a file outside that directory.

For example, an archive extractor writing files to an output directory should use os.Root, because the filenames are potentially untrusted and it would be incorrect to write a file outside the output directory.

However, a command-line program that writes output to a user-specified location should not use os.Root, because the filename is not untrusted and may refer to anywhere on the filesystem.

As a good rule of thumb, code which calls filepath.Join to combine a fixed directory and an externally-provided filename should probably use os.Root instead.

// This might open a file not located in baseDirectory.
f, err := os.Open(filepath.Join(baseDirectory, filename))

// This will only open files under baseDirectory.
f, err := os.OpenInRoot(baseDirectory, filename)

Future work

The os.Root API is new in Go 1.24. We expect to make additions and refinements to it in future releases.

The current implementation prioritizes correctness and safety over performance. Future versions will take advantage of platform-specific APIs, such as Linux’s openat2, to improve performance where possible.

There are a number of filesystem operations which Root does not support yet, such as creating symbolic links and renaming files. Where possible, we will add support for these operations. A list of additional functions in progress is in go.dev/issue/67002.

Previous article: From unique to cleanups and weak: new low-level tools for efficiency
Blog Index