Skip to content

Commit a926057

Browse files
authored
fix: use path.Clean for URL paths to fix Windows static file serving (#124)
* 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. * 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
1 parent 59bbbc9 commit a926057

File tree

2 files changed

+113
-1
lines changed

2 files changed

+113
-1
lines changed

internal/server/handler.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"net/url"
99
"os"
10+
"path"
1011
"path/filepath"
1112
"strconv"
1213
"strings"
@@ -481,6 +482,11 @@ func (h *Handler) GetStatic(c echo.Context) error {
481482

482483
absolutePath := filepath.Join(h.setting.Static, relativePath)
483484

485+
// Serve index.html for directory requests
486+
if info, statErr := os.Stat(absolutePath); statErr == nil && info.IsDir() {
487+
absolutePath = filepath.Join(absolutePath, "index.html")
488+
}
489+
484490
return c.File(absolutePath)
485491
}
486492

@@ -519,7 +525,8 @@ func paramPath(c echo.Context, param string) (string, error) {
519525
return "", fmt.Errorf("path unescape: %w", err)
520526
}
521527

522-
cleanPath := filepath.Clean("/" + urlPath)
528+
// Use path.Clean (not filepath.Clean) for URL paths - URLs always use forward slashes
529+
cleanPath := path.Clean("/" + urlPath)
523530
if cleanPath != "/"+urlPath {
524531
return "", ErrInvalidPath
525532
}

internal/server/handler_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,13 @@ func TestGetStatic(t *testing.T) {
913913
err = os.WriteFile(indexPath, []byte("<html>test</html>"), 0644)
914914
require.NoError(t, err)
915915

916+
// Create nested directory with file
917+
scriptsDir := filepath.Join(staticDir, "scripts")
918+
err = os.MkdirAll(scriptsDir, 0755)
919+
require.NoError(t, err)
920+
err = os.WriteFile(filepath.Join(scriptsDir, "ocap.js"), []byte("// test"), 0644)
921+
require.NoError(t, err)
922+
916923
hdlr := Handler{
917924
setting: Setting{Static: staticDir},
918925
}
@@ -929,6 +936,32 @@ func TestGetStatic(t *testing.T) {
929936
assert.NoError(t, err)
930937
})
931938

939+
t.Run("root path serves index.html", func(t *testing.T) {
940+
e := echo.New()
941+
req := httptest.NewRequest(http.MethodGet, "/", nil)
942+
rec := httptest.NewRecorder()
943+
c := e.NewContext(req, rec)
944+
c.SetParamNames("*")
945+
c.SetParamValues("") // Empty param for root path
946+
947+
err := hdlr.GetStatic(c)
948+
assert.NoError(t, err)
949+
assert.Contains(t, rec.Body.String(), "<html>test</html>")
950+
})
951+
952+
t.Run("nested path with forward slashes", func(t *testing.T) {
953+
e := echo.New()
954+
req := httptest.NewRequest(http.MethodGet, "/scripts/ocap.js", nil)
955+
rec := httptest.NewRecorder()
956+
c := e.NewContext(req, rec)
957+
c.SetParamNames("*")
958+
c.SetParamValues("scripts/ocap.js") // Forward slashes in path
959+
960+
err := hdlr.GetStatic(c)
961+
assert.NoError(t, err)
962+
assert.Contains(t, rec.Body.String(), "// test")
963+
})
964+
932965
t.Run("path traversal blocked", func(t *testing.T) {
933966
e := echo.New()
934967
req := httptest.NewRequest(http.MethodGet, "/../../../etc/passwd", nil)
@@ -942,6 +975,78 @@ func TestGetStatic(t *testing.T) {
942975
})
943976
}
944977

978+
func TestParamPath(t *testing.T) {
979+
tests := []struct {
980+
name string
981+
param string
982+
wantPath string
983+
wantError bool
984+
}{
985+
{
986+
name: "empty path returns root",
987+
param: "",
988+
wantPath: "/",
989+
wantError: false,
990+
},
991+
{
992+
name: "simple filename",
993+
param: "index.html",
994+
wantPath: "/index.html",
995+
wantError: false,
996+
},
997+
{
998+
name: "nested path with forward slashes",
999+
param: "scripts/ocap.js",
1000+
wantPath: "/scripts/ocap.js",
1001+
wantError: false,
1002+
},
1003+
{
1004+
name: "deeply nested path",
1005+
param: "assets/images/logo.png",
1006+
wantPath: "/assets/images/logo.png",
1007+
wantError: false,
1008+
},
1009+
{
1010+
name: "path traversal blocked",
1011+
param: "../../../etc/passwd",
1012+
wantPath: "",
1013+
wantError: true,
1014+
},
1015+
{
1016+
name: "double slash blocked",
1017+
param: "foo//bar",
1018+
wantPath: "",
1019+
wantError: true,
1020+
},
1021+
{
1022+
name: "dot segment blocked",
1023+
param: "foo/../bar",
1024+
wantPath: "",
1025+
wantError: true,
1026+
},
1027+
}
1028+
1029+
for _, tt := range tests {
1030+
t.Run(tt.name, func(t *testing.T) {
1031+
e := echo.New()
1032+
req := httptest.NewRequest(http.MethodGet, "/"+tt.param, nil)
1033+
rec := httptest.NewRecorder()
1034+
c := e.NewContext(req, rec)
1035+
c.SetParamNames("*")
1036+
c.SetParamValues(tt.param)
1037+
1038+
got, err := paramPath(c, "*")
1039+
1040+
if tt.wantError {
1041+
assert.Error(t, err)
1042+
} else {
1043+
assert.NoError(t, err)
1044+
assert.Equal(t, tt.wantPath, got)
1045+
}
1046+
})
1047+
}
1048+
}
1049+
9451050
func TestCacheControl(t *testing.T) {
9461051
hdlr := Handler{}
9471052

0 commit comments

Comments
 (0)