diff --git a/middleware/static/static.go b/middleware/static/static.go index 7afc77980f..8e6a0f38e4 100644 --- a/middleware/static/static.go +++ b/middleware/static/static.go @@ -98,11 +98,30 @@ func New(root string, cfg ...Config) fiber.Handler { } } + // Add a leading slash if missing if len(path) > 0 && path[0] != '/' { path = append([]byte("/"), path...) } - return path + // Perform explicit path validation + absRoot, err := filepath.Abs(root) + if err != nil { + fctx.Response.SetStatusCode(fiber.StatusInternalServerError) + return nil + } + + // Clean the path and resolve it against the root + cleanPath := filepath.Clean(utils.UnsafeString(path)) + absPath := filepath.Join(absRoot, cleanPath) + relPath, err := filepath.Rel(absRoot, absPath) + + // Check if the resolved path is within the root + if err != nil || strings.HasPrefix(relPath, "..") { + fctx.Response.SetStatusCode(fiber.StatusForbidden) + return nil + } + + return []byte(cleanPath) } maxAge := config.MaxAge diff --git a/middleware/static/static_test.go b/middleware/static/static_test.go index 4e1d7a96d8..72e65c5bf7 100644 --- a/middleware/static/static_test.go +++ b/middleware/static/static_test.go @@ -412,7 +412,7 @@ func Test_Static_Next(t *testing.T) { func Test_Route_Static_Root(t *testing.T) { t.Parallel() - dir := "../../.github/testdata/fs/css" + dir := "../../.github/testdata/fs/css" //nolint:goconst // test app := fiber.New() app.Get("/*", New(dir, Config{ Browse: true, @@ -850,3 +850,187 @@ func Test_Static_Compress_WithFileSuffixes(t *testing.T) { require.NoError(t, err, "File should exist") } } + +func Test_Static_PathTraversal(t *testing.T) { + // Skip this test if running on Windows + if runtime.GOOS == "windows" { + t.Skip("Skipping Windows-specific tests") + } + + t.Parallel() + app := fiber.New() + + // Serve only from "../../.github/testdata/fs/css" + // This directory should contain `style.css` but not `index.html` or anything above it. + rootDir := "../../.github/testdata/fs/css" + 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 != "windows" { + t.Skip("Skipping Windows-specific tests") + } + + t.Parallel() + app := fiber.New() + + // Serve only from "../../.github/testdata/fs/css" + rootDir := "../../.github/testdata/fs/css" + 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") +}