Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix path traversal in Go and net/http

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Open the base directory once with root, err := os.OpenRoot(base) and serve requested files through root.Open(name). os.Root (Go 1.24+) refuses names that leave the directory, whether by ../, an absolute path or a symlink, at the moment it opens the file. filepath.Join is not a check, and a strings.HasPrefix test on its result accepts files-secret and follows symlinks.

Stack
  • Language Go
  • Runtime Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
  • Framework Go standard library 1.27.2 (net/http, os.Root)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 21 KB
On this page

Steps

  1. Find every os.Open, os.ReadFile, os.OpenFile, http.ServeFile and filepath.Join whose name includes request data: r.URL.Query(), r.PathValue(), form values and multipart file names.
  2. Open the base directory once, when the app starts, with os.OpenRoot, and open each requested file with root.Open; for a one-off lookup os.OpenInRoot(base, name) does both.
  3. Check that what opened is a regular file (f.Stat() and Mode().IsRegular()) and send it with http.ServeContent; answer the same 404 for a refused name and a missing file so the response does not reveal what exists outside the folder.
  4. Reject a missing, repeated or empty value and a NUL byte with 400 before touching the file system.
  5. To serve a whole folder, use http.FileServerFS(root.FS()) rather than http.FileServer(http.Dir(base)) or os.DirFS, which follow symlinks out of the folder, and decide whether directory listings are acceptable: all three list a folder that has no index.html.
  6. Add regression tests with httptest.NewServer that aim this card's payloads at real files outside the base, including a symlink created in t.TempDir(), next to functional tests for nested paths and names with consecutive dots.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.goGo
func (v Vulnerable) Open(name string) (*os.File, error) {
	// filepath.Join cleans the joined path, which resolves "../" lexically: the result is
	// simply a path somewhere else.
	return os.Open(filepath.Join(v.Base, name))
}

With IMPL=vulnerable, 4 of 12 tests fail (4 exploit tests).

The fix: open through os.Root

Fixedimpl/fixed.goGo
// NewFixed opens the base directory once, when the app starts.
func NewFixed(base string) (*Fixed, error) {
	root, err := os.OpenRoot(base)
	if err != nil {
		return nil, err
	}
	return &Fixed{root: root}, nil
}

func (f *Fixed) Open(name string) (*os.File, error) {
	// Root.Open fails for any name that leaves the root: "../secret.txt", an absolute path, or
	// a path through a symlink that points outside. Names such as "a/../b" and links that stay
	// inside work. The file it opens is the one it checked, so there is no window to swap in a link.
	return f.root.Open(name)
}

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.goGo
func (w Wrong) Open(name string) (*os.File, error) {
	target := filepath.Join(w.Base, name)
	// Join has cleaned the path, so anything that still starts with the base must be inside it.
	if !strings.HasPrefix(target, w.Base) {
		return nil, os.ErrNotExist
	}
	return os.Open(target)
}

With IMPL=wrong, 2 of 12 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testapp_test.goGo
// Exploit: each payload aims at a real file outside files/, so a leak would come back as a 200.

func TestExploitDotDotSlashCannotClimbOutOfTheBaseDirectory(t *testing.T) {
	f := newServer(t)
	expectNotServed(t, f.get(t, "../secret.txt"))
}

func TestExploitPercentEncodedDotDotSlashIsDecodedByNetHTTPAndStillRefused(t *testing.T) {
	f := newServer(t)
	expectNotServed(t, f.getRaw(t, "path=%2e%2e%2fsecret.txt"))
}

func TestExploitAbsolutePathCannotReplaceTheBaseDirectory(t *testing.T) {
	f := newServer(t)
	expectNotServed(t, f.get(t, filepath.Join(f.tree, "secret.txt")))
}

func TestExploitBackslashesCannotClimbOutASeparatorOnWindows(t *testing.T) {
	f := newServer(t)
	expectNotServed(t, f.get(t, `..\secret.txt`))
}

func TestExploitSiblingFolderThatSharesThePrefixIsOutside(t *testing.T) {
	f := newServer(t)
	// files-secret starts with "files" but is not inside files/.
	expectNotServed(t, f.get(t, "../files-secret/api-keys.txt"))
}

func TestExploitSymlinkInsideTheBaseCannotLeadOutsideIt(t *testing.T) {
	f := newServer(t)
	if err := os.Symlink(filepath.Join(f.tree, "files-secret"), filepath.Join(f.base, "shared")); err != nil {
		t.Fatal(err)
	}
	expectNotServed(t, f.get(t, "shared/api-keys.txt"))
}

func TestExploitFourDotsDoNotTurnBackIntoDotDotSlash(t *testing.T) {
	f := newServer(t)
	// A single-pass filter that deletes "../" turns "....//" into "../".
	expectNotServed(t, f.get(t, "....//secret.txt"))
}

Pitfalls

  • filepath.Join is not a check. It cleans the joined path, so ../ simply moves the result outside the base. Unlike Python's os.path.join, it keeps an absolute second element under the base, which is why the absolute-path test passes even for the vulnerable code here.
  • strings.HasPrefix(target, base) accepts /srv/files-secret. Compare with base plus filepath.Separator, or use filepath.Rel or filepath.IsLocal; but every check on the path string still follows symlinks, so prefer os.Root.
  • http.Dir and os.DirFS follow symlinks that point out of the tree, as their documentation warns; a probe served files-secret/api-keys.txt through a link with http.FileServer(http.Dir(base)) and with http.FileServerFS(os.DirFS(base)). root.FS() refused it.
  • ServeMux redirects a request for /files/../secret.txt to /secret.txt (307 since Go 1.26), but escaped forms are kept: with a GET /raw/{path...} pattern, /raw/..%2fsecret.txt and /raw/%2e%2e/secret.txt reached the handler with r.PathValue("path") returning ../secret.txt. The same holds for a query parameter, which r.URL.Query() decodes.
  • http.ServeFile and http.ServeFileFS refuse a request whose r.URL.Path contains .., but they do not check the name you pass them; that is your job.
  • os.Root follows symlinks that stay inside the root and accepts names such as a/../b; it refuses absolute symlinks. It does not stop traversal of bind mounts, /proc files or device files, so keep the served tree free of them.
  • On Windows, Root.Mkdir and Root.MkdirAll could follow junctions out of the root before Go 1.26.9 and 1.27.2 (GO-2026-6604); run a patched Go if you create directories through a Root.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs path-traversal/go-nethttp does this for you, in Docker if you do not have Go).
  2. Run gosec -include=G304 ./...; expect findings in impl/vulnerable.go and impl/wrong.go only. No scanner here distinguished the prefix check from a real one, so keep the regression tests.
  3. Against your own endpoint, request ?path=..%2Fsecret.txt, an absolute path and a file reached through a symlink that points outside the base; each must answer 404 without the file's content.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix path traversal in Go and net/http
ToolRuleWhat it found here
Semgrepgo.lang.security.filepath-clean-misuse.filepath-clean-misuseNo findings. It is a taint rule whose sink is an explicit filepath.Clean or path.Clean call on request data; the card's code calls filepath.Join, which cleans internally, and the request value reaches impl/ only through an interface. The p/golang and p/gosec packs reported nothing on the card either.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config r/<id> .
CodeQLgo/path-injectionOne result: the os.Open call in impl/vulnerable.go, traced from r.URL in app.go. It missed impl/wrong.go, because TaintedPathCustomizations.qll treats any strings.HasPrefix(path, ...) as a sanitizer guard (its own comment calls this overapproximate, to avoid false positives). Nothing in impl/fixed.go: the os model has no os.Root methods as path-injection sinks.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12; database built with go build ./... in the golang:1.27.2 image (query source read at github/codeql b5b8165)
gosecG304File path provided as taint inputFlagged the os.Open call in impl/vulnerable.go and the one in impl/wrong.go (MEDIUM severity, HIGH confidence): it fires on os.Open with a non-constant name, so it cannot tell the prefix check apart. Nothing in impl/fixed.go, which opens through root.Open.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec ./...
gosecG703Path traversal via taint analysisNo findings: the taint analysis does not follow the request value through the Files interface into impl/. On a probe file with the handler and os.Open(filepath.Join(base, r.URL.Query().Get("path"))) in one function it reported G703 (HIGH severity, HIGH confidence).Checked: gosec 2.29.0; gosec -include=G304,G703 ./...

Libraries and versions

Libraries and versions
ComponentVersion
LanguageGo
RuntimeGo 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
FrameworkGo standard library 1.27.2 (net/http, os.Root)
gotest.tools/gotestsum (JUnit report, a go.mod tool)1.13.0
golang.org/x/text (raised for gotestsum)0.42.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the path-traversal/go-nethttpfolder.

Shell
go mod download
go build ./... tool
Shell
# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go mod download && go build ./... tool'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=fixed \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=vulnerable \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=wrong \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs path-traversal/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 21 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop 4.73.0 (Engine 29.4.3), linux/arm64
Toolchain
  • go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
  • gotestsum 1.13.0
  • semgrep 1.180.0
  • gosec 2.29.0
  • codeql CLI 2.27.1, codeql/go-queries 1.6.12
Matrix
fixed: 12 of 12 tests pass; vulnerable: 4 of 12 tests fail (4 exploit tests); wrong: 2 of 12 tests fail (2 exploit tests)
Test results
fixed 12/12 pass; vulnerable 4/12 fail (4 exploit tests: ../, %2e%2e%2f, the files-secret sibling, the symlink); wrong 2/12 fail (2 exploit tests: the files-secret sibling, the symlink), with --network=none after install; the same on Go 1.26.9. As in the Node.js cards, filepath.Join keeps an absolute name under the base, so the absolute-path test passes in every mode; the ..\ test passes everywhere on Linux, where a backslash is an ordinary file-name character, and guards a Windows deployment.
Dependency freshness
The card needs only the standard library. gotestsum 1.13.0 was published on 2025-09-11; it asks for golang.org/x/text 0.17.0, which GO-2026-5970 affects in a package gotestsum builds (x/text/unicode/norm), so go.mod raises it to 0.42.0 (2026-09-08). Go 1.27.2 (2026-10-08) is under seven days old but is a security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. Go blog: Traversal-resistant file APIs (os.Root)
  2. Go standard library: os.Root
  3. Go standard library: os.OpenInRoot
  4. Go standard library: os.DirFS (follows symlinks; use Root.FS)
  5. Go standard library: net/http Dir (follows symlinks out of the tree)
  6. Go standard library: net/http ServeMux request sanitizing
  7. Go standard library: net/http Request.PathValue (the value is unescaped)
  8. Go standard library: path/filepath IsLocal
  9. Go vulnerability database: GO-2026-6604 (Root.Mkdir and junctions on Windows)
  10. CodeQL Go: TaintedPathCustomizations.qll (strings.HasPrefix as a sanitizer guard)
  11. gosec 2.29.0 rules
  12. OWASP: Path Traversal

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.