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.Cleanor 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.OpenRooton Go 1.24 or later. CallingEvalSymlinksand 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.Joinnormalizes paths but does not reject results outside the root. The filename comes from a query value and is passed toServeFile, 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.