skip to content

What does os.OpenRoot give an archive unpacker that a check on the entry name cannot?

level: seniorimportance: should knowfreq 40%

answer

  1. the boundary is a handle, not a string
  2. one open directory, names relative to it
  3. components resolved from that directory
  4. a symlink out is an error
  5. os.OpenRoot then Root.Create, never os.Create

basics

~20 s

os.OpenRoot returns an *os.Root holding an open directory handle. Root.Open, Root.Create and Root.OpenFile resolve each name component relative to that handle and fail on anything leaving the root, including through a symlink — enforcement at open time, not string analysis.

solid answer

~50 s

A name check is lexical: it inspects a string and then hands that string to a separate open, which the kernel resolves for real, following symlinks. `os.OpenRoot(dir)` closes that gap by returning an `*os.Root` that holds an **open handle on the directory itself**. `Root.Open`, `Root.Create`, `Root.OpenFile`, `Root.Mkdir` and `Root.Stat` all resolve names relative to that handle, component by component, and return an error rather than a file for any name that would leave the root — an absolute name, a `..` that escapes, or a symlink pointing outside. So for an unpacker writing entry names from an untrusted archive, the guarantee moves from "I believed this string was fine" to "the operation that opened the file refused to leave the directory". In practice I still call `filepath.IsLocal` first, because it gives a clean error naming the bad entry, and then do every open through the `Root` so nothing in the loop can address the wider filesystem.

code

go · 30 lines
go
root, err := os.OpenRoot(tenantDir)
if err != nil {
	return err
}
defer root.Close()

for {
	hdr, err := tr.Next() // tr is a *tar.Reader over the submitted archive
	if errors.Is(err, io.EOF) {
		break
	}
	if err != nil {
		return err
	}
	name, err := filepath.Localize(hdr.Name) // reject non-local entries early
	if err != nil {
		return fmt.Errorf("rejecting entry %q: %w", hdr.Name, err)
	}
	f, err := root.Create(name) // an escape is an error, not a surprise
	if err != nil {
		return err
	}
	if _, err := io.Copy(f, tr); err != nil {
		f.Close()
		return err
	}
	if err := f.Close(); err != nil {
		return err
	}
}

go deeper

for a junior

Know that the standard library has a directory-handle type, os.Root, obtained with os.OpenRoot, and that you open files through its methods instead of building a path with filepath and calling os.Open.

for a middle

Explain the mechanism: the value holds an open directory, names are resolved relative to it component by component, and anything leaving the root — absolute, escaping .., or a symlink pointing out — returns an error. Name the core methods and remember to Close the Root.

for a senior

Show the whole unpack loop and defend it: one Root for the operation, a lexical reject first for a good error message, every open and mkdir through the handle, errors treated as rejected entries, and separate limits for size and count because Root does not provide them.

for a principal

Argue about where the boundary belongs in the system — one component that owns tenant storage and exposes only handle-based operations — and about the evidence you would demand that it holds, such as a fuzz target over entry names running in CI.

## The gap a name check leaves Any check on a path is a check on a **string**. The open that follows is performed by the kernel on the **filesystem**, and the two need not agree: the kernel resolves each component and follows symlinks, and a string predicate cannot see a symlink at all. On top of that, a name and the thing it names are only loosely connected — the directory tree can be modified between the moment you inspect the string and the moment you open it. `os.Root` removes the string from the middle of that. ## What os.Root is ``` func os.OpenRoot(name string) (*os.Root, error) ``` It opens `name` as a directory and returns a value that holds that open handle. Its methods take names **relative to the root** and perform directory-relative operations: - `Root.Open(name) (*os.File, error)` — open for reading. - `Root.Create(name) (*os.File, error)` — create or truncate. - `Root.OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error)` — the general form. - `Root.Mkdir(name, perm)`, `Root.Remove(name)`, `Root.Stat(name)`, `Root.Lstat(name)`. - `Root.OpenRoot(name) (*Root, error)` — a nested root for a subdirectory. - `Root.FS() fs.FS` — an `fs.FS` view backed by the handle. - `Root.Name() string` and `Root.Close() error`; a `Root` holds a descriptor, so close it. Later releases added more of the `os` surface as `Root` methods — `MkdirAll`, `WriteFile`, `ReadFile`, `Symlink`, `Rename` and friends — so a whole subsystem can be written against the handle rather than against global path functions. The rule the methods enforce is uniform: it is an error to reference a name outside the root. Absolute names are refused. A `..` that would climb above the root is refused. A symlink encountered during resolution may not escape the root either — a symlink to `/etc` inside the root does not become a door out of it. The methods return an error; nothing is silently clamped or rewritten. ## Why a handle is stronger than a name When you hold an open directory, the operating system can resolve each component *from that directory* rather than from the filesystem root, and Go uses directory-relative system calls where the platform provides them. The directory you are confined to is identified by the open handle, not by a string that something else could re-interpret. That is the difference between "this text looks contained" and "this open was performed inside that directory". ## Applying it to an unpacker An upload service that unpacks a submitted archive into a per-tenant directory is the worst case for name checks: every entry name is attacker-chosen, there can be thousands of them, and one mistake in one branch is the whole boundary. The shape that holds up: 1. `root, err := os.OpenRoot(tenantDir)` once, `defer root.Close()`. 2. For each entry, reject the name lexically first — `filepath.IsLocal` (or `filepath.Localize` for the slash-separated name an archive gives you) — so a hostile entry produces a precise error rather than a vague open failure. 3. Create directories with `root.Mkdir` and files with `root.Create` or `root.OpenFile`. Never call `os.Create`, `os.MkdirAll` or `filepath.Join(tenantDir, ...)` inside that loop; the point is that the wider filesystem is not addressable from here. 4. Treat an error from a `Root` method as a rejected entry, and log the entry name. The reviewable property that buys you is *local*: it is visible in the loop that nothing but `root` methods touch the disk. You do not have to reason about what each name could become. ## Proving it This is a natural fuzz target. `f.Fuzz(func(t *testing.T, name string) { ... })` seeded with `f.Add` corpus entries like `../x`, `/etc/passwd`, `a/../../b`, `.`, an empty string, and a name with embedded separators; the property asserted is that unpacking an entry with that name either returns an error or produces a file whose path, resolved for real, is under the temp root — and that nothing appears outside it. Fuzzing is a good fit because the input is a single string, the property is cheap to check, and the interesting inputs are exactly the ones a hand-written table forgets. ## Limits worth stating `os.Root` confines path resolution; it is not a sandbox. It does not bound how much you write, how many files you create, or what an entry's contents are, so an unpacker still needs entry-count and size limits and still has to decide what to do about entry types it does not want to materialise. And a `Root` costs a file descriptor, so open one per unpack operation, not one per entry.

  • Which os.Root method would you use to create the intermediate directories an entry name needs?
    `Root.Mkdir(name, perm)` for a single component, and the `Root.MkdirAll` added in a later release for a whole chain — both resolved inside the root. The point is that no step of directory creation may call `os.MkdirAll` on a joined path, or the loop regains the ability to address the wider filesystem.
  • Does os.Root remove the need to limit what an unpacker writes?
    No. Root confines *where* names resolve; it says nothing about how many entries you materialise, how large they are, or what types they are. An unpacker still needs an entry count cap, a per-entry and total size cap, and an explicit decision about entry types it will not create. Confinement and resource limits are separate concerns.
  • How many os.Root values should an unpack of a thousand-entry archive open?
    One, for the duration of the unpack, closed with `defer root.Close()`. A `Root` holds an open directory descriptor, so opening one per entry burns descriptors for nothing. If you want a tighter boundary for a subtree, `Root.OpenRoot` gives you a nested root without going back through a path string.
  • What does Root.FS() give you, and when is it the better handle to pass around?
    It returns an `fs.FS` backed by the same directory handle, so read-only consumers written against `fs.FS` — template loaders, walkers, anything taking an `fs.FS` — get the confinement without knowing about `os.Root`. Pass it when the consumer only reads; keep the `*os.Root` where you need to create or modify.

saying these in an interview costs you the question

  • Says os.Root just calls Clean on the name for you
  • Thinks Root silently clamps escaping names into the root
  • Mixes os.Create with Root.Create in the same loop
  • Believes os.Root also bounds size or entry count
  • Opens a new Root per archive entry
  • Assumes a lexical check makes the handle unnecessary