File Access That Can't Escape Its Directory, via os.Root
Cleaning a filename fixes the lexical problem and leaves the real one alone. You accept a name 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. But 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,
- nothing at all on macOS or Windows.
Since Go 1.24 the standard library does this for you, in the shape of os.Root.
Open the directory once, then everything is relative to it
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. The rules:
- 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.
It works everywhere because it is 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. It opens each path component relative to the previous directory file descriptor with no-follow semantics, and resolves 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:
- you do not need kernel 5.6 or later,
- you do not need a build tag per platform,
- 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.
Zip extraction without a single path check
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,
- entry names use forward slashes, so
path.Diris used rather thanfilepath.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).
A test that plants an escaping symlink
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. 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
Rootat the top of the handler, - replace
filepath.Join(base, name)plusos.Openwithroot.Open(name), - delete the validation you were never quite sure about.