CWE-22: Path Traversal - Go
Overview
Path Traversal (Directory Traversal) vulnerabilities in Go applications occur when user-supplied input is used to construct file paths without proper validation, allowing attackers to access files outside the intended directory. Go's path/filepath package provides tools for safe path handling, but developers must use them correctly. The language's explicit error handling and cross-platform path utilities offer advantages, but they don't automatically prevent traversal if paths aren't validated before use.
Go's filepath.Clean() normalizes paths by resolving . and .. sequences, removing duplicate separators, and converting to OS-specific format (forward slashes on Unix, backslashes on Windows). However, this alone doesn't prevent traversal - a cleaned path like ../../../etc/passwd is still a valid traversal attack. The key is combining filepath.Clean() with boundary checks to ensure the resolved path stays within the allowed directory. Go's standard library lacks a built-in "is path within directory" function, requiring developers to implement this validation explicitly.
Symbolic links present an additional challenge: filepath.Clean() doesn't resolve symlinks, so an attacker can create a symlink in an allowed directory pointing to sensitive files elsewhere. filepath.EvalSymlinks() resolves symlinks to their targets, but requires the file to exist and is still a validation step before the later open. Strong patterns combine cleaning, canonicalization, boundary checking, type verification, and filesystem permissions that prevent attacker-controlled path swaps; Go 1.24+ root-relative file APIs are preferred where available.
Primary Defence: Prefer indirect file references or Go 1.24+ traversal-resistant root APIs such as os.OpenInRoot/os.OpenRoot where available. Otherwise, use filepath.Clean() to normalize paths, filepath.EvalSymlinks() to resolve symlinks for existing targets, and verify the canonicalized path is equal to the allowed base directory or starts with the base plus a path separator. Only access regular files, not symlinks or directories.
Common Vulnerable Patterns
Direct Path Concatenation
// VULNERABLE - String concatenation allows traversal
package main
import (
"net/http"
"os"
)
func serveFileHandler(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
// DANGEROUS: Direct concatenation without validation
filePath := "/var/www/uploads/" + filename
data, err := os.ReadFile(filePath)
if err != nil {
http.Error(w, "File not found", 404)
return
}
w.Write(data)
}
// Attack example:
// GET /file?file=../../../etc/passwd
// Reads: /var/www/uploads/../../../etc/passwd -> /etc/passwd
// Result: Returns password file
Why this is vulnerable: Simple string concatenation allows ../ sequences to traverse up the directory tree. Even with a base directory prefix, attackers can use multiple ../ to escape the intended boundary and access arbitrary files like /etc/passwd, configuration files, or application source code.
filepath.Join Without Validation
// VULNERABLE - filepath.Join alone doesn't prevent traversal
func downloadHandler(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("filename")
// INSUFFICIENT: filepath.Join doesn't validate boundaries
filePath := filepath.Join("/app/data", filename)
data, err := os.ReadFile(filePath)
if err != nil {
http.Error(w, "File not found", 404)
return
}
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
w.Write(data)
}
// Attack example:
// GET /download?filename=../../etc/shadow
// Path becomes: /app/data/../../etc/shadow -> /etc/shadow
// Result: Downloads shadow password file
Why this is vulnerable: filepath.Join() normalizes path components and uses OS-appropriate separators, but it doesn't prevent upward traversal. The joined path can still escape the base directory if it contains .. elements. After joining, the path needs validation to ensure it remains within boundaries.
filepath.Clean Without Boundary Check
// VULNERABLE - Clean normalizes but doesn't validate
func readFileHandler(w http.ResponseWriter, r *http.Request) {
userPath := r.URL.Query().Get("path")
baseDir := "/var/app/files"
// INSUFFICIENT: Clean alone doesn't check boundaries
cleanPath := filepath.Clean(userPath)
fullPath := filepath.Join(baseDir, cleanPath)
content, err := os.ReadFile(fullPath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Write(content)
}
// Attack example:
// GET /read?path=../../../../etc/hostname
// Clean doesn't prevent traversal, just normalizes
// Result: Reads /etc/hostname
Why this is vulnerable: filepath.Clean() only normalizes the path syntax - it removes redundant separators, resolves . and .. sequences, and converts to OS format. It doesn't validate that the resulting path stays within a boundary. A cleaned path like ../../../../etc/passwd is still a valid absolute path that escapes any base directory.
Symlink Attack
// VULNERABLE - Not resolving symlinks allows escapes
func serveStaticHandler(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
baseDir := "/var/www/static"
// Clean and join
cleanName := filepath.Clean(filename)
fullPath := filepath.Join(baseDir, cleanName)
// INSUFFICIENT: Check prefix but don't resolve symlinks
if !strings.HasPrefix(fullPath, baseDir) {
http.Error(w, "Invalid path", 400)
return
}
data, err := os.ReadFile(fullPath)
if err != nil {
http.Error(w, "Not found", 404)
return
}
w.Write(data)
}
// Attack: Create symlink in /var/www/static:
// ln -s /etc/passwd /var/www/static/public_file
// GET /static?file=public_file
// Path check passes: /var/www/static/public_file starts with /var/www/static
// But reads: /etc/passwd via symlink
// Result: Exposes password file
Why this is vulnerable: Checking the path prefix before symlink resolution allows attackers to create symbolic links within the allowed directory that point to sensitive files elsewhere. The path string passes validation, but os.ReadFile() follows the symlink and reads the target file outside the boundary. This bypasses the security check.
Secure Patterns
Clean, Join, and Validate Boundary (Basic)
// SECURE - Clean, join, and verify boundaries
package main
import (
"net/http"
"os"
"path/filepath"
"strings"
)
func secureFileHandler(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
// Define allowed base directory (use absolute path)
baseDir, err := filepath.Abs("/var/app/uploads")
if err != nil {
http.Error(w, "Server error", 500)
return
}
// SECURE: Clean user input
cleanName := filepath.Clean(filename)
// Reject absolute paths and paths starting with ..
if filepath.IsAbs(cleanName) || strings.HasPrefix(cleanName, "..") {
http.Error(w, "Invalid filename", 400)
return
}
// Join with base directory
fullPath := filepath.Join(baseDir, cleanName)
// CRITICAL: Verify the final path is within base directory
if !strings.HasPrefix(fullPath, baseDir+string(filepath.Separator)) &&
fullPath != baseDir {
http.Error(w, "Path traversal detected", 400)
return
}
// Verify it's a regular file (not directory or symlink)
info, err := os.Lstat(fullPath) // Lstat doesn't follow symlinks
if err != nil {
http.Error(w, "File not found", 404)
return
}
if !info.Mode().IsRegular() {
http.Error(w, "Not a regular file", 400)
return
}
// Safe to read
data, err := os.ReadFile(fullPath)
if err != nil {
http.Error(w, "Read error", 500)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(fullPath))
w.Write(data)
}
Why this works: Multiple layers of validation reduce traversal risk: filepath.Clean() normalizes the path, rejecting absolute paths and .. prefixes blocks obvious attacks, filepath.Join() combines components, and strings.HasPrefix() with filepath.Separator ensures the final path stays within baseDir without prefix bypasses like /var/app/uploads_evil. Using os.Lstat() instead of os.Stat() detects symlinks without following them, and checking IsRegular() blocks directories and special files. This pattern still performs validation before opening the file, so use root-relative APIs or OS permissions when defending against attacker-controlled symlink races.
Resolve Symlinks with EvalSymlinks (Recommended)
// SECURE - Resolve symlinks and validate canonical paths
package main
import (
"net/http"
"os"
"path/filepath"
"strings"
)
func secureDownloadHandler(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
// Get absolute base directory
baseDir := "/var/app/downloads"
baseAbs, err := filepath.Abs(baseDir)
if err != nil {
http.Error(w, "Server error", 500)
return
}
// Clean user input
cleanName := filepath.Clean(filename)
// Reject absolute paths and .. prefixes
if filepath.IsAbs(cleanName) || strings.HasPrefix(cleanName, "..") {
http.Error(w, "Invalid path", 400)
return
}
// Join with base
candidatePath := filepath.Join(baseAbs, cleanName)
// CRITICAL: Resolve symlinks to get canonical path
realPath, err := filepath.EvalSymlinks(candidatePath)
if err != nil {
// File doesn't exist or symlink is broken
http.Error(w, "File not found", 404)
return
}
// Also resolve symlinks in base directory
realBase, err := filepath.EvalSymlinks(baseAbs)
if err != nil {
http.Error(w, "Server error", 500)
return
}
// Verify canonical path is within canonical base
if !strings.HasPrefix(realPath, realBase+string(filepath.Separator)) &&
realPath != realBase {
http.Error(w, "Access denied", 403)
return
}
// Verify it's a regular file
info, err := os.Stat(realPath) // Safe to use Stat now
if err != nil {
http.Error(w, "File not found", 404)
return
}
if info.IsDir() {
http.Error(w, "Cannot download directories", 400)
return
}
// Read and serve file
data, err := os.ReadFile(realPath)
if err != nil {
http.Error(w, "Read error", 500)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(filename))
w.Write(data)
}
Why this works: filepath.EvalSymlinks() resolves symbolic links in the path to their final targets, returning the canonical absolute path. This catches symlink-based escapes at validation time because the boundary check compares actual filesystem locations, not only path strings. Calling EvalSymlinks() on both the candidate path and base directory ensures both are canonical before comparison. This approach requires files to exist and can still be vulnerable to time-of-check/time-of-use races if an attacker can modify the directory between validation and open; prefer Go's root-relative file APIs or an attacker-unwritable base directory for stronger protection.
io/fs Path Validation (Go 1.16+)
// SECURE - Validate fs.FS paths before opening through os.DirFS
package main
import (
"io"
"io/fs"
"net/http"
"os"
"path"
)
func serveFSHandler(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
// Create a filesystem rooted at base directory.
// os.DirFS is convenient, but it is not a security sandbox.
baseDir := "/var/app/public"
fsys := os.DirFS(baseDir)
// SECURE: Clean path (use path.Clean for forward-slash paths)
cleanName := path.Clean(filename)
// Reject absolute paths, ".", empty input, and any "." or ".." elements
if filename == "" || filename != cleanName || cleanName == "." || path.IsAbs(cleanName) ||
!fs.ValidPath(cleanName) {
http.Error(w, "Invalid path", 400)
return
}
// Open file through fs abstraction after validation
file, err := fsys.Open(cleanName)
if err != nil {
http.Error(w, "File not found", 404)
return
}
defer file.Close()
// Get file info
info, err := file.Stat()
if err != nil {
http.Error(w, "Stat error", 500)
return
}
if info.IsDir() {
http.Error(w, "Cannot serve directories", 400)
return
}
// Serve file
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename="+path.Base(cleanName))
io.Copy(w, file)
}
Why this works: fs.ValidPath() enforces the path syntax expected by fs.FS: slash-separated, relative paths with no . or .. elements. Comparing the raw input to path.Clean() rejects inputs that needed cleanup before they reach Open(). Using path.Clean() (not filepath.Clean()) is important because fs.FS expects forward-slash separated paths regardless of OS.
Important limitation: os.DirFS() is not a security boundary. It opens paths relative to a directory, but it does not behave like chroot and does not prevent escapes through symlinks inside a writable or attacker-controlled directory. Use this pattern only for directories whose contents and symlinks are trusted. For attacker-writable directories, prefer Go 1.24+ root-relative APIs such as os.OpenInRoot/os.OpenRoot, or combine canonical boundary checks with OS permissions that prevent attackers from swapping paths after validation.
Allowlist Approach (Most Restrictive)
// SECURE - Allowlist of permitted files (highest security)
package main
import (
"net/http"
"os"
"path/filepath"
)
// Define allowed files with indirect mapping
var allowedFiles = map[string]string{
"doc1": "/var/app/docs/manual.pdf",
"doc2": "/var/app/docs/guide.pdf",
"report1": "/var/app/reports/2024-q1.pdf",
"report2": "/var/app/reports/2024-q2.pdf",
}
func allowlistHandler(w http.ResponseWriter, r *http.Request) {
fileID := r.URL.Query().Get("id")
// SECURE: Look up file path from allowlist
filePath, exists := allowedFiles[fileID]
if !exists {
http.Error(w, "Invalid file ID", 400)
return
}
// Verify file exists and is regular
info, err := os.Stat(filePath)
if err != nil {
http.Error(w, "File not found", 404)
return
}
if !info.Mode().IsRegular() {
http.Error(w, "Invalid file type", 400)
return
}
// Read and serve
data, err := os.ReadFile(filePath)
if err != nil {
http.Error(w, "Read error", 500)
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", "inline; filename="+filepath.Base(filePath))
w.Write(data)
}
Why this works: Allowlist mapping completely eliminates path traversal by decoupling user input from filesystem paths. Users provide IDs that map to pre-defined, hardcoded paths - there's no way to inject ../ or manipulate paths because the actual path is never derived from user input. This is the most secure approach when you have a fixed set of files to serve. Map lookups are O(1) and type-safe, and even if attackers discover valid IDs, they can only access explicitly permitted files.
Framework-Specific Guidance
Gin Static File Serving
// SECURE - Gin static file serving with validation
package main
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// SECURE: Use Gin's built-in static file serving
// Static serving handles ordinary traversal cleanup for trusted static trees.
r.Static("/public", "./public")
r.StaticFS("/files", http.Dir("./files"))
// Custom handler with validation
r.GET("/download/:filename", secureDownloadHandler)
r.Run(":8080")
}
func secureDownloadHandler(c *gin.Context) {
filename := c.Param("filename")
baseDir := "./downloads"
// Get absolute base
absBase, err := filepath.Abs(baseDir)
if err != nil {
c.JSON(500, gin.H{"error": "Server error"})
return
}
// Clean and validate
cleanName := filepath.Clean(filename)
if filepath.IsAbs(cleanName) || strings.Contains(cleanName, "..") {
c.JSON(400, gin.H{"error": "Invalid filename"})
return
}
fullPath := filepath.Join(absBase, cleanName)
// Resolve symlinks
realPath, err := filepath.EvalSymlinks(fullPath)
if err != nil {
c.JSON(404, gin.H{"error": "File not found"})
return
}
// Verify within base
realBase, _ := filepath.EvalSymlinks(absBase)
if !strings.HasPrefix(realPath, realBase+string(filepath.Separator)) {
c.JSON(403, gin.H{"error": "Access denied"})
return
}
// Serve file
c.File(realPath)
}
Why this works: Gin's Static() and StaticFS() methods use Go's static file-serving path handling for ordinary static asset trees. They are appropriate for trusted, server-controlled directories, but should not be treated as a sandbox for attacker-writable trees or symlink-heavy content. For custom handlers, combining path cleaning, symlink resolution, boundary validation, and filesystem permissions reduces traversal risk. Gin's c.File() method sets appropriate headers and efficiently streams the file after the handler has selected a validated path.
Chi File Server
// SECURE - Chi with custom file server middleware
package main
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
// SECURE: Serve static files with Chi's FileServer
workDir, _ := os.Getwd()
filesDir := http.Dir(filepath.Join(workDir, "public"))
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(filesDir)))
// Custom download endpoint with validation
r.Get("/api/download", downloadEndpoint)
http.ListenAndServe(":8080", r)
}
func downloadEndpoint(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
baseDir := "/var/app/downloads"
// Validate filename is just a filename, no path components
if filepath.Base(filename) != filename {
http.Error(w, "Invalid filename", 400)
return
}
// Reject dotFiles and hidden files
if strings.HasPrefix(filename, ".") {
http.Error(w, "Access denied", 403)
return
}
fullPath := filepath.Join(baseDir, filename)
// Verify it's a regular file
info, err := os.Lstat(fullPath)
if err != nil {
http.Error(w, "File not found", 404)
return
}
if !info.Mode().IsRegular() {
http.Error(w, "Not a file", 400)
return
}
http.ServeFile(w, r, fullPath)
}
Why this works: Chi's FileServer wraps http.FileServer, which includes path validation and traversal protection. For custom endpoints, checking filepath.Base(filename) == filename ensures the input is just a filename with no directory components, preventing any traversal. Rejecting dot-prefixed files blocks access to hidden files and . or .. directories. Using http.ServeFile() handles range requests and proper headers automatically.
Echo with Validated DirFS Paths
// SECURE - Echo using validated fs.FS paths
package main
import (
"io"
"io/fs"
"net/http"
"os"
"path"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
// Static file serving with Echo
e.Static("/static", "public")
// Custom endpoint with validated io/fs paths
e.GET("/files/*", filesHandler)
e.Start(":8080")
}
func filesHandler(c echo.Context) error {
// Get the wildcard path
requestPath := c.Param("*")
// Create FS rooted at files directory.
// os.DirFS is not a sandbox; keep this directory trusted.
fsys := os.DirFS("./files")
// Clean path (use path.Clean for fs.FS)
cleanPath := path.Clean(requestPath)
// Reject absolute paths, ".", empty input, and any "." or ".." elements
if requestPath == "" || requestPath != cleanPath || cleanPath == "." || path.IsAbs(cleanPath) ||
!fs.ValidPath(cleanPath) {
return c.String(400, "Invalid path")
}
// Open through fs abstraction
file, err := fsys.Open(cleanPath)
if err != nil {
return c.String(404, "File not found")
}
defer file.Close()
// Check it's a regular file
info, err := file.Stat()
if err != nil {
return c.String(500, "Stat error")
}
if info.IsDir() {
return c.String(400, "Cannot serve directories")
}
// Serve file
c.Response().Header().Set("Content-Disposition", "attachment; filename="+path.Base(cleanPath))
return c.Stream(200, "application/octet-stream", file)
}
Why this works: Echo's Static() method provides safe file serving for ordinary static assets. For custom handlers, path.Clean() plus fs.ValidPath() ensures only clean, relative fs.FS paths reach Open(), and using path.Clean() (not filepath) is critical because fs.FS expects slash-separated paths. os.DirFS() should still be treated as a convenience wrapper rather than a sandbox; do not rely on it to contain symlink escapes in attacker-writable directories.
Input Validation Patterns
// SECURE - Additional validation patterns
import (
"path/filepath"
"regexp"
"strings"
)
// Validate filename contains only safe characters
var safeFilenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]+$`)
func validateFilename(filename string) bool {
// Only basename, no path
if filepath.Base(filename) != filename {
return false
}
// No dot-dot
if strings.Contains(filename, "..") {
return false
}
// Alphanumeric and safe chars only
if !safeFilenameRegex.MatchString(filename) {
return false
}
// Reject hidden files
if strings.HasPrefix(filename, ".") {
return false
}
return true
}
// Validate file extension
func validateExtension(filename string, allowed []string) bool {
ext := strings.ToLower(filepath.Ext(filename))
for _, allowedExt := range allowed {
if ext == allowedExt {
return true
}
}
return false
}
// Example usage
func secureHandler(w http.ResponseWriter, r *http.Request) {
filename := r.URL.Query().Get("file")
// Validate filename characters
if !validateFilename(filename) {
http.Error(w, "Invalid filename", 400)
return
}
// Validate extension
allowedExts := []string{".pdf", ".txt", ".jpg", ".png"}
if !validateExtension(filename, allowedExts) {
http.Error(w, "File type not allowed", 400)
return
}
// Proceed with safe file access...
}