Path traversal

Path traversal

Description

A file path derived from user input can read or write unintended locations if it escapes the intended directory. Parent traversal such as ../ and link-based escapes are common examples; archive extraction can have the same problem.

Potential impact

  • Sensitive configuration, source code or credentials may be exposed.
  • Uploads or extraction may overwrite files, causing data corruption or service disruption.
  • Modification of files used for execution may lead to code execution.

Remediation

  • Open files only within a fixed root. filepath.Clean or a string-prefix check does not guarantee the boundary of actual filesystem access.
  • Allow a single component when only a filename is needed; for relative paths, reject parent escapes using filepath.Rel.
  • Use directory-relative opening that addresses symlinks and path replacement after checks. Consider os.OpenRoot on Go 1.24 or later. Calling EvalSymlinks and opening the path afterward still leaves a race.
  • Apply per-file authorization and required extension, size and depth limits; stop on validation failure.

Examples

The after-example checks the path as a string. Trusted administrators must control /var/app/data, with no links escaping it or paths an attacker can replace. If those assumptions cannot be guaranteed, use an opening method that prevents link-based escapes. Authentication and per-file authorization are omitted.

Before

go
package main

import (
    "net/http"
    "os"
    "path/filepath"
    "github.com/gin-gonic/gin"
)

func download(c *gin.Context) {
    // Before: use user input directly as a path
    // Example: ?file=../../etc/passwd
    file := c.Query("file")

    baseDir := "/var/app/data"
    // Join cleans the path but can still produce a parent escape
    fullPath := filepath.Join(baseDir, file)

    if _, err := os.Stat(fullPath); err != nil {
        c.String(http.StatusNotFound, "not found")
        return
    }
    http.ServeFile(c.Writer, c.Request, fullPath)
}

func main() {
    r := gin.Default()
    r.GET("/download", download)
    r.Run()
}

After

go
package main

import (
    "net/http"
    "os"
    "path/filepath"
    "runtime"
    "strings"
    "github.com/gin-gonic/gin"
)

func isUnderBase(base, target string) bool {
    // Normalize paths and include the separator when comparing the base
    b := filepath.Clean(base)
    t := filepath.Clean(target)
    sep := string(os.PathSeparator)
    if runtime.GOOS == "windows" {
        b = strings.ToLower(b)
        t = strings.ToLower(t)
    }
    return strings.HasPrefix(t+sep, b+sep)
}

func safeDownload(c *gin.Context) {
    file := c.Query("file")

    baseDir := "/var/app/data" // Fixed root directory

    // 1) Require the joined path to remain lexically inside baseDir
    target := filepath.Join(baseDir, file)
    if !isUnderBase(baseDir, target) {
        c.String(http.StatusBadRequest, "invalid path")
        return
    }

    // 2) Optional extension allow-list
    // if !strings.HasSuffix(strings.ToLower(target), ".txt") { ... }

    // 3) Serve the file
    if _, err := os.Stat(target); err != nil {
        c.String(http.StatusNotFound, "not found")
        return
    }
    http.ServeFile(c.Writer, c.Request, target)
}

func main() {
    r := gin.Default()
    r.GET("/download", safeDownload)
    r.Run()
}

Explanation:

  • Before: filepath.Join normalizes paths but does not reject results outside the root. The filename comes from a query value and is passed to ServeFile, so checks on the request URL path alone do not protect it.
  • After: A prefix comparison including the separator rejects paths outside the lexical boundary. It does not address symlinks, path replacement after checks, per-file authorization or disclosure of directory contents.

References