FlawOpen

flawopen.com/path-traversal/Go

● CWE-22 · High
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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 .. away
Join calls Clean, so filepath.Join("/srv/uploads", "../../etc/hostname") is /etc/hostname with no .. left. http.ServeFile only rejects .. in r.URL.Path, not in the name you pass it, so a query parameter goes straight through.
strings.HasPrefix and symlinks
strings.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 whose Open, OpenFile and Create resolve names inside dir only. Names with .., absolute names and symbolic links that lead outside the root fail with an error, and root.FS() exposes the same guarantee as an fs.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 as NUL. Use it to reject bad input early; os.Root still enforces containment at open time.
archive/zip and GODEBUG=zipinsecurepath=0
Entry names such as ../../app/config.yaml are written outside the destination by code that joins them onto it (Zip Slip). With GODEBUG=zipinsecurepath=0 (and tarinsecurepath=0), zip.OpenReader returns ErrInsecurePath for such archives.

Framework mappings

  • CWE reference for the weakness described in this guide: CWE-22

Step-by-Step Attack Flow

Step 1

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.

Step 2

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.

Step 3

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.

Step 4

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

UNPATCHED FLAW
// 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
}
HARDENED SECURE PATCH
// 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

Sources