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