Applications often combine a trusted directory with a file name that came from somewhere less trusted: an HTTP request, archive entry, manifest, job message, or database row.

The obvious implementation is also a common security boundary mistake:

path := filepath.Join("./uploads", userName)
f, err := os.Open(path)

If userName can select a path outside ./uploads, the application may expose files it never intended to touch. Even careful string validation becomes harder when symbolic links and concurrent filesystem changes enter the picture.

Go 1.24 added os.Root and os.OpenInRoot for this exact boundary. They let code express the invariant directly: operations using this root must stay inside this directory tree.

The security property should live in the file operation

A useful rule is:

If a file name is externally influenced and the operation must remain beneath one trusted directory, make that directory a filesystem root for the operation.

For a single read, os.OpenInRoot is concise:

func openUpload(name string) (*os.File, error) {
    return os.OpenInRoot("./uploads", name)
}

OpenInRoot returns an error if a component of name references a location outside the supplied directory.

For several operations under the same directory, open a reusable root:

func readUpload(root *os.Root, name string) ([]byte, error) {
    f, err := root.Open(name)
    if err != nil {
        return nil, err
    }
    defer f.Close()

    return io.ReadAll(f)
}

func run() error {
    root, err := os.OpenRoot("./uploads")
    if err != nil {
        return err
    }
    defer root.Close()

    data, err := readUpload(root, "reports/summary.txt")
    if err != nil {
        return err
    }

    fmt.Printf("read %d bytes\n", len(data))
    return nil
}

The important part is not convenience. The root is an explicit capability: code holding it can operate inside that tree, while names passed to its methods cannot intentionally escape it.

Why filepath.Join is not containment

filepath.Join constructs a path. It does not establish a security boundary.

Code such as:

path := filepath.Join(baseDir, userName)
return os.Open(path)

mixes two different concerns:

  1. where the program wants access to be confined;
  2. which relative name the caller wants to access.

If the caller can influence the second value, containment must be checked separately. With os.Root, containment is part of resolving the operation itself:

root, err := os.OpenRoot(baseDir)
if err != nil {
    return nil, err
}
defer root.Close()

return root.Open(userName)

That distinction becomes especially important when the filesystem can change concurrently.

Lexical validation is useful, but it solves a smaller problem

Go also provides filepath.IsLocal. It reports whether a path is a local, non-empty path that does not escape its evaluation directory lexically. On Windows it also rejects reserved names.

That is useful when the threat model is limited to the input string:

if !filepath.IsLocal(userName) {
    return errors.New("invalid local file name")
}

It can improve error messages and reject obviously unsuitable names early.

But lexical validation does not make a later ordinary os.Open traversal-resistant when an attacker can manipulate the filesystem itself. A path can look local while containing a symbolic link whose target points elsewhere.

Use validation to constrain accepted input. Use os.Root when the actual filesystem operation must remain contained.

A more elaborate implementation may try to resolve a path first, verify the result, and then open it:

resolved, err := filepath.EvalSymlinks(candidate)
if err != nil {
    return err
}

if !isInside(baseDir, resolved) {
    return errors.New("path escaped root")
}

f, err := os.Open(resolved)

This has a time-of-check-to-time-of-use problem. The filesystem may change between validation and os.Open. If an attacker can modify relevant directories or links, the path checked by the program need not resolve the same way when it is used.

os.Root is designed to avoid making containment depend on that separate check-then-open sequence. Its methods resolve operations relative to the root and reject traversal outside it.

A symlink inside the root may still be followed when it stays inside the root. A symlink that would escape the root is rejected. Absolute symlink targets are not permitted through Root operations.

When a service performs several operations in the same tree, opening the root once also makes the ownership boundary clearer:

type UploadStore struct {
    root *os.Root
}

func OpenUploadStore(dir string) (*UploadStore, error) {
    root, err := os.OpenRoot(dir)
    if err != nil {
        return nil, err
    }
    return &UploadStore{root: root}, nil
}

func (s *UploadStore) Close() error {
    return s.root.Close()
}

func (s *UploadStore) Open(name string) (*os.File, error) {
    return s.root.Open(name)
}

Callers no longer need to receive the host filesystem directory and repeatedly reconstruct paths. The store exposes the narrower capability it actually intends to provide.

Root methods are safe for concurrent use from multiple goroutines, so a long-lived service can share a root across requests when that matches the application’s lifecycle.

Creating files needs the same containment boundary

Traversal is not only a read problem. Upload handlers, archive extractors, report generators, and import jobs can overwrite unexpected files if an untrusted destination name escapes its intended directory.

With a root, creation remains relative to the trusted tree:

func createUpload(root *os.Root, name string) (*os.File, error) {
    return root.OpenFile(
        name,
        os.O_WRONLY|os.O_CREATE|os.O_EXCL,
        0o600,
    )
}

O_EXCL here expresses a separate policy: do not replace an existing file. os.Root supplies containment; open flags supply creation and replacement semantics. Keep those concerns distinct.

Newer Go releases add more operations to Root, but code should still choose the smallest operation that matches its policy rather than assuming containment answers every filesystem question.

os.Root is not a complete filesystem sandbox

Containment has boundaries of its own.

Root prevents path components and symlinks from escaping its directory tree, but it does not prohibit traversal across filesystem boundaries such as Linux bind mounts. It also does not turn special files into ordinary files or decide whether a file’s contents are safe to consume.

Platform behavior matters too. In particular, the Go documentation notes weaker traversal guarantees for GOOS=js, where the underlying filesystem API cannot provide the same protection against symlink races.

So do not translate “rooted path access” into “fully sandboxed untrusted code.” It is a precise defense for a precise class of filesystem traversal problems.

Keep resource ownership explicit

os.OpenRoot acquires a resource. Close it when the component that owns the root shuts down:

root, err := os.OpenRoot(dataDir)
if err != nil {
    return err
}
defer root.Close()

Files opened through the root have their own lifecycle and must also be closed:

f, err := root.Open(name)
if err != nil {
    return err
}
defer f.Close()

Closing a root prevents later operations through that root. Treat it like other long-lived resources: establish one owner and make shutdown behavior explicit.

Test escape attempts as behavior

Security-sensitive path handling deserves tests that exercise the invariant rather than only the happy path.

At minimum, test:

  • a normal file directly beneath the root;
  • a nested file beneath the root;
  • a name containing parent traversal that would leave the root;
  • an absolute path;
  • an internal symlink that remains beneath the root, where supported;
  • a symlink that points outside the root;
  • operations after the root has been closed.

A test for parent traversal can be small:

func TestRootRejectsEscape(t *testing.T) {
    parent := t.TempDir()
    rootDir := filepath.Join(parent, "root")

    if err := os.Mkdir(rootDir, 0o755); err != nil {
        t.Fatal(err)
    }
    if err := os.WriteFile(filepath.Join(parent, "secret.txt"), []byte("secret"), 0o600); err != nil {
        t.Fatal(err)
    }

    root, err := os.OpenRoot(rootDir)
    if err != nil {
        t.Fatal(err)
    }
    defer root.Close()

    if _, err := root.Open("../secret.txt"); err == nil {
        t.Fatal("expected traversal to be rejected")
    }
}

For symlink tests, account for platforms where creating symlinks requires different permissions or semantics. The assertion you care about is that an operation through the root cannot use an escaping link to reach the outside target.

Prefer containment over path-prefix tricks

Path-prefix checks are easy to get subtly wrong. String prefixes do not understand path components, platform separators, volume names, symlinks, or races.

If the real requirement is “this externally influenced name may only access files under this directory,” encode that requirement at the filesystem boundary:

root, err := os.OpenRoot(trustedDir)
if err != nil {
    return err
}
defer root.Close()

f, err := root.Open(untrustedName)
if err != nil {
    return err
}
defer f.Close()

That design is easier to review because the security property is visible where the file is actually opened. Validation can still narrow accepted names, and application policy can still restrict file types or sizes, but containment no longer depends on manually proving that an ordinary host path stayed inside a directory.