Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion app.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/http/httputil"
Expand Down Expand Up @@ -90,6 +91,9 @@ type App struct {
mountFields *mountFields
// state management
state *State
// upload root cache
uploadRoot string
uploadRootErr error
// Route stack divided by HTTP methods
stack [][]*Route
// customConstraints is a list of external constraints
Expand All @@ -99,7 +103,8 @@ type App struct {
// custom binders
customBinders []CustomBinder
// Route stack divided by HTTP methods and route prefixes
treeStack []map[int][]*Route
treeStack []map[int][]*Route
uploadRootOnce sync.Once
// sendfilesMutex is a mutex used for sendfile operations
sendfilesMutex sync.RWMutex
mutex sync.Mutex
Expand Down Expand Up @@ -159,6 +164,22 @@ type Config struct { //nolint:govet // Aligning the struct fields is not necessa
// Default: 4 * 1024 * 1024
BodyLimit int `json:"body_limit"`

// RootDir defines the directory where files can be persisted using SaveFile and SaveFileToStorage.
// The path must be relative to the current working directory or an absolute path on the host system.
//
// Default: "."
RootDir string `json:"root_dir"`

// RootFS provides an fs.FS implementation rooted at RootDir used to validate upload targets.
//
// Default: os.DirFS(RootDir)
RootFS fs.FS `json:"-"`
Comment on lines +173 to +176
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RootFS field documentation states "Default: os.DirFS(RootDir)" but this default is actually applied at runtime in resolveUploadPath (ctx.go:530-532) rather than during config initialization. This could be confusing because users might expect the field to be populated during app creation. Consider either populating this field during app initialization in New() or clarifying in the documentation that this is a lazy default applied during upload operations.

Copilot uses AI. Check for mistakes.

// RootPerms are the permissions applied when creating RootDir.
//
// Default: 0o750
RootPerms fs.FileMode `json:"root_perms"`

// Maximum number of concurrent connections.
//
// Default: 256 * 1024
Expand Down Expand Up @@ -562,6 +583,12 @@ func New(config ...Config) *App {
if app.config.BodyLimit <= 0 {
app.config.BodyLimit = DefaultBodyLimit
}
if app.config.RootDir == "" {
app.config.RootDir = "."
}
if app.config.RootPerms == 0 {
app.config.RootPerms = 0o750
}
if app.config.Concurrency <= 0 {
app.config.Concurrency = DefaultConcurrency
}
Expand Down
208 changes: 205 additions & 3 deletions ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@ package fiber
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"io/fs"
"maps"
"mime/multipart"
"os"
pathpkg "path"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -453,12 +459,26 @@ func (c *DefaultCtx) IsPreflight() bool {
}

// SaveFile saves any multipart file to disk.
func (*DefaultCtx) SaveFile(fileheader *multipart.FileHeader, path string) error {
return fasthttp.SaveMultipartFile(fileheader, path)
func (c *DefaultCtx) SaveFile(fileheader *multipart.FileHeader, path string) error {
_, absolutePath, err := resolveUploadPath(c.app, path)
if err != nil {
return err
}

if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil { //nolint:gosec // upload subdirectories need shared execute permissions for reads
return fmt.Errorf("failed to prepare upload path: %w", err)
}

return fasthttp.SaveMultipartFile(fileheader, absolutePath)
}

// SaveFileToStorage saves any multipart file to an external storage system.
func (c *DefaultCtx) SaveFileToStorage(fileheader *multipart.FileHeader, path string, storage Storage) error {
safePath, _, err := resolveUploadPath(c.app, path)
if err != nil {
return err
}

file, err := fileheader.Open()
if err != nil {
return fmt.Errorf("failed to open: %w", err)
Expand Down Expand Up @@ -488,13 +508,195 @@ func (c *DefaultCtx) SaveFileToStorage(fileheader *multipart.FileHeader, path st

data := append([]byte(nil), buf.Bytes()...)

if err := storage.SetWithContext(c.Context(), path, data, 0); err != nil {
if err := storage.SetWithContext(c.Context(), safePath, data, 0); err != nil {
return fmt.Errorf("failed to store: %w", err)
}

return nil
}

//nolint:nonamedreturns // names clarify path handling through normalization and validation
func resolveUploadPath(app *App, path string) (normalizedPath, absolutePath string, err error) {
if app == nil {
return "", "", fmt.Errorf("invalid upload root: %w", errors.New("app is nil"))
}

uploadRoot, err := getRootDir(app)
if err != nil {
return "", "", err
}

uploadFS := app.config.RootFS
if uploadFS == nil {
uploadFS = os.DirFS(uploadRoot)
}

normalizedPath, err = sanitizeUploadPath(path, uploadFS)
if err != nil {
return "", "", err
}

relativePath := filepath.FromSlash(normalizedPath)
absolutePath = filepath.Join(uploadRoot, relativePath)
if !isWithinRoot(uploadRoot, absolutePath) {
return "", "", errUploadOutsideRoot
}

return normalizedPath, absolutePath, nil
}

func getRootDir(app *App) (string, error) {
if app == nil {
return "", fmt.Errorf("invalid upload root: %w", errors.New("app is nil"))
}

app.uploadRootOnce.Do(func() {
root := app.config.RootDir
if root == "" {
root = "."
}

perms := app.config.RootPerms
if perms == 0 {
perms = 0o750
}

absoluteRoot, err := filepath.Abs(root)
if err != nil {
app.uploadRootErr = fmt.Errorf("invalid upload root: %w", err)
return
}

if err = os.MkdirAll(absoluteRoot, perms); err != nil {
app.uploadRootErr = fmt.Errorf("invalid upload root: %w", err)
return
}

resolvedRoot, err := filepath.EvalSymlinks(absoluteRoot)
if err == nil {
absoluteRoot = resolvedRoot
} else if !errors.Is(err, fs.ErrNotExist) {
app.uploadRootErr = fmt.Errorf("invalid upload root: %w", err)
return
}

Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After calling os.MkdirAll with the configured permissions, the root directory is resolved through filepath.EvalSymlinks. However, if the root itself is a symlink or contains symlinks, the resolved path may have different permissions than intended. The subsequent os.Stat check verifies the final directory but doesn't ensure it has the correct permissions specified by RootPerms. Consider either checking/setting permissions after symlink resolution or documenting this behavior.

Suggested change
// Ensure the resolved directory has the intended permissions
if err := os.Chmod(absoluteRoot, perms); err != nil {
app.uploadRootErr = fmt.Errorf("invalid upload root: %w", err)
return
}

Copilot uses AI. Check for mistakes.
info, err := os.Stat(absoluteRoot)
if err != nil {
app.uploadRootErr = fmt.Errorf("invalid upload root: %w", err)
return
}

if !info.IsDir() {
app.uploadRootErr = fmt.Errorf("invalid upload root: %s is not a directory", absoluteRoot)
return
}

app.uploadRoot = absoluteRoot
})

if app.uploadRootErr != nil {
return "", app.uploadRootErr
}

return app.uploadRoot, nil
}

func sanitizeUploadPath(path string, uploadFS fs.FS) (string, error) {
if filepath.IsAbs(path) {
return "", errUploadAbsolute
}

rawNormalized := strings.ReplaceAll(path, "\\", "/")
if containsParentDir(rawNormalized) {
return "", errUploadTraversal
}

normalized := pathpkg.Clean(rawNormalized)
normalized = utils.TrimLeft(normalized, '/')
if normalized == "" || normalized == "." {
return "", errUploadTraversal
}

if !fs.ValidPath(normalized) {
return "", errUploadTraversal
}

if err := rejectSymlinkTraversal(uploadFS, normalized); err != nil {
return "", err
}

return normalized, nil
}

func rejectSymlinkTraversal(uploadFS fs.FS, normalized string) error {
if uploadFS == nil {
return nil
}

parts := strings.Split(normalized, "/")
parent := "."

for i, part := range parts {
entries, err := fs.ReadDir(uploadFS, parent)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return fmt.Errorf("invalid upload path: %w", err)
}
Comment on lines +639 to +646
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rejectSymlinkTraversal function performs a directory listing for each path segment, which can be inefficient for deeply nested upload paths. Consider using fs.Stat instead of fs.ReadDir to check individual path components, as it would be more efficient and still allow symlink detection through the file info.

Copilot uses AI. Check for mistakes.

var entry fs.DirEntry

for _, e := range entries {
if e.Name() == part {
entry = e
break
}
}

if entry == nil {
return nil
}

if entry.Type()&fs.ModeSymlink != 0 {
return errUploadSymlinkPath
}

info, err := entry.Info()
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return fmt.Errorf("invalid upload path: %w", err)
}

if i < len(parts)-1 && !info.IsDir() {
return errUploadTraversal
}

if parent == "." {
parent = part
} else {
parent = pathpkg.Join(parent, part)
}
}

return nil
}
Comment on lines +631 to +685
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rejectSymlinkTraversal function performs directory traversal on every upload, which can cause significant performance degradation especially for deeply nested paths or directories with many entries. Each directory is read fully using fs.ReadDir, and this happens for every path component. For frequent uploads, this overhead can be substantial. Consider caching directory metadata or implementing a more efficient validation approach.

Copilot uses AI. Check for mistakes.

func containsParentDir(p string) bool {
return slices.Contains(strings.Split(p, "/"), "..")
}

func isWithinRoot(root, target string) bool {
rel, err := filepath.Rel(root, target)
if err != nil {
return false
}

return rel != ".." && !strings.HasPrefix(rel, "../") && rel != "..\\" && !strings.HasPrefix(rel, "..\\")
}

// Secure returns whether a secure connection was established.
func (c *DefaultCtx) Secure() bool {
return c.Protocol() == schemeHTTPS
Expand Down
Loading