Would you mandate os.Root for every untrusted filename across your team's Go services, and how do you make that stick?
answer
- name the trust boundary, not a blanket rule
- outside-the-process names get a handle
- no exported function returns the base path
- the floor it sets on your go directive
- evidence in CI beats reviewer vigilance
basics
~20 sYes for names crossing a trust boundary, but only if the safe call is the easy one: ship one handle-based storage package that hands out no base path string, accept the Go 1.24 floor, and make exceptions named and owned.
solid answer
~50 sI would make handle-based access the default rather than a guideline. The rule I can defend is narrow: any filename that came from outside the process — a request, an upload, an archive index, a job payload — is opened through an `*os.Root`, never through `os.Open` on a joined path; operator-controlled paths from config or flags stay ordinary. Making it stick is the real work. Expose one internal package that hands out roots for tenant storage and offers no string-path escape hatch, so the wrong thing is harder to write than the right one. Take the toolchain floor `os.Root` implies (Go 1.24) as an explicit decision with a migration order: untrusted-input code first. Back it with evidence rather than vigilance — a fuzz target over hostile entry names in CI. Exceptions get a written reason and an owner. I am accepting a version floor and churn; I am refusing a boundary that depends on every future author remembering a string rule.
go deeper
You are not expected to set this policy, but know which side of it your code sits on: if a filename came from a request or an archive, it goes through the handle-based helper your team provides rather than through os.Open on a joined path.
Be ready to carry out such a migration: find the call sites that consume outside names, move them onto Root methods, and notice when a dependency's filename-taking API blocks you. Explain the version floor the change implies.
Show that you would enforce the rule structurally — a storage package that exposes no base path string — and with evidence in CI rather than reviewer memory, and that you can order the migration so the highest-risk code moves first.
Own the tradeoff out loud: the toolchain floor, the churn, the dependencies that will not fit, and who can grant an exception. Be able to justify the rule as making review cheaper, not merely as a security preference.
## The decision, stated narrowly A blanket "always use `os.Root`" is easy to say and easy to erode. The defensible version names the trust boundary: **a path element that entered the process from outside is opened through a directory handle; a path controlled by the operator is not**. Request parameters, upload filenames, archive entry names, keys in a job payload, anything from a database row that originated with a user — handle. A log directory from a config file, a certificate path from a flag, a fixed asset directory baked into the binary — ordinary `os` calls, because the string never left your control and wrapping it buys nothing but noise. That distinction is what makes the rule reviewable. "Does this name come from outside?" is a question a reviewer can answer in a diff; "is this path safe?" is not. ## Make the safe call the default, not the disciplined one Rules that depend on every future author remembering them decay at the rate people join the team. The structural version is a small internal package that owns tenant or job storage and whose API hands back an `*os.Root` (or an `fs.FS` from `Root.FS` for read-only consumers) and **never** a directory path string. If there is no exported function that returns `"/var/lib/app/tenants/" + id`, nobody joins onto it. The next author gets the boundary for free without knowing why it is there — which is the only kind of boundary that survives. Pair it with a lexical reject on the way in (`filepath.IsLocal`, or `filepath.Localize` for slash-separated names off the wire) so a hostile name produces a precise error naming the entry rather than a generic open failure. The predicate is for diagnostics; the handle is for the guarantee. ## What the mandate costs, honestly - **A toolchain floor.** `os.Root` is Go 1.24. Adopting it sets the `go` directive in every module that uses it. For most teams that is already true; if you have a module pinned lower for a reason, that reason is now on the table and someone has to own it. - **Churn at call sites.** Code written against `os.Open`/`os.Create` on joined paths has to move to `Root` methods, and libraries that accept a *filename* rather than an `io.Reader` or an `*os.File` will not fit. Some of those you wrap; a few you replace. This is why migration order matters: do the code that consumes untrusted names first, and let the rest follow when it is touched anyway. - **A per-operation descriptor.** A `Root` is an open directory. Long-lived roots need the same lifetime discipline as any handle, and a design that would open one per item is a design smell to catch in review. What it does **not** cost is meaningful throughput; this is not a performance tradeoff, and framing it as one is usually a proxy for not wanting to do the migration. ## Evidence instead of vigilance The rule should be checked by something that does not get tired. Options, cheapest first: a fuzz target over entry names that asserts nothing is ever created outside a temp root, run in CI as a seeded test and periodically as a real fuzz session; an import or call check written as a small analyzer over `golang.org/x/tools` that flags `os.Open`/`os.Create`/`os.OpenFile` inside the package that handles submitted archives; and a review checklist item that exists only as a fallback. If the only enforcement is code review, assume the rule is already partly broken and design for that. ## Exceptions, and who can grant them There will be a genuine one — a vendored library that insists on a filename, a platform where the guarantee is weaker, a migration in flight. The exception should be a named, dated line in the repository with the reason and an owner, not an unremarked call site. Whoever owns the security posture for the service is the one who can grant it, and the one who can overrule an engineer's "I already checked the string". That is the substance of the decision: not that string checks are useless, but that the team stops relying on each author's checking being correct, and moves the boundary somewhere it can be seen. ## How I would sell it Not as a security lecture. As a reduction in what a reviewer has to hold in their head: with handle-based access, the reviewable property is local — *this loop only touches the disk through `root`* — and a reader can confirm it in seconds. With string checks, the property is global and depends on every path the name could take through the function. Teams accept rules that make review cheaper.
- Where would you draw the line so the rule does not become "wrap every file operation"?At the origin of the name. Anything that entered the process from outside — request, upload, archive index, job payload, user-supplied row — goes through a root. Operator-controlled paths from flags, config or the binary itself stay ordinary. That question is answerable in a diff, which is what makes the rule survive review.
- A team says their module cannot move to the Go version os.Root requires. What do you do?Treat the pin as the decision it is: find out what holds it, put a date on lifting it, and in the meantime require the strictest available substitute plus a written exception with an owner. What I would not accept is an indefinite pin with no owner, because that quietly makes the weaker pattern permanent everywhere that module is imported.
- How do you keep the rule alive a year after the migration?By making the safe path the only convenient one — a storage package that never hands out a base path string — and by keeping a mechanical check in CI, such as a fuzz target over entry names plus an analyzer flagging direct os.Open calls in the packages that handle untrusted names. Documentation and review checklists are the fallback, not the mechanism.
saying these in an interview costs you the question
- Mandates it everywhere without naming the trust boundary
- Ignores the Go version floor the rule imposes
- Relies on code review alone to enforce it
- Leaves an exported helper returning the base directory path
- Argues it away on unmeasured performance grounds
- Grants exceptions verbally with no owner or date