From 168c8f56cd77bfae34659bdf19dbbc2490f7e498 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Wed, 4 Feb 2026 21:51:59 +0100 Subject: [PATCH 1/2] fix: use path.Clean for URL paths to fix Windows static file serving filepath.Clean converts forward slashes to backslashes on Windows, which broke the path validation in paramPath since URL paths always use forward slashes. This caused all static file requests to return 404 on Windows. Also add index.html fallback for directory requests. --- internal/server/handler.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 } From 432e33c3a1f067667d61b55e9eb59cb142463061 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Wed, 4 Feb 2026 21:53:39 +0100 Subject: [PATCH 2/2] test: add tests for paramPath and static file serving - Test root path serves index.html - Test nested paths with forward slashes work correctly - Add paramPath unit tests for path validation --- internal/server/handler_test.go | 105 ++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) 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{}