Path traversal remains one of the most persistent bugs in file-handling code, and Go's own standard library has taught that lesson the hard way at least twice at the language level. The mistake usually looks harmless: a handler takes a filename from a URL parameter, an archive entry, or a multipart upload, and does filepath.Join(baseDir, userInput) before opening the file. Developers assume Join sanitizes the result because it calls filepath.Clean internally. It does not check whether the final path stays inside baseDir — it only normalizes separators and collapses .. segments lexically. Feed it ../../../../etc/passwd and Join will happily walk the reference straight out of the intended directory. This single misunderstanding has produced real CVEs against the Go toolchain itself (CVE-2022-41722, CVE-2023-45283) and against dozens of Go services and libraries built on top of it, from archive extractors to WebDAV servers. Here's what's actually happening under the hood, and how to close the gap.
Why doesn't filepath.Join stop path traversal by itself?
Because Join is a string-normalization function, not an access-control function — it has no concept of a trust boundary. Under the hood, filepath.Join(elem...) concatenates its arguments with the OS separator and then runs the result through filepath.Clean, which lexically resolves . and .. segments without touching the filesystem or checking against any root directory. If you call filepath.Join("/var/www/uploads", "../../etc/passwd"), Go doesn't know /var/www/uploads is supposed to be a sandbox — it just sees a string and returns /etc/passwd. The Go documentation is explicit that Clean "does not consider the meaning of the path"; it's a lexical operation, and lexical operations can't enforce semantic boundaries like "stay inside this directory." This is precisely the gap tracked in the Go issue tracker as "path/filepath: Join is susceptible to SlipZip" (golang/go#40373) — the maintainers acknowledge Join was never designed as a security boundary, and never will be.
What real-world Go CVEs came from this exact mistake?
At least three distinct incident classes trace back to unchecked joins. The first and most famous is Zip Slip, publicly disclosed by Snyk's security team on June 5, 2018: archive-extraction code across many languages — including popular Go libraries such as mholt/archiver — built output paths with filepath.Join(destDir, header.Name) using the filename stored inside a zip, tar, or jar entry. Because archive formats don't restrict what an entry name can contain, an attacker-crafted archive with an entry named ../../../root/.ssh/authorized_keys would extract straight out of the destination directory, enabling arbitrary file write and, in many deployments, remote code execution. Thousands of open-source projects across the Java, Go, JavaScript, Ruby, and .NET ecosystems were affected.
The second class lives inside the Go standard library itself. CVE-2022-41722, patched in the Go 1.19.6 and 1.20.1 security releases on February 14, 2023, was a path traversal in filepath.Clean on Windows, where certain inputs could be normalized into a path that escaped the intended root. Less than a year later, CVE-2023-45283 showed the same family of bug resurfacing in a different corner of Windows path handling: filepath.Clean and filepath.Join could turn a rooted path like \a\..\??\b into \??\b, a Windows "Root Local Device" path that resolves outside normal filesystem semantics entirely, giving attackers a way to reach arbitrary locations via crafted "dot dot" sequences. Both fixes shipped as out-of-band Go security releases, which is itself a signal of how seriously the Go team treats regressions in this exact area.
How do you actually validate a joined path in Go?
You validate by checking the relationship between the result and the base directory, not by trusting Join's output. The reliable pattern is: clean and join as usual, then confirm the resulting absolute path is still lexically inside the base using filepath.Rel, rejecting anything where the relative path starts with .. or equals ..:
func safeJoin(base, userInput string) (string, error) {
target := filepath.Join(base, userInput)
rel, err := filepath.Rel(base, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid path: %q escapes base directory", userInput)
}
return target, nil
}
Since Go 1.20, the standard library also ships filepath.IsLocal(path), which reports whether a path is "local" — meaning it doesn't reference anything outside the rooted subtree it's evaluated in, doesn't begin with a volume/drive component, and isn't empty. Running the untrusted input (before joining) through IsLocal is a useful first-pass filter, but it should be paired with the Rel-based check above, because IsLocal reasons about the input string in isolation, not the final joined path on your specific filesystem. Neither check substitutes for resolving symlinks with filepath.EvalSymlinks when the base directory itself might contain symlinked entries — otherwise an attacker who can plant a symlink inside base can still walk out through it even with a textbook-correct Rel check.
Does Go 1.24's os.Root fix this for good?
It fixes the largest remaining gap — symlink-based escapes — but it isn't an unconditional guarantee. Go 1.24, released in February 2025, introduced os.OpenRoot and the os.Root type specifically to give developers a traversal-resistant file API instead of another string-validation pattern to get wrong. Once you open a root with r, err := os.OpenRoot("/var/www/uploads"), every subsequent operation — r.Open, r.Create, r.Mkdir, r.Stat, r.OpenFile, r.Remove — takes a filename relative to that root and structurally rejects any resolution, via .. components or symlinks, that would escape it. This closes the classic Zip Slip and CVE-2023-45283-style failure modes at the API level rather than relying on every call site remembering to validate correctly. The Go team is explicit about the boundary of the guarantee, though: os.Root defends against filesystem constructs an ordinary unprivileged user can create, such as symlinks, but it does not defend against mount points, which require elevated privileges to create. For services running with reduced privileges — which should be all of them — that's a meaningful, practical guarantee; it is not, however, a reason to skip validating untrusted input further upstream.
What does a secure file-serving pattern look like end to end?
It combines three things: a fixed base directory, input rejection before any filesystem call, and a traversal-resistant open. Concretely: reject any user-supplied filename containing a null byte, an absolute path prefix, or backslash/forward-slash-encoded traversal sequences before it ever reaches filepath.Join; run the joined result through the Rel-based containment check shown above; and, on Go 1.24+, perform the actual open through os.Root rather than a bare os.Open. On archive extraction specifically — the exact pattern that produced Zip Slip — apply the same containment check to every single entry name before writing it to disk, since a single unchecked entry in an otherwise-valid archive is enough to compromise the whole extraction. None of these steps is exotic; the vulnerability persists in production Go codebases mainly because filepath.Join's name implies more safety than it delivers, and code review rarely catches the absence of a containment check on a diff that "just joins two paths."
How Safeguard Helps
Safeguard is built to catch exactly this class of gap before it reaches production. Our static analysis rules for Go flag filepath.Join and os.Open/os.Create call sites where a tainted source — an HTTP parameter, an archive entry name, a form field — flows into a file path without a corresponding containment check or os.Root/IsLocal guard, surfacing CWE-22 findings directly in pull requests instead of leaving them for a pen test. Because path traversal in Go so often arrives through a vulnerable dependency rather than first-party code — as Zip Slip demonstrated at scale — Safeguard's software composition analysis continuously matches your dependency tree against known-vulnerable versions of archive, upload, and static-file-serving libraries, and flags projects still pinned to a Go toolchain predating the CVE-2022-41722 and CVE-2023-45283 fixes. Findings are prioritized by real reachability — whether the vulnerable join or open call sits on a path actually fed by external input — so teams fix the joins that matter instead of triaging every string concatenation in the codebase. That reachability-first view plugs into the same CI gate your team already runs, so a missing containment check blocks the merge with the same rigor as any other supply-chain control, keeping path traversal out of the codebase instead of out of the changelog.