Skip to content

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. The safest pattern combines cleaning, canonicalization, boundary checking, and type verification (regular file vs directory/symlink) to prevent all traversal variants including symbolic link attacks.

Primary Defence: Use filepath.Clean() to normalize paths, filepath.EvalSymlinks() to resolve symlinks, and verify the canonicalized path starts with the allowed base directory using strings.HasPrefix() or component-by-component comparison. 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.

// 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 prevent traversal: filepath.Clean() normalizes the path, rejecting absolute paths and .. prefixes blocks obvious attacks, filepath.Join() safely combines components, and strings.HasPrefix() ensures the final path stays within baseDir. Adding filepath.Separator to the prefix check prevents bypasses like /var/app/uploads_evil matching /var/app/uploads. Using os.Lstat() instead of os.Stat() detects symlinks without following them, and checking IsRegular() blocks directories and special files. This multi-layer approach addresses all common traversal vectors.

// 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 all symbolic links in the path to their final targets, returning the canonical absolute path. This prevents symlink-based escapes because the boundary check compares actual filesystem locations, not path strings. Calling EvalSymlinks() on both the candidate path and base directory ensures both are canonical before comparison. If a symlink points outside the base directory, the canonical path won't match the prefix, triggering rejection. This approach requires files to exist but provides the strongest protection against symbolic link attacks.

io/fs Abstraction (Go 1.16+)

// SECURE - Use io/fs abstraction to limit filesystem access
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 filesystem rooted at base directory
    baseDir := "/var/app/public"
    fsys := os.DirFS(baseDir)

    // SECURE: Clean path (use path.Clean for forward-slash paths)
    cleanName := path.Clean(filename)

    // Reject absolute and parent directory paths
    if path.IsAbs(cleanName) || cleanName == ".." || 
        path.Dir(cleanName) == ".." {
        http.Error(w, "Invalid path", 400)
        return
    }

    // Open file through fs abstraction - automatically constrained
    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: os.DirFS() creates an fs.FS interface that's automatically rooted at the base directory - all paths are relative to this root, and the abstraction prevents escaping it. The Open() method rejects paths with .. components and absolute paths at the API level, providing built-in protection. Using path.Clean() (not filepath.Clean()) is important because fs.FS expects forward-slash separated paths regardless of OS. This pattern leverages Go's filesystem abstraction for simpler, safer code with less manual 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
    // This automatically handles path validation
    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 http.FileServer, which has built-in path traversal protection. For custom handlers, combining path cleaning, symlink resolution, and boundary validation ensures safety. Gin's c.File() method sets appropriate headers and efficiently streams the file. Using Gin's abstractions when possible reduces manual validation code while maintaining security.

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 DirFS

// SECURE - Echo using io/fs abstraction
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 io/fs
    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
    fsys := os.DirFS("./files")

    // Clean path (use path.Clean for fs.FS)
    cleanPath := path.Clean(requestPath)

    // Reject absolute and parent refs
    if path.IsAbs(cleanPath) || strings.HasPrefix(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 custom handlers, os.DirFS() creates a sandboxed filesystem view that can't escape the root directory. The fs.FS interface rejects .. components automatically, and using path.Clean() (not filepath) is critical because fs.FS expects slash-separated paths. Echo's c.Stream() efficiently sends file content to the response.

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...
}

Additional Resources