Go's os.Root: Traversal-Resistant File Access Without openat2
The classic bug goes like this. You accept a filename from somewhere untrusted, join it onto a base directory, and open it. Someone sends ../../etc/passwd, so you reach for filepath.Clean or check for a .. prefix. That fixes the lexical problem and leaves the real one alone: the filesystem is not a string, and a symlink inside your base directory can point anywhere it likes.
The proper fix on Linux has long been to walk the path yourself with openat, or hand the whole job to the kernel with openat2 and RESOLVE_BENEATH. That means raw syscalls via golang.org/x/sys/unix, a fallback for older kernels, and nothing at all on macOS or Windows. Since Go 1.24 the standard library does this for you, in the shape of os.Root.
The API in one screen
You open a directory once, and every operation after that is relative to it:
root, err := os.OpenRoot("/srv/uploads")
if err != nil {
return err
}
defer root.Close()
f, err := root.Open("reports/2026/q3.csv")
if err != nil {
return err // an escape attempt lands here too
}
defer f.Close()
Names passed to a Root method are resolved inside that directory. A .. that would climb out is refused. A symlink is followed only if its target stays inside the root; an absolute symlink is rejected outright, and so is a relative one that climbs out. The failure is an ordinary *fs.PathError, with a message along the lines of "path escapes from parent". If you want to branch on it programmatically, check the docs for your Go version rather than relying on my memory of the exact error value.
There is also os.OpenInRoot(dir, name) for the one-shot case, which opens the directory, opens the file and gives you back just the file. In 1.24 the Root methods cover Open, Create, OpenFile, OpenRoot, Mkdir, Remove, Stat and Lstat. Go 1.25 widened it considerably (MkdirAll, ReadFile, WriteFile, Rename, RemoveAll, Symlink, Readlink and friends), so if a method you expect is missing, check your toolchain version first.
root.FS() returns an fs.FS with the same confinement, which is handy for handing to http.FileServerFS or template.ParseFS. Unlike os.DirFS, which is only as safe as the directory contents, the Root version follows symlinks under the same rules as everything else.
Why not just openat2?
Actually, this bit is interesting: os.Root is not a thin wrapper over openat2. As I understand the implementation, on Unix it does the walk in user space using the *at family of calls, opening each path component relative to the previous directory file descriptor with no-follow semantics, and resolving symlinks itself so it can check each target. Windows gets a comparable design built on handles relative to the root. I would not lean on the precise mechanism in your own reasoning, since it is an implementation detail that could change; the documented contract is what counts.
The reason this matters is that you do not need kernel 5.6 or later, you do not need a build tag per platform, and you do not get the "works on my Linux box" surprise. The price is more syscalls per open than a single openat2, which for upload handlers and archive extraction is noise.
The race-safety comes from the same place as it does with hand-rolled openat: each step goes through a directory descriptor you already hold, so swapping a directory for a symlink between "check" and "open" does not help an attacker. A string-based check followed by os.Open has exactly that window.
A worked example: zip extraction
Zip slip is the textbook case. Entry names are attacker-controlled and can contain ../. Here is extraction confined to a destination directory, using 1.25's MkdirAll:
package main
import (
"archive/zip"
"fmt"
"io"
"os"
"path"
)
func extract(zr *zip.Reader, dest string) error {
root, err := os.OpenRoot(dest)
if err != nil {
return err
}
defer root.Close()
for _, zf := range zr.File {
if zf.FileInfo().IsDir() {
if err := root.MkdirAll(zf.Name, 0o755); err != nil {
return fmt.Errorf("mkdir %q: %w", zf.Name, err)
}
continue
}
if err := root.MkdirAll(path.Dir(zf.Name), 0o755); err != nil {
return fmt.Errorf("mkdir for %q: %w", zf.Name, err)
}
if err := writeEntry(root, zf); err != nil {
return fmt.Errorf("extract %q: %w", zf.Name, err)
}
}
return nil
}
func writeEntry(root *os.Root, zf *zip.File) error {
src, err := zf.Open()
if err != nil {
return err
}
defer src.Close()
dst, err := root.OpenFile(zf.Name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(dst, src); err != nil {
dst.Close()
return err
}
return dst.Close()
}
Simplifications, flagged: there is no size limit on the copy (a decompression bomb will happily fill your disk), the file mode ignores what the archive says, and entry names use forward slashes so path.Dir is used rather than filepath.Dir. O_EXCL is there deliberately so an archive cannot overwrite a file an earlier entry created, or write through a symlink an earlier entry planted; inside a Root that symlink could only point within the destination anyway.
Notice what is absent: no filepath.Clean, no strings.HasPrefix against the base path, no EvalSymlinks. Those are the three things people bolt on, and each has a known way of failing (prefix checks without a trailing separator, for one).
Checking it does what it says
Trust, but poke it. This test plants an escaping symlink and confirms the open fails:
func TestRootBlocksSymlinkEscape(t *testing.T) {
outside := t.TempDir()
if err := os.WriteFile(outside+"/secret", []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
inside := t.TempDir()
if err := os.Symlink(outside, inside+"/link"); err != nil {
t.Skip("symlinks unavailable:", err)
}
root, err := os.OpenRoot(inside)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if f, err := root.Open("link/secret"); err == nil {
f.Close()
t.Fatal("opened a file outside the root")
}
}
Run the same thing with plain os.Open(inside + "/link/secret") and it succeeds, which is rather the point.
What it does not cover
- Mount points. The documentation is explicit that
Rootdoes not stop traversal into a different filesystem mounted inside the directory. A bind mount of/under your upload directory is out of scope. If an attacker can create mounts, you have a bigger problem. - The root itself.
OpenRoot(dir)resolvesdirnormally, symlinks and all. Confinement starts from the descriptor it returns. Do not pass it an attacker-influenced string. - Hard links. A hard link to a sensitive file, created by something with access, is just another name for the inode. Same story for anything else that can already write inside the directory in a hostile way.
- Permissions and content. It confines where you can go, not what a file is. A FIFO or device node inside the root will still be opened as one, so
Statbefore you read if that matters. - Windows names. Reserved device names and similar oddities are their own category; check the package docs for current behaviour before assuming they are covered.
- Old toolchains. Anything built with Go before 1.24 does not have it at all, and code written against 1.25 methods will not compile on 1.24.
It is a containment tool for path resolution, not a sandbox. If you need to limit what a whole process can do, that is a job for seccomp, Landlock or a container, and os.Root sits comfortably underneath any of them as the layer that makes your own file handling honest.
For most code that takes a name from outside and touches disk, the migration is mechanical: open a Root at the top of the handler, replace filepath.Join(base, name) plus os.Open with root.Open(name), and delete the validation you were never quite sure about.