filepath.Clean Cannot Contain an Attacker: Using openat2 to Stop Symlink Escapes in Go
Suppose a service stores user files beneath /srv/uploads. A request supplies avatars/alice.png, the service cleans the path, checks that it still appears to be inside the upload directory, and opens it.
clean := filepath.Clean(filepath.Join(uploadDir, name))
if !strings.HasPrefix(clean, uploadDir+string(filepath.Separator)) {
return errors.New("path escapes upload directory")
}
f, err := os.Open(clean)
This catches an obvious ../../etc/passwd. It does not establish that the file opened by the kernel is beneath /srv/uploads.
An attacker who can create /srv/uploads/avatars as a symbolic link to /etc can request avatars/passwd. The cleaned path remains the thoroughly respectable /srv/uploads/avatars/passwd. Path cleaning has done its job; unfortunately, its job was only string manipulation.
Names are not files
filepath.Clean performs lexical simplification. It removes redundant separators and resolves . and .. components without consulting the filesystem. It cannot know that a component is a symbolic link, a mount point, or something being renamed by another process.
Replacing the prefix check with filepath.Rel fixes several string-comparison mistakes, but not this one. Calling filepath.EvalSymlinks before os.Open is also insufficient when an attacker can modify the directory tree. They can change a link after validation and before the open. That check-then-use interval may be tiny, but computers are rather good at repeatedly aiming for tiny intervals.
O_NOFOLLOW is useful but narrower than its name suggests. On Linux it prevents following a symbolic link only in the final pathname component. A link in avatars/alice.png can therefore hide in avatars and still be followed. The Linux open(2) documentation spells out this distinction.
Make containment part of pathname resolution
Linux 5.6 introduced openat2, an extended relative-open system call. It accepts a directory file descriptor and resolution rules which the kernel applies while walking every component of the pathname. The relevant rule is RESOLVE_BENEATH: resolution fails if a component would escape above or outside the directory represented by that descriptor.
This changes the security boundary. Instead of asking, "Did my earlier string check describe a safe path?", the program asks the kernel, "Open this only if the resolution itself remains beneath this directory." Symlink lookup and the containment decision happen within the same kernel operation.
The following Linux-only helper opens a file for reading beneath a trusted root:
//go:build linux
package confined
import (
"errors"
"fmt"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
func OpenBeneath(root, name string) (*os.File, error) {
if name == "" {
return nil, errors.New("empty path")
}
if filepath.IsAbs(name) {
return nil, errors.New("absolute paths are not accepted")
}
rootFD, err := unix.Open(
root,
unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC,
0,
)
if err != nil {
return nil, fmt.Errorf("open root %q: %w", root, err)
}
defer unix.Close(rootFD)
how := &unix.OpenHow{
Flags: uint64(unix.O_RDONLY | unix.O_CLOEXEC),
Resolve: uint64(
unix.RESOLVE_BENEATH |
unix.RESOLVE_NO_MAGICLINKS,
),
}
fd, err := unix.Openat2(rootFD, name, how)
if err != nil {
switch {
case errors.Is(err, unix.ENOSYS):
return nil, fmt.Errorf("openat2 is unavailable: %w", err)
case errors.Is(err, unix.EXDEV), errors.Is(err, unix.ELOOP):
return nil, fmt.Errorf("path escaped root or used a forbidden link: %w", err)
default:
return nil, fmt.Errorf("open %q beneath root: %w", name, err)
}
}
f := os.NewFile(uintptr(fd), name)
if f == nil {
_ = unix.Close(fd)
return nil, errors.New("could not construct os.File")
}
return f, nil
}
O_PATH creates a lightweight reference to the root directory. It is an anchor for later pathname resolution, not a readable directory stream. The file descriptor also continues to identify that directory if it is renamed while the operation is in progress.
RESOLVE_BENEATH rejects absolute input and absolute symbolic links, so the explicit filepath.IsAbs check is primarily an API-level error message. Notice that the helper does not clean the untrusted name. Components such as .. are passed to the kernel, which can decide whether their actual resolution escapes the root.
RESOLVE_NO_MAGICLINKS blocks Linux magic links, notably entries under /proc/PID/fd. The current RESOLVE_BENEATH behaviour also disables them, but the openat2(2) manual explicitly recommends requesting this rule rather than relying on that incidental behaviour.
Decide whether ordinary symlinks are allowed
The example permits an ordinary symlink when its target remains beneath the root. Thus current -> releases/2026-09 can work, while current -> /etc cannot. This is often the useful policy for content trees and deployment directories.
If no untrusted path component should ever be a symlink, add unix.RESOLVE_NO_SYMLINKS. Unlike O_NOFOLLOW, it applies to every component:
Resolve: uint64(
unix.RESOLVE_BENEATH |
unix.RESOLVE_NO_MAGICLINKS |
unix.RESOLVE_NO_SYMLINKS,
),
That stronger rule can reject perfectly legitimate layouts, so it should express an actual security policy rather than a vague preference for more flags.
There is a separate mount-boundary question. RESOLVE_BENEATH can traverse a mount or bind mount located beneath the root. Add RESOLVE_NO_XDEV if crossing one must fail, but expect this to conflict with some container, package and deployment arrangements. An attacker capable of creating mounts generally has powers beyond those of an ordinary account that can merely add symlinks.
Errors and fallbacks are part of the boundary
An escape detected under RESOLVE_BENEATH normally produces EXDEV. Forbidden symbolic or magic links can produce ELOOP. The kernel may return EAGAIN if it cannot prove containment during a racing lookup; a caller may retry a bounded number of times or simply reject the request. For a file-serving endpoint, treating this as a failed request is often the calmer choice.
openat2 returns ENOSYS on kernels predating Linux 5.6. A restrictive seccomp profile may also block it. Do not silently fall back to filepath.Clean followed by os.Open, because that converts a deployment incompatibility into a security vulnerability. Refuse to start, disable the affected feature, or use a carefully implemented component-by-component openat walk.
Go already has a portable higher-level option
For applications using Go 1.24 or later, os.OpenRoot and os.Root should usually be the first choice. They provide traversal-resistant operations beneath an opened directory and work across supported operating systems:
root, err := os.OpenRoot("/srv/uploads")
if err != nil {
return err
}
defer root.Close()
f, err := root.Open(name)
if err != nil {
return err
}
defer f.Close()
The standard-library abstraction follows symlinks that stay inside the root and rejects those which escape. Direct openat2 remains useful when a Linux-specific service needs explicit policies such as RESOLVE_NO_SYMLINKS or RESOLVE_NO_XDEV, or when the exact error behaviour matters.
Neither API freezes file contents or prevents another process from modifying a file after it has been opened. What the returned descriptor gives you is narrower and more precise: it refers to the object that safely resolved beneath the chosen root. That is the guarantee filepath.Clean was never designed to make.