flawopen.com/path-traversal/Go
Path Traversal in Go (os.Root, path/filepath)
filepath.Join() cleans ../../etc/hostname into /etc/hostname before http.ServeFile ever sees it, a strings.HasPrefix check accepts /srv/uploads-old and cannot see symlinks, and zip entry names can point outside the extraction folder. How each mistake lets a request read or write files outside your Go service's upload folder, and how os.Root and filepath.IsLocal stop it.
Imagine a delivery entrance for one apartment building. A request gives the courier directions, and ../ means “leave this hallway.” filepath.Join cleans those directions, but cleaning does not prove the destination stayed in the building; strings.HasPrefix can mistake a neighboring address like /srv/uploads-old for /srv/uploads, and a symlink can redirect a path outside. filepath.IsLocal rejects obvious absolute or escaping names, while os.Root is the locked entrance that enforces containment when opening files. ZIP imports need the same boundary: check each entry with filepath.IsLocal and create it through the destination root.
Core Concepts & Subsystem Terms
filepath.Join() cleans .. awayJoincallsClean, sofilepath.Join("/srv/uploads", "../../etc/hostname")is/etc/hostnamewith no..left.http.ServeFileonly rejects..inr.URL.Path, not in the name you pass it, so a query parameter goes straight through.strings.HasPrefix and symlinksstrings.HasPrefix(p, "/srv/uploads")also accepts/srv/uploads-old/.... Even a correct lexical check cannot see a symbolic link inside the folder that points to/etc; the kernel follows it when the file is opened.os.Root (Go 1.24)os.OpenRoot(dir)returns a handle whoseOpen,OpenFileandCreateresolve names insidedironly. Names with.., absolute names and symbolic links that lead outside the root fail with an error, androot.FS()exposes the same guarantee as anfs.FS.filepath.IsLocal (Go 1.20)- A lexical check that rejects empty names, absolute paths, names that climb out with
.., and reserved Windows names such asNUL. Use it to reject bad input early;os.Rootstill enforces containment at open time. archive/zip and GODEBUG=zipinsecurepath=0- Entry names such as
../../app/config.yamlare written outside the destination by code that joins them onto it (Zip Slip). WithGODEBUG=zipinsecurepath=0(andtarinsecurepath=0),zip.OpenReaderreturnsErrInsecurePathfor such archives.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-22
Step-by-Step Attack Flow
Request value reaches a file API
A handler passes r.URL.Query().Get("file") or the entry names of an uploaded ZIP to http.ServeFile, os.Open or os.Create.
The value leaves the intended folder
It contains ../ segments, is absolute, names a sibling folder with the same prefix, or reaches a symlink that points elsewhere. The benign test value ../../etc/hostname illustrates the read case.
The kernel opens the resolved path
filepath.Join has already removed the .. segments, so every later check sees a clean path and the process opens the file it names.
The attacker reads or overwrites files
Reads expose configuration, keys and source; archive writes can replace templates or binaries that the service loads later.
Source Code: Flaw vs. Secure Implementation
// files.go: each handler lets the request pick a path outside uploadDir
package files
import (
"archive/zip"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const uploadDir = "/srv/uploads"
// Join cleans ../ away, so ServeFile's .. check never fires
func Download(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filepath.Join(uploadDir, r.URL.Query().Get("file")))
}
// HasPrefix accepts /srv/uploads-old and cannot see symlinks
func Preview(w http.ResponseWriter, r *http.Request) {
p := filepath.Join(uploadDir, r.URL.Query().Get("file"))
if !strings.HasPrefix(p, uploadDir) {
http.NotFound(w, r)
return
}
f, err := os.Open(p)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
io.Copy(w, io.LimitReader(f, 2000))
}
// an entry named ../../app/config.yaml is written outside destDir
func ImportArchive(zipPath, destDir string) error {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer zr.Close()
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
target := filepath.Join(destDir, f.Name)
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return err
}
if err := copyEntry(target, f); err != nil {
return err
}
}
return nil
}
func copyEntry(target string, f *zip.File) error {
src, err := f.Open()
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(target)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
return err
}
// files.go: every open goes through an os.Root
package files
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
)
// os.Root refuses .., absolute names and symlinks that leave the directory
type Store struct {
root *os.Root
}
func Open(dir string) (*Store, error) {
root, err := os.OpenRoot(dir)
if err != nil {
return nil, err
}
return &Store{root: root}, nil
}
// IsLocal rejects bad names early; the root enforces containment
func (s *Store) Download(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("file")
if !filepath.IsLocal(name) {
http.NotFound(w, r)
return
}
f, err := s.root.Open(name)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}
// no prefix check needed: the root cannot open files outside itself
func (s *Store) Preview(w http.ResponseWriter, r *http.Request) {
f, err := s.root.Open(r.URL.Query().Get("file"))
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
io.Copy(w, io.LimitReader(f, 2000))
}
// IsLocal on each entry, then create inside the root with O_EXCL
func ImportArchive(zipPath, destDir string) error {
dest, err := os.OpenRoot(destDir)
if err != nil {
return err
}
defer dest.Close()
zr, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer zr.Close()
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
if !filepath.IsLocal(f.Name) {
return fmt.Errorf("unsafe entry name %q", f.Name)
}
if err := os.MkdirAll(filepath.Join(destDir, filepath.Dir(f.Name)), 0o750); err != nil {
return err
}
if err := copyEntry(dest, f); err != nil {
return err
}
}
return nil
}
func copyEntry(dest *os.Root, f *zip.File) error {
src, err := f.Open()
if err != nil {
return err
}
defer src.Close()
dst, err := dest.OpenFile(f.Name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
return err
}
Engineering & System Hardening Checklist
- ✓Enable gosec G304 (file path from tainted input) and G305 (file traversal when extracting zip or tar) in golangci-lint and fail the build on them.
- ✓Open every user-named file through an
os.Root(Go 1.24+) or serve withhttp.FileServerFS(root.FS()), instead ofos.Openon a joined path. - ✓Reject names that fail
filepath.IsLocalbefore using them, and never rely onstrings.HasPrefixfor containment. - ✓Check each archive entry with
filepath.IsLocal, create files through the root withO_EXCL, and setGODEBUG=zipinsecurepath=0,tarinsecurepath=0. - ✓Store uploads under generated IDs and give the service write access only to its upload directory.