skip to content

Why does filepath.Join("/srv/uploads", name) not guarantee a path inside /srv/uploads?

level: juniorimportance: must knowfreq 55%

answer

  1. Join is arithmetic, not a check
  2. the result is passed through Clean
  3. dot-dot gets resolved, never refused
  4. no base directory is remembered anywhere
  5. check the element before you join it

basics

~10 s

filepath.Join cleans its result, so ".." elements are resolved rather than rejected: Join("/srv/uploads", "../../etc/passwd") is "/etc/passwd". Join is path arithmetic, not a confinement check, and Clean is not one either.

solid answer

~40 s

`filepath.Join` concatenates its elements with a separator and runs `filepath.Clean` over the result. Cleaning is purely lexical: it collapses `a/b/..` into `a`, and once the `..` elements have eaten the prefix they keep going, so `filepath.Join("/srv/uploads", "../../etc/passwd")` returns `/etc/passwd` with no error. Neither `Join` nor `Clean` has any notion of a base directory it must stay under, and neither touches the filesystem, so they also know nothing about symlinks. The fix is not to inspect the string after joining but to check the untrusted element before you use it — `filepath.IsLocal` answers exactly the lexical question "can this element escape?" — and, better, to open through `os.Root`, which enforces the boundary at open time instead of in string arithmetic.

code

go · 8 lines
go
fmt.Println(filepath.Join("/srv/uploads", "../../etc/passwd"))
// prints: /etc/passwd

fmt.Println(filepath.Join("/srv/uploads", "/etc/passwd"))
// prints: /srv/uploads/etc/passwd  (a later element is never a new root)

fmt.Println(filepath.Clean("../../etc"))
// prints: ../../etc  (leading .. has nothing to cancel against)

go deeper

for a junior

Be ready to say out loud what Join returns for a ..-laden element, and that it returns only a string with no error. Recall that Clean is lexical: it resolves .., it does not refuse it.

for a middle

Explain the mechanics: Join concatenates then Cleans, Clean cancels an inner .. against the element before it, leading .. survives, and a later absolute-looking element is nested rather than honoured. Name IsLocal as the lexical check that does answer the containment question.

for a senior

Show that you would not fix this with string inspection at all. Explain why any lexical check is blind to symlinks and why the boundary belongs at open time with a directory handle, then say where in a service you would put that boundary so every path flows through it.

for a principal

Own the rule rather than the call site: decide that untrusted names never reach filepath arithmetic in your codebase, express that as one helper other teams call, and be able to justify the toolchain floor that decision imposes.

## What Join actually promises `filepath.Join(elem ...string) string` joins any number of path elements with the OS separator, skipping empty ones, and returns `filepath.Clean` of the result. That is the whole contract. There is no parameter that says "the first element is a base directory the rest must stay inside", and there is no error return in which such a violation could be reported. `filepath.Clean` is the lexical simplifier underneath it. It replaces repeated separators with one, removes `.` elements, and removes each inner `..` element **together with the non-`..` element that precedes it**. It is documented to apply those rules until no more apply, and it never consults the filesystem. ## Why ".." escapes rather than errors Cleaning resolves `..`; it does not reject it. Given `/srv/uploads/../../etc/passwd`, the first `..` cancels `uploads`, the second cancels `srv`, and what is left is `/etc/passwd`. The result is a perfectly well-formed path that happens to name something far outside the directory you had in mind, returned as an ordinary string with no signal that anything unusual happened. One detail cuts the other way and is worth knowing because candidates often guess it backwards: an *absolute-looking* later element does **not** escape. `filepath.Join("/srv/data", "/etc/passwd")` produces `/srv/data//etc/passwd` before cleaning and `/srv/data/etc/passwd` after it. Join never treats a later element as a new root. So the dangerous input is `..`, not a leading slash — though a leading slash still means the caller sent you something you did not expect, and rejecting it is right. A related asymmetry: `Clean` cannot remove *leading* `..` from a relative path, because lexically there is nothing to cancel them against. `filepath.Clean("../../etc")` is `../../etc`. So a cleaned path is not a `..`-free path. ## Why the filesystem disagrees with the string anyway Even a string that looks contained can name something else. `Clean` and `Join` are lexical; the kernel resolves each component for real, following symlinks. If `uploads/reports` is a symlink to `/`, then the lexically impeccable `/srv/uploads/reports/etc/passwd` resolves outside `/srv/uploads`. No amount of string tidying can see that, because no string function opens anything. This is the structural reason the standard library grew a *handle-based* API rather than a smarter string function. ## What to do instead Check the untrusted element, before joining: - `filepath.IsLocal(name)` reports, using lexical analysis only, whether `name` stays within the directory it is evaluated in: it is false for the empty string, for absolute paths, for anything that escapes via `..`, and on Windows for reserved device names. Its documented guarantee is the one you want: if `IsLocal(name)` is true, then `Join(base, name)` is contained within `base`. - `filepath.Localize(name)` does the same check for a slash-separated name (an archive entry, a URL path segment) and converts it to an OS path, returning an error when the name is not local. And enforce the boundary at open time. `os.OpenRoot(dir)` (Go 1.24) returns an `*os.Root` holding an open handle on `dir`; `Root.Open`, `Root.Create` and `Root.OpenFile` resolve names relative to that handle and return an error for anything that would leave it, including through a symlink. The check and the open are then the same operation instead of two operations on a string. ## The shape to avoid The tempting repair is to join first and then test the result — `strings.HasPrefix(joined, base)`. It is better than nothing and still wrong in two ways: prefix matching on strings treats `/srv/data2/x` as being inside `/srv/data` unless you are careful to compare whole path elements, and it is still lexical, so a symlink under `base` defeats it. Reach for `IsLocal` on the element or `os.Root` on the open, and treat `Join`/`Clean` as what they are: arithmetic on strings.

  • Does filepath.Clean ever strip leading ".." elements from a relative path?
    No. `Clean` removes an inner `..` along with the element before it, but leading `..` on a relative path has nothing to cancel against, so `filepath.Clean("../../etc")` is `../../etc`. A cleaned path is therefore not a `..`-free path, which is why "I cleaned it, so it is safe" is wrong.
  • What does filepath.Join do when a later element looks absolute, like Join("/srv/data", "/etc/passwd")?
    It nests it: the elements are concatenated with a separator and cleaned, giving `/srv/data/etc/passwd`. A later element is never treated as a new root. Absolute-looking input is still worth rejecting because it signals the caller sent something unexpected, but the element that actually escapes is `..`.
  • Is strings.HasPrefix on the joined result a sufficient containment check?
    No. It is still lexical, so a symlink under the base directory defeats it, and naive prefix matching treats `/srv/data2/x` as inside `/srv/data` unless you compare whole path elements. Use `filepath.IsLocal` on the untrusted element, and open through `os.Root` so the boundary is enforced by the open itself.

saying these in an interview costs you the question

  • Says filepath.Join sanitises the untrusted element
  • Believes filepath.Clean removes every .. element
  • Thinks a cleaned path is confined by definition
  • Assumes an absolute later element makes Join return it unchanged
  • Checks the joined string instead of the element
  • Expects Join to return an error on escape