Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v3: Improve path validation in Static Middleware #3105

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
21 changes: 20 additions & 1 deletion middleware/static/static.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,30 @@
}
}

// 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
}

Check warning on line 111 in middleware/static/static.go

View check run for this annotation

Codecov / codecov/patch

middleware/static/static.go#L109-L111

Added lines #L109 - L111 were not covered by tests
Comment on lines +107 to +111
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Codebase verification

Add test case for filepath.Abs error handling

The error handling for filepath.Abs(root) is not covered by any existing tests. A test case should be added that verifies the middleware returns a 500 Internal Server Error when filepath.Abs fails. This can be achieved by using an invalid root path that triggers the error condition.

Example test case to add:

func Test_Static_AbsError(t *testing.T) {
    app := fiber.New()
    app.Get("/*", New("\x00invalid")) // null byte in path triggers Abs error

    req := httptest.NewRequest(fiber.MethodGet, "/", nil)
    resp, err := app.Test(req)
    require.NoError(t, err)
    require.Equal(t, fiber.StatusInternalServerError, resp.StatusCode)
}
🔗 Analysis chain

Add test coverage for root resolution error

The error handling for filepath.Abs(root) is correct, but lacks test coverage.

Let's verify the test coverage:

Would you like me to help create a test case that verifies this error path? We could use an invalid root path to trigger this scenario.


Let me verify if there are any existing tests for error cases in the static middleware by checking the test file more thoroughly.


After reviewing the test file, I can see that there are comprehensive tests for various scenarios, but none specifically testing the filepath.Abs error path. Let me check if there are any other relevant tests in the codebase.

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Check if there are any tests that verify the root resolution error
rg -l "StatusInternalServerError.*filepath\.Abs" middleware/static/static_test.go || echo "No tests found for root resolution error"

Length of output: 174


Script:

#!/bin/bash
# Check the content of static_test.go to see existing test patterns
cat middleware/static/static_test.go

Length of output: 35332


Script:

#!/bin/bash
# Search for any tests related to filepath.Abs error handling
rg -l "filepath\.Abs.*error" middleware/static/

Length of output: 49

🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 109-111: middleware/static/static.go#L109-L111
Added lines #L109 - L111 were not covered by tests


// 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
}

Check warning on line 122 in middleware/static/static.go

View check run for this annotation

Codecov / codecov/patch

middleware/static/static.go#L120-L122

Added lines #L120 - L122 were not covered by tests

return []byte(cleanPath)
}

maxAge := config.MaxAge
Expand Down
181 changes: 180 additions & 1 deletion middleware/static/static_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -850,3 +850,182 @@ func Test_Static_Compress_WithFileSuffixes(t *testing.T) {
require.NoError(t, err, "File should exist")
}
}

func Test_Static_PathTraversal(t *testing.T) {
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")
}
Loading