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
23 changes: 23 additions & 0 deletions 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 @@ -159,6 +160,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)
Comment on lines +169 to +171
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The comment for RootFS suggests a default of os.DirFS(RootDir). While this is indeed the fallback logic in resolveUploadPath, the New function itself doesn't explicitly set this default. It might be clearer to either set app.config.RootFS = os.DirFS(app.config.RootDir) in the New function (after RootDir is initialized) or adjust the comment to reflect that it's a runtime default if not explicitly configured.

RootFS fs.FS `json:"-"`

// 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 +579,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
171 changes: 168 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), c.app.config.RootPerms); err != nil {
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,158 @@ 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) {
root := app.config.RootDir
if root == "" {
root = "."
}

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

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

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

resolvedRoot, err := filepath.EvalSymlinks(absoluteRoot)
if err == nil {
absoluteRoot = resolvedRoot
} else if !errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("invalid upload root: %w", err)
}
Comment on lines +568 to +573
Copy link
Contributor

Choose a reason for hiding this comment

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

security-high high

The use of filepath.EvalSymlinks on the absoluteRoot is a critical security measure. It resolves any symbolic links in the root path itself, preventing an attacker from configuring the upload root to point to a sensitive directory via a symlink. The error handling for fs.ErrNotExist is also correct, allowing the root to be a non-existent path that will be created.


info, err := os.Stat(absoluteRoot)
if err != nil {
return "", fmt.Errorf("invalid upload root: %w", err)
}

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

return absoluteRoot, nil
}

func sanitizeUploadPath(path string, uploadFS fs.FS) (string, error) {
if filepath.IsAbs(path) {
return "", errUploadAbsolute
}
Comment on lines +588 to +590
Copy link
Contributor

Choose a reason for hiding this comment

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

security-high high

Rejecting absolute paths (filepath.IsAbs) and directory traversal segments (containsParentDir) early in sanitizeUploadPath is a robust first line of defense against common path manipulation attacks. This ensures that user-provided paths are always relative and do not attempt to escape the intended directory structure.


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

normalized := pathpkg.Clean(rawNormalized)
normalized = utils.TrimLeft(normalized, '/')
if normalized == "" || normalized == "." {
return "", errUploadTraversal
Comment on lines +597 to +600
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The combination of pathpkg.Clean and utils.TrimLeft followed by checks for empty or . paths is effective for normalizing and validating the path. This helps catch edge cases where cleaning might result in an unexpected path, further preventing traversal or writing to the root itself when a specific file is expected.

}

if !fs.ValidPath(normalized) {
return "", errUploadTraversal
}
Comment on lines +603 to +605
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Using fs.ValidPath(normalized) is a good standard check for ensuring the path conforms to io/fs requirements, which can help prevent issues with invalid characters or structures that might bypass other checks.


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, "/")
current := "."

for i, part := range parts {
next := part
if current != "." {
next = pathpkg.Join(current, part)
}

info, err := fs.Stat(uploadFS, next)
if err != nil {
Comment on lines +628 to +629

Choose a reason for hiding this comment

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

P1 Badge Detect symlink traversal when validating upload paths

Upload validation walks path components with fs.Stat, but Stat follows symlinks, so the subsequent ModeSymlink check in rejectSymlinkTraversal never triggers. If the configured upload root already contains a symlinked directory (e.g., uploads/link -> /tmp/outside), a caller can save a file under that link and the write will escape the configured root despite the new validation. Use an lstat-style check (e.g., fs.ReadDir/DirEntry.Type or os.Lstat) to detect symlink components before writing.

Useful? React with 👍 / 👎.

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

if info.Mode()&fs.ModeSymlink != 0 {
return errUploadSymlinkPath
}

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

current = next
}

return nil
Comment on lines +614 to +647
Copy link
Contributor

Choose a reason for hiding this comment

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

security-critical critical

The rejectSymlinkTraversal function is a crucial security component. By iterating through each part of the normalized path and checking for symlinks using fs.Stat against the uploadFS (which is rooted at RootDir), it effectively prevents symlink traversal attacks where an attacker might try to create or overwrite files outside the designated upload root by exploiting symlinks within the provided path. The check !info.IsDir() for intermediate parts also prevents writing into a file disguised as a directory.

}

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, "..\\")
}
Comment on lines +654 to +661
Copy link
Contributor

Choose a reason for hiding this comment

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

security-high high

The isWithinRoot function provides an excellent final check to ensure that the target path, after all normalization and resolution, truly remains within the root directory. Using filepath.Rel and checking for .. or ../ prefixes is a robust way to confirm this containment, especially important for cross-platform compatibility with both Unix-like and Windows paths.


// Secure returns whether a secure connection was established.
func (c *DefaultCtx) Secure() bool {
return c.Protocol() == schemeHTTPS
Expand Down
79 changes: 67 additions & 12 deletions ctx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"math"
"mime/multipart"
"net"
Expand Down Expand Up @@ -3910,25 +3911,18 @@ func Test_Ctx_RouteNormalized(t *testing.T) {
func Test_Ctx_SaveFile(t *testing.T) {
// TODO We should clean this up
t.Parallel()
app := New()
uploadRoot := t.TempDir()
app := New(Config{RootDir: uploadRoot})

app.Post("/test", func(c Ctx) error {
fh, err := c.Req().FormFile("file")
require.NoError(t, err)

tempFile, err := os.CreateTemp(os.TempDir(), "test-")
require.NoError(t, err)

defer func(file *os.File) {
closeErr := file.Close()
require.NoError(t, closeErr)
closeErr = os.Remove(file.Name())
require.NoError(t, closeErr)
}(tempFile)
err = c.SaveFile(fh, tempFile.Name())
relativePath := filepath.Join("uploads", "test-upload")
err = c.SaveFile(fh, relativePath)
require.NoError(t, err)

bs, err := os.ReadFile(tempFile.Name())
bs, err := os.ReadFile(filepath.Join(uploadRoot, relativePath)) //nolint:gosec // upload path validated before use
require.NoError(t, err)
require.Equal(t, "hello world", string(bs))
return nil
Expand All @@ -3953,6 +3947,47 @@ func Test_Ctx_SaveFile(t *testing.T) {
require.Equal(t, StatusOK, resp.StatusCode, "Status code")
}

func Test_Ctx_SaveFile_PreventTraversal(t *testing.T) {
t.Parallel()

uploadRoot := t.TempDir()
app := New(Config{RootDir: uploadRoot})
ctx := app.AcquireCtx(&fasthttp.RequestCtx{})

t.Cleanup(func() {
app.ReleaseCtx(ctx)
})

fileHeader := createMultipartFileHeader(t, "blocked.txt", []byte("forbidden"))

err := ctx.SaveFile(fileHeader, filepath.Join("..", "escape", "blocked.txt"))
require.Error(t, err)

_, statErr := os.Stat(filepath.Join(uploadRoot, "escape", "blocked.txt"))
require.ErrorIs(t, statErr, fs.ErrNotExist)
}

func Test_Ctx_SaveFile_RejectAbsolute(t *testing.T) {
t.Parallel()

uploadRoot := t.TempDir()
app := New(Config{RootDir: uploadRoot})
ctx := app.AcquireCtx(&fasthttp.RequestCtx{})

t.Cleanup(func() {
app.ReleaseCtx(ctx)
})

fileHeader := createMultipartFileHeader(t, "blocked.txt", []byte("forbidden"))

absoluteTarget := filepath.Join(t.TempDir(), "outside.txt")
err := ctx.SaveFile(fileHeader, absoluteTarget)
require.Error(t, err)

_, statErr := os.Stat(absoluteTarget)
require.ErrorIs(t, statErr, fs.ErrNotExist)
}

func createMultipartFileHeader(t *testing.T, filename string, data []byte) *multipart.FileHeader {
t.Helper()

Expand Down Expand Up @@ -4022,6 +4057,26 @@ func Test_Ctx_SaveFileToStorage(t *testing.T) {
require.Equal(t, StatusOK, resp.StatusCode, "Status code")
}

func Test_Ctx_SaveFileToStorage_InvalidPath(t *testing.T) {
t.Parallel()

app := New()
storage := memory.New()
ctx := app.AcquireCtx(&fasthttp.RequestCtx{})

t.Cleanup(func() {
app.ReleaseCtx(ctx)
})

fileHeader := createMultipartFileHeader(t, "blocked.bin", []byte("forbidden"))

err := ctx.SaveFileToStorage(fileHeader, filepath.Join("..", "escape", "blocked.bin"), storage)
require.Error(t, err)

err = ctx.SaveFileToStorage(fileHeader, filepath.Join(t.TempDir(), "outside.bin"), storage)
require.Error(t, err)
}

func Test_Ctx_SaveFileToStorage_LargeUpload(t *testing.T) {
t.Parallel()
const (
Expand Down
10 changes: 10 additions & 0 deletions docs/api/ctx.md
Original file line number Diff line number Diff line change
Expand Up @@ -1398,6 +1398,11 @@ app.Get("/", func(c fiber.Ctx) error {
### SaveFile

Method is used to save **any** multipart file to disk.
Paths are normalized against the configured upload root (`app.Config.RootDir`,
default `"."`) and validated using `app.Config.RootFS` (defaults to
`os.DirFS(RootDir)`). Absolute paths, traversal attempts, and symlink escapes
are rejected before writing. The upload root will be created with
`app.Config.RootPerms` (default `0o750`) when needed.

```go title="Signature"
func (c fiber.Ctx) SaveFile(fh *multipart.FileHeader, path string) error
Expand Down Expand Up @@ -1431,6 +1436,11 @@ app.Post("/", func(c fiber.Ctx) error {
### SaveFileToStorage

Method is used to save **any** multipart file to an external storage system.
Paths are normalized against the configured upload root (`app.Config.RootDir`,
default `"."`) and validated using `app.Config.RootFS` (defaults to
`os.DirFS(RootDir)`). Absolute paths, traversal attempts, and symlink escapes
are rejected before writing. The upload root will be created with
`app.Config.RootPerms` (default `0o750`) when needed.

```go title="Signature"
func (c fiber.Ctx) SaveFileToStorage(fileheader *multipart.FileHeader, path string, storage Storage) error
Expand Down
3 changes: 3 additions & 0 deletions docs/api/fiber.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ app := fiber.New(fiber.Config{
|---------------------------------------------------------------------------------------|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|
| <Reference id="appname">AppName</Reference> | `string` | Sets the application name used in logs and the Server header | `""` |
| <Reference id="bodylimit">BodyLimit</Reference> | `int` | Sets the maximum allowed size for a request body. Zero or negative values fall back to the default limit. If the size exceeds the configured limit, it sends `413 - Request Entity Too Large` response. | `4 * 1024 * 1024` |
| <Reference id="rootdir">RootDir</Reference> | `string` | Base directory used by `SaveFile` and `SaveFileToStorage` when resolving upload targets. Relative paths are resolved from the process working directory and must remain under this root. | `"."` |
| <Reference id="rootfs">RootFS</Reference> | `fs.FS` | Filesystem rooted at `RootDir` used to validate upload paths before writing. Defaults to `os.DirFS(RootDir)` when unset. | `os.DirFS(RootDir)` |
| <Reference id="rootperms">RootPerms</Reference> | `fs.FileMode` | Permissions applied when creating `RootDir` if it does not already exist. | `0o750` |
| <Reference id="casesensitive">CaseSensitive</Reference> | `bool` | When enabled, `/Foo` and `/foo` are different routes. When disabled, `/Foo` and `/foo` are treated the same. | `false` |
| <Reference id="colorscheme">ColorScheme</Reference> | [`Colors`](https://github.com/gofiber/fiber/blob/main/color.go) | You can define custom color scheme. They'll be used for startup message, route list and some middlewares. | [`DefaultColors`](https://github.com/gofiber/fiber/blob/main/color.go) |
| <Reference id="compressedfilesuffixes">CompressedFileSuffixes</Reference> | `map[string]string` | Adds a suffix to the original file name and tries saving the resulting compressed file under the new file name. | `{"gzip": ".fiber.gz", "br": ".fiber.br", "zstd": ".fiber.zst"}` |
Expand Down
Loading