diff --git a/internal/server/handler.go b/internal/server/handler.go index 29637db7..7437164d 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "os" + "path" "path/filepath" "strconv" "strings" @@ -481,6 +482,11 @@ func (h *Handler) GetStatic(c echo.Context) error { absolutePath := filepath.Join(h.setting.Static, relativePath) + // Serve index.html for directory requests + if info, statErr := os.Stat(absolutePath); statErr == nil && info.IsDir() { + absolutePath = filepath.Join(absolutePath, "index.html") + } + return c.File(absolutePath) } @@ -519,7 +525,8 @@ func paramPath(c echo.Context, param string) (string, error) { return "", fmt.Errorf("path unescape: %w", err) } - cleanPath := filepath.Clean("/" + urlPath) + // Use path.Clean (not filepath.Clean) for URL paths - URLs always use forward slashes + cleanPath := path.Clean("/" + urlPath) if cleanPath != "/"+urlPath { return "", ErrInvalidPath } diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go index 2f5f24b9..62bf31df 100644 --- a/internal/server/handler_test.go +++ b/internal/server/handler_test.go @@ -913,6 +913,13 @@ func TestGetStatic(t *testing.T) { err = os.WriteFile(indexPath, []byte("test"), 0644) require.NoError(t, err) + // Create nested directory with file + scriptsDir := filepath.Join(staticDir, "scripts") + err = os.MkdirAll(scriptsDir, 0755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(scriptsDir, "ocap.js"), []byte("// test"), 0644) + require.NoError(t, err) + hdlr := Handler{ setting: Setting{Static: staticDir}, } @@ -929,6 +936,32 @@ func TestGetStatic(t *testing.T) { assert.NoError(t, err) }) + t.Run("root path serves index.html", func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("*") + c.SetParamValues("") // Empty param for root path + + err := hdlr.GetStatic(c) + assert.NoError(t, err) + assert.Contains(t, rec.Body.String(), "test") + }) + + t.Run("nested path with forward slashes", func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/scripts/ocap.js", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("*") + c.SetParamValues("scripts/ocap.js") // Forward slashes in path + + err := hdlr.GetStatic(c) + assert.NoError(t, err) + assert.Contains(t, rec.Body.String(), "// test") + }) + t.Run("path traversal blocked", func(t *testing.T) { e := echo.New() req := httptest.NewRequest(http.MethodGet, "/../../../etc/passwd", nil) @@ -942,6 +975,78 @@ func TestGetStatic(t *testing.T) { }) } +func TestParamPath(t *testing.T) { + tests := []struct { + name string + param string + wantPath string + wantError bool + }{ + { + name: "empty path returns root", + param: "", + wantPath: "/", + wantError: false, + }, + { + name: "simple filename", + param: "index.html", + wantPath: "/index.html", + wantError: false, + }, + { + name: "nested path with forward slashes", + param: "scripts/ocap.js", + wantPath: "/scripts/ocap.js", + wantError: false, + }, + { + name: "deeply nested path", + param: "assets/images/logo.png", + wantPath: "/assets/images/logo.png", + wantError: false, + }, + { + name: "path traversal blocked", + param: "../../../etc/passwd", + wantPath: "", + wantError: true, + }, + { + name: "double slash blocked", + param: "foo//bar", + wantPath: "", + wantError: true, + }, + { + name: "dot segment blocked", + param: "foo/../bar", + wantPath: "", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/"+tt.param, nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("*") + c.SetParamValues(tt.param) + + got, err := paramPath(c, "*") + + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantPath, got) + } + }) + } +} + func TestCacheControl(t *testing.T) { hdlr := Handler{}