Prompt and scope
An archive extractor writes user uploads into /srv/uploads/job-42. Archive entry names are fully controlled by the caller, who may submit ../../etc/passwd, an absolute path, or a symlink that points outside the job directory. The service must create directories and files while ensuring that every operation stays inside the job directory.
Use Go 1.24 or later. Assume ordinary application privileges and Linux in production, with possible Windows or WASI clients. Distinguish an untrusted filename from a caller that is explicitly allowed to choose any output directory.
What the interviewer is testing
- Whether you define the root and attacker-controlled input before choosing a directory-constrained API instead of doing string replacement.
- Whether you know that
os.Rootrejects..and symlinks that escape the root, and where that guarantee differs fromfilepath.Clean. - Whether you recognize the TOCTOU window in checking with
EvalSymlinksand opening later. - Whether you handle
Root, file descriptors, temporary files, cleanup, and permission policy. - Whether you state the limits involving bind mounts,
GOOS=js, WASI implementations, and very deep paths.
Questions to clarify first
- Is the operation reading, writing, or both? Read-only access and creation need different
OpenFileflags and permissions. - Is the root fixed by the service or chosen by the caller? If the caller can choose any directory,
os.Rootis not an additional sandbox boundary. - May the archive create symlinks, hard links, device files, or renames? Disallowed types must be rejected by archive policy rather than delegated to
Root. - Are
GOOS=jsor WASI targets in scope? The official material describes different path-safety guarantees from Unix descriptor-based implementations.
A 30-second answer framework
“I make the job directory the only root. Every archive name is passed as a relative name to an os.Root; I do not join it with a base string and call ordinary os.Create. Creation, opening, and removal go through Root methods, so .. and escaping symlinks fail. I write through a temporary name with restricted permissions, then commit according to the product policy. Tests cover traversal, symlink races, concurrent creation, Windows device names, and closing the root. Root constrains paths, but it does not replace archive-type checks, privilege isolation, or bind-mount controls.”
Step-by-step deep dive
1. Fix the security invariant
“The name does not contain ..” is not a security definition. An attacker can use a symlink or replace a directory entry between a check and an open. State the invariant precisely: every filesystem operation derived from an archive name resolves within the job root, and an operation fails when that cannot be proven.
Go 1.24’s os.OpenRoot opens a root directory. Methods such as Root.Open, Root.Create, Root.OpenFile, Root.Mkdir, and Root.Stat accept names relative to that root. The implementation rejects .. and symlink traversal outside the root. Reuse one root for the task, and close it after all file handles are closed.
func writeEntry(rootDir, name string, data []byte) error {
root, err := os.OpenRoot(rootDir)
if err != nil {
return err
}
defer root.Close()
f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(data)
return err
}This prevents name resolution from escaping, but production code still needs limits on file size, entry count, and directory depth, plus removal of incomplete output after a write failure. O_EXCL means “do not overwrite an existing file”; it is not archive deduplication or business idempotency.
2. Explain why string sanitization is insufficient
filepath.Clean can normalize a/../b, but it cannot prove that a is not a symlink outside the root. Checking with EvalSymlinks and opening later still has a TOCTOU window: a directory entry can change after the check. filepath.Join followed by os.Create provides no directory boundary.
On Unix, Root uses a root directory descriptor and constrained relative opens, so the boundary participates in the operation instead of relying on a separate check. Root-internal relative paths and symlinks are allowed; ../ or an absolute symlink that escapes must fail. The caller should still reject symlink entries in the archive unless the product explicitly supports them.
3. Handle archive types and writes
Read each entry’s type, size, permissions, and name before writing. Reject device files, FIFOs, hard links, and symlinks that the product does not support. Create parent directories with Root.Mkdir or MkdirAll, then create regular files with Root.OpenFile. Budget total bytes, per-file bytes, path components, and concurrent jobs.
A safer write sequence is “temporary name → complete write → verify → atomic commit.” The temporary name must also be created through Root; writing in the system temporary directory and moving across directories changes permission, mount, and atomicity assumptions. If the available Root API cannot provide the required rename behavior, make that a documented platform or product constraint instead of silently falling back to unrestricted path operations.
4. State platform and privilege boundaries
The Go material says Unix implementations normally track the root directory descriptor, so a renamed root still refers to the original directory. Windows uses a handle and blocks some reserved device names. GOOS=js lacks the openat family of calls, leaving a TOCTOU limit in symlink validation; WASI’s guarantee depends on its implementation. Root also does not block Linux bind mounts, /proc special files, or Unix device-file access.
Container isolation, mount policy, process privileges, and an archive-type allowlist are therefore separate controls. Do not describe Root as a complete container sandbox or treat a caller-selected arbitrary directory as a constrained root.
5. Build executable verification
Test ../escape, absolute paths, root-internal symlinks, symlinks outside the root, names ending in ../, concurrent creation of one file, oversized entries, and calls after closing the root. Go 1.24.3 fixed a case where a Root path ending in ../ could open the parent directory, so CI should pin a toolchain containing the fix and retain that regression test.
On Linux, use temporary directories and real symlinks for both accepted and rejected cases. Use -race for shared-state races, but do not treat it as proof of a filesystem boundary. A cross-compiled binary does not prove identical kernel guarantees; include each GOOS in the test matrix.
High-quality sample answer
“I make the job directory a fixed root and allow untrusted archive names only as relative paths passed to os.Root. The extractor rejects device files, hard links, and unsupported symlinks, and limits entries, bytes, and directory depth. It creates parent directories and regular files through Root, writes to a temporary name under the same root, and removes incomplete output on failure. .. and symlinks that escape are rejected by the constrained open itself, avoiding a check-then-open TOCTOU window.
I would not call this a complete sandbox: bind mounts, privileges, container mounts, and archive policy remain separate. Linux tests use real symlinks and concurrent writes for traversal, duplicate creation, cleanup, and trailing ../; Windows, WASI, and GOOS=js get explicit boundary tests. If the product permits an arbitrary caller-selected directory, I remove the false Root assumption and use an explicit privilege and audit policy.”
Common mistakes
- Symptom:
filepath.Join(base, name)followed by ordinaryos.Create→ Why it fails:..and symlinks can resolve outside base → Fix: fix the root and perform every operation throughRoot. - Symptom: check with
EvalSymlinks, then open normally → Why it fails: a TOCTOU window remains → Fix: make the constraint part of the open and test the race boundary. - Symptom: claim that
os.Rootblocks every filesystem escape → Why it fails: bind mounts, device files, and privileges are outside that API guarantee → Fix: add mount, privilege, and archive-type controls. - Symptom: ignore
GOOS=js, WASI, and patch versions → Why it fails: platform implementations and security fixes differ → Fix: pin the toolchain and build a cross-platform regression matrix. - Symptom: test only successful extraction → Why it fails: path safety is evidenced by rejected escapes and cleanup → Fix: test traversal, absolute names, symlinks, conflicts, limits, and closed-root calls.
Follow-up questions and responses
Follow-up 1: Should a root-internal symlink in an archive be allowed?
Decide from the product requirement. If links must be preserved, verify through Root that the target stays inside the root and cap link count and depth. If the job only extracts regular files, rejecting symlinks is easier to audit. Either way, Root does not replace an archive-type allowlist.
Follow-up 2: How do you handle Linux bind mounts?
Root does not provide bind-mount isolation. Put the job directory on a dedicated filesystem through container or host mount policy, restrict application privileges, and reject untrusted mounts in deployment checks. If the threat model includes a privileged attacker, use a stronger sandbox or an isolated worker instead of adding more string checks.
Follow-up 3: Why not write a temporary file in the system temp directory and move it into the root?
A cross-directory move changes permission, mount, and atomicity assumptions, and the temporary file may leak. Create the temporary name through Root and complete the write and commit under the same root. If the platform cannot provide the needed atomic operation, document a reduced feature or audited compatibility implementation instead of silently returning to ordinary path joins.
Follow-up 4: Can GOOS=js be claimed to have the same safety?
No. The official explanation says that target lacks the openat family and has a TOCTOU limitation in symlink validation. Restrict the feature for that target, rely on the runtime’s sandbox, or move untrusted-file processing to a server with stronger directory constraints. The API and documentation must disclose the difference.