diff --git a/client/client_test.go b/client/client_test.go index 95fc7dc1020..01310a4f630 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -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 { diff --git a/middleware/static/static.go b/middleware/static/static.go index dc21ec83363..fad31084144 100644 --- a/middleware/static/static.go +++ b/middleware/static/static.go @@ -1,9 +1,12 @@ package static import ( + "errors" "fmt" "io/fs" + "net/url" "os" + pathpkg "path" "path/filepath" "strconv" "strings" @@ -14,6 +17,48 @@ 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 + if strings.Contains(s, "\x00") { + return nil, errors.New("invalid path") + } + + s = pathpkg.Clean("/" + s) + + if filesystem != nil { + s = utils.TrimLeft(s, '/') + if s == "" { + return []byte("/"), nil + } + if !fs.ValidPath(s) { + return nil, errors.New("invalid path") + } + s = "/" + s + } + + return utils.UnsafeBytes(s), nil +} + // New creates a new middleware handler. // The root argument specifies the root directory from which to serve static assets. // @@ -108,7 +153,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 } maxAge := config.MaxAge diff --git a/middleware/static/static_test.go b/middleware/static/static_test.go index 7d34b74609c..fd29236f9cc 100644 --- a/middleware/static/static_test.go +++ b/middleware/static/static_test.go @@ -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, @@ -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() } @@ -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) @@ -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) @@ -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, @@ -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, @@ -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, })) @@ -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, }, @@ -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") }) @@ -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") + + // 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") + + // 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") +}