Skip to content
2 changes: 1 addition & 1 deletion client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1603,7 +1603,7 @@ func Test_Client_SetProxyURL(t *testing.T) {
DisablePathNormalizing: true,
}

// Create a simple proxy sever
// Create a simple proxy server
proxyServer := fiber.New()

proxyServer.Use("*", func(c fiber.Ctx) error {
Expand Down
61 changes: 60 additions & 1 deletion middleware/static/static.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package static

import (
"errors"
"fmt"
"io/fs"
"net/url"
"os"
pathpkg "path"
"path/filepath"
"strconv"
"strings"
Expand All @@ -14,6 +17,57 @@ import (
"github.com/valyala/fasthttp"
)

// sanitizePath validates and cleans the requested path.
// It returns an error if the path attempts to traverse directories.
func sanitizePath(p []byte, filesystem fs.FS) ([]byte, error) {
// convert backslashes to slashes for consistency
s := strings.ReplaceAll(string(p), "\\", "/")

// repeatedly unescape until it no longer changes, catching errors
for {
if !strings.Contains(s, "%") {
break
}
us, err := url.PathUnescape(s)
if err != nil {
return nil, errors.New("invalid path")
}
if us == s {
break
}
s = us
}

// reject any null bytes or traversal attempts
if strings.Contains(s, "\x00") || strings.Contains(s, "..") || strings.Contains(s, ":") {
Comment thread
gaby marked this conversation as resolved.
Outdated
return nil, errors.New("invalid path")
}

raw := s
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
s = pathpkg.Clean("/" + s)

if filesystem != nil {
s = utils.TrimLeft(s, '/')
if s == "" {
return []byte("/"), nil
}
Comment thread
gaby marked this conversation as resolved.
if !fs.ValidPath(s) {
return nil, errors.New("invalid path")
}
s = "/" + s
} else {
// verify no traversal after cleaning
if strings.Contains(raw, "..") || strings.Contains(s, "..") {
return nil, errors.New("invalid path")
}
Comment thread
gaby marked this conversation as resolved.
Outdated
if !strings.HasPrefix(s, "/") {
s = "/" + s
}
}
Comment thread
gaby marked this conversation as resolved.
Outdated

return utils.UnsafeBytes(s), nil
}

// New creates a new middleware handler.
// The root argument specifies the root directory from which to serve static assets.
//
Expand Down Expand Up @@ -108,7 +162,12 @@ func New(root string, cfg ...Config) fiber.Handler {
path = append([]byte("/"), path...)
}

return path
sanitized, err := sanitizePath(path, fs.FS)
if err != nil {
// return a guaranteed-missing path so fs responds with 404
return []byte("/__fiber_invalid__")
}
return sanitized
Comment thread
gaby marked this conversation as resolved.
}

maxAge := config.MaxAge
Expand Down
211 changes: 200 additions & 11 deletions middleware/static/static_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import (
"github.com/stretchr/testify/require"
)

const (
winOS = "windows"
testCSSDir = "../../.github/testdata/fs/css"
)

var testConfig = fiber.TestConfig{
Timeout: 10 * time.Second,
FailOnTimeout: true,
Expand Down Expand Up @@ -133,7 +138,7 @@ func Test_Static_Custom_CacheControl(t *testing.T) {

func Test_Static_Disable_Cache(t *testing.T) {
// Skip on Windows. It's not possible to delete a file that is in use.
if runtime.GOOS == "windows" {
if runtime.GOOS == winOS {
t.SkipNow()
}

Expand Down Expand Up @@ -354,7 +359,7 @@ func Test_Static_Trailing_Slash(t *testing.T) {
require.NotEmpty(t, resp.Header.Get(fiber.HeaderContentLength))
require.Equal(t, fiber.MIMETextHTMLCharsetUTF8, resp.Header.Get(fiber.HeaderContentType))

app.Get("/john_without_index*", New("../../.github/testdata/fs/css"))
app.Get("/john_without_index*", New(testCSSDir))

req = httptest.NewRequest(fiber.MethodGet, "/john_without_index/", nil)
resp, err = app.Test(req)
Expand All @@ -379,7 +384,7 @@ func Test_Static_Trailing_Slash(t *testing.T) {
require.NotEmpty(t, resp.Header.Get(fiber.HeaderContentLength))
require.Equal(t, fiber.MIMETextHTMLCharsetUTF8, resp.Header.Get(fiber.HeaderContentType))

app.Use("/john_without_index/", New("../../.github/testdata/fs/css"))
app.Use("/john_without_index/", New(testCSSDir))

req = httptest.NewRequest(fiber.MethodGet, "/john_without_index/", nil)
resp, err = app.Test(req)
Expand Down Expand Up @@ -437,7 +442,7 @@ func Test_Static_Next(t *testing.T) {
func Test_Route_Static_Root(t *testing.T) {
t.Parallel()

dir := "../../.github/testdata/fs/css"
dir := testCSSDir
app := fiber.New()
app.Get("/*", New(dir, Config{
Browse: true,
Expand Down Expand Up @@ -474,7 +479,7 @@ func Test_Route_Static_Root(t *testing.T) {
func Test_Route_Static_HasPrefix(t *testing.T) {
t.Parallel()

dir := "../../.github/testdata/fs/css"
dir := testCSSDir
app := fiber.New()
app.Get("/static*", New(dir, Config{
Browse: true,
Expand Down Expand Up @@ -623,7 +628,7 @@ func Test_Static_FS_Browse(t *testing.T) {
}))

app.Get("/dirfs*", New("", Config{
FS: os.DirFS("../../.github/testdata/fs/css"),
FS: os.DirFS(testCSSDir),
Browse: true,
}))

Expand Down Expand Up @@ -715,25 +720,25 @@ func Test_isFile(t *testing.T) {
{
name: "directory",
path: ".",
filesystem: os.DirFS("../../.github/testdata/fs/css"),
filesystem: os.DirFS(testCSSDir),
expected: false,
},
{
name: "file",
path: "../../.github/testdata/fs/css/style.css",
path: testCSSDir + "/style.css",
filesystem: nil,
expected: true,
},
{
name: "file",
path: "../../.github/testdata/fs/css/style2.css",
path: testCSSDir + "/style2.css",
filesystem: nil,
expected: false,
gotError: fs.ErrNotExist,
},
{
name: "directory",
path: "../../.github/testdata/fs/css",
path: testCSSDir,
filesystem: nil,
expected: false,
},
Expand Down Expand Up @@ -881,7 +886,7 @@ func Test_Router_Mount_n_Static(t *testing.T) {

app := fiber.New()

app.Use("/static", New("../../.github/testdata/fs/css", Config{Browse: true}))
app.Use("/static", New(testCSSDir, Config{Browse: true}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString("Home")
})
Expand All @@ -900,3 +905,187 @@ func Test_Router_Mount_n_Static(t *testing.T) {
require.NoError(t, err, "app.Test(req)")
require.Equal(t, 200, resp.StatusCode, "Status code")
}

func Test_Static_PathTraversal(t *testing.T) {
// Skip this test if running on Windows
if runtime.GOOS == winOS {
t.Skip("Skipping Windows-specific tests")
}

t.Parallel()
app := fiber.New()

// Serve only from testCSSDir
// This directory should contain `style.css` but not `index.html` or anything above it.
rootDir := testCSSDir
app.Get("/*", New(rootDir))

// A valid request: should succeed
validReq := httptest.NewRequest(fiber.MethodGet, "/style.css", nil)
validResp, err := app.Test(validReq)
require.NoError(t, err, "app.Test(req)")
require.Equal(t, 200, validResp.StatusCode, "Status code")
require.Equal(t, fiber.MIMETextCSSCharsetUTF8, validResp.Header.Get(fiber.HeaderContentType))
validBody, err := io.ReadAll(validResp.Body)
require.NoError(t, err, "app.Test(req)")
require.Contains(t, string(validBody), "color")

Comment thread
gaby marked this conversation as resolved.
// Helper function to assert that a given path is blocked.
// Blocked can mean different status codes depending on what triggered the block.
// We'll accept 400 or 404 as "blocked" statuses:
// - 404 is the expected blocked response in most cases.
// - 400 might occur if fasthttp rejects the request before it's even processed (e.g., null bytes).
assertTraversalBlocked := func(path string) {
req := httptest.NewRequest(fiber.MethodGet, path, nil)
resp, err := app.Test(req)
require.NoError(t, err, "app.Test(req)")

status := resp.StatusCode
require.Truef(t, status == 400 || status == 404,
"Status code for path traversal %s should be 400 or 404, got %d", path, status)

body, err := io.ReadAll(resp.Body)
require.NoError(t, err)

// If we got a 404, we expect the "Cannot GET" message because that's how fiber handles NotFound by default.
if status == 404 {
require.Contains(t, string(body), "Cannot GET",
"Blocked traversal should have a Cannot GET message for %s", path)
} else {
require.Contains(t, string(body), "Are you a hacker?",
"Blocked traversal should have a Cannot GET message for %s", path)
}
}

// Basic attempts to escape the directory
assertTraversalBlocked("/index.html..")
assertTraversalBlocked("/style.css..")
assertTraversalBlocked("/../index.html")
assertTraversalBlocked("/../../index.html")
assertTraversalBlocked("/../../../index.html")

// Attempts with double slashes
assertTraversalBlocked("//../index.html")
assertTraversalBlocked("/..//index.html")

// Encoded attempts: `%2e` is '.' and `%2f` is '/'
assertTraversalBlocked("/..%2findex.html") // ../index.html
assertTraversalBlocked("/%2e%2e/index.html") // ../index.html
assertTraversalBlocked("/%2e%2e%2f%2e%2e/secret") // ../../../secret

// Mixed encoded and normal attempts
assertTraversalBlocked("/%2e%2e/../index.html") // ../../index.html
assertTraversalBlocked("/..%2f..%2fsecret.json") // ../../../secret.json

// Attempts with current directory references
assertTraversalBlocked("/./../index.html")
assertTraversalBlocked("/././../index.html")

// Trailing slashes
assertTraversalBlocked("/../")
assertTraversalBlocked("/../../")

// Attempts to load files from an absolute path outside the root
assertTraversalBlocked("/" + rootDir + "/../../index.html")

// Additional edge cases:

// Double-encoded `..`
assertTraversalBlocked("/%252e%252e/index.html") // double-encoded .. -> ../index.html after double decoding

// Multiple levels of encoding and traversal
assertTraversalBlocked("/%2e%2e%2F..%2f%2e%2e%2fWINDOWS") // multiple ups and unusual pattern
assertTraversalBlocked("/%2e%2e%2F..%2f%2e%2e%2f%2e%2e/secret") // more complex chain of ../

// Null byte attempts
assertTraversalBlocked("/index.html%00.jpg")
assertTraversalBlocked("/%00index.html")
assertTraversalBlocked("/somefolder%00/something")
assertTraversalBlocked("/%00/index.html")

// Attempts to access known system files
assertTraversalBlocked("/etc/passwd")
assertTraversalBlocked("/etc/")

// Complex mixed attempts with encoded slashes and dots
assertTraversalBlocked("/..%2F..%2F..%2F..%2Fetc%2Fpasswd")
Comment thread
gaby marked this conversation as resolved.

// Attempts inside subdirectories with encoded traversal
assertTraversalBlocked("/somefolder/%2e%2e%2findex.html")
assertTraversalBlocked("/somefolder/%2e%2e%2f%2e%2e%2findex.html")

// Backslash encoded attempts
assertTraversalBlocked("/%5C..%5Cindex.html")
}

func Test_Static_PathTraversal_WindowsOnly(t *testing.T) {
// Skip this test if not running on Windows
if runtime.GOOS != winOS {
t.Skip("Skipping Windows-specific tests")
}

t.Parallel()
app := fiber.New()

// Serve only from testCSSDir
rootDir := testCSSDir
app.Get("/*", New(rootDir))

// A valid request (relative path without backslash):
validReq := httptest.NewRequest(fiber.MethodGet, "/style.css", nil)
validResp, err := app.Test(validReq)
require.NoError(t, err, "app.Test(req)")
require.Equal(t, 200, validResp.StatusCode, "Status code for valid file on Windows")
body, err := io.ReadAll(validResp.Body)
require.NoError(t, err, "app.Test(req)")
require.Contains(t, string(body), "color")

// Helper to test blocked responses
assertTraversalBlocked := func(path string) {
req := httptest.NewRequest(fiber.MethodGet, path, nil)
resp, err := app.Test(req)
require.NoError(t, err, "app.Test(req)")

// We expect a blocked request to return either 400 or 404
status := resp.StatusCode
require.Containsf(t, []int{400, 404}, status,
"Status code for path traversal %s should be 400 or 404, got %d", path, status)

// If it's a 404, we expect a "Cannot GET" message
if status == 404 {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), "Cannot GET",
"Blocked traversal should have a 'Cannot GET' message for %s", path)
} else {
require.Contains(t, string(body), "Are you a hacker?",
"Blocked traversal should have a Cannot GET message for %s", path)
}
}

// Windows-specific traversal attempts
// Backslashes are treated as directory separators on Windows.
assertTraversalBlocked("/..\\index.html")
assertTraversalBlocked("/..\\..\\index.html")

// Attempt with a path that might try to reference Windows drives or absolute paths
// Note: These are artificial tests to ensure no drive-letter escapes are allowed.
assertTraversalBlocked("/C:\\Windows\\System32\\cmd.exe")
assertTraversalBlocked("/C:/Windows/System32/cmd.exe")

// Attempt with UNC-like paths (though unlikely in a web context, good to test)
assertTraversalBlocked("//server\\share\\secret.txt")

// Attempt using a mixture of forward and backward slashes
assertTraversalBlocked("/..\\..\\/index.html")

// Attempt that includes a null-byte on Windows
assertTraversalBlocked("/index.html%00.txt")

// Check behavior on an obviously non-existent and suspicious file
assertTraversalBlocked("/\\this\\path\\does\\not\\exist\\..")

// Attempts involving relative traversal and current directory reference
assertTraversalBlocked("/.\\../index.html")
assertTraversalBlocked("/./..\\index.html")
}
Loading