ARGEMMA BLOG

Go's filepath.Clean does not prevent path traversal

Kelby Ludwig
Kelby Ludwig Security Engineer

The problem: preventing path traversal

Let's say you are building a Go service that needs to use untrusted path strings for writing to the filesystem. You have planned ahead and you know about path traversal vulnerabilities and want these files to be written under a directory called node_modules. You dig through Go API docs and find a reasonable looking API: filepath.Clean. "Great!" you think, "I am interested in cleaning my untrusted path and making it safe" and you (or Claude Code) write something like this:

// seemsSafe takes an untrusted path `p` and turns it into
// path rooted under `root`.
// Spoiler alert: This is not actually safe! Don't actually use this.
func seemsSafe(p string) string {
	const root = "/node_modules/"
	return filepath.Join(root, filepath.Clean(p))
}

You also do some further due diligence and test seemsSafe and it continues to seem safe:

fmt.Println(seemsSafe("/../../../foo"))
=> /node_modules/foo

seemsSafe, unfortunately, has not solved your problem and is still vulnerable to path traversal. For example, the path ../../bar escapes the node_modules directory:

fmt.Println(seemsSafe("../../bar"))
=> /bar

Despite the Clean name which could be reasonably interpreted as "Clean this untrusted path and make it safe to use" it's not intended as a security control. A more verbose, but more descriptive, name might be ShortestEquivalentPath.

Seeing this issue in practice

I was digging through recent Go security advisories and found GO-2025-4138 which was an issue reported by pyozzi-toss around November 2025 in the esm.sh project. That report describes an attack where an NPM package (read: tarball) containing path strings like package/../../tmp/evil is able to escape out of the esm.sh node_modules directory when extracted using esm.sh's logic. This class of issue is often called "tar slip" or "zip slip."

The commit to fix the originally reported issue was:

- filename := path.Join(pkgDir, name)
+ filename := path.Join(pkgDir, path.Clean(name))

As we've already discussed, this doesn't quite fix the issue.

On December 10, 2025, [I reported the mistake in the fix with a test case demonstrating the issue](https://github.com/esm-dev/esm.sh/security/advisories/GHSA-2657-3c98-63jq) and [a second commit](https://github.com/esm-dev/esm.sh/commit/c62ab83c589e7b421a0e1376d2a00a4e48161093) was merged on January 15, 2026.

A better fix: os.Root

A safer alternative to rolling your own path traversal prevention is using Go's os.Root API. os.Root is designed to prevent this class of issue as well as similar issues like symlinks that reference files outside the root. It does have some platform-specific caveats but they are documented in the Godocs.

If you are using untrusted paths for filesystem operations, please use os.Root!