From 218e44505b20d388f2d74df32993d8906bbc82d3 Mon Sep 17 00:00:00 2001 From: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:25:59 +0300 Subject: [PATCH] fix(middleware): escape static directory listing links Signed-off-by: Yusuf Efe <120668197+yuefdev@users.noreply.github.com> --- middleware/static.go | 12 +++++++--- middleware/static_test.go | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/middleware/static.go b/middleware/static.go index 3fbef23ed..ee1bf64da 100644 --- a/middleware/static.go +++ b/middleware/static.go @@ -80,6 +80,7 @@ type StaticConfig struct { EnablePathUnescaping bool // DirectoryListTemplate is template to list directory contents + // File entries expose Name, URL, Dir and Size; URL is the escaped relative link. // Optional. Default to `directoryListHTMLTemplate` constant below. DirectoryListTemplate string } @@ -144,9 +145,9 @@ const directoryListHTMLTemplate = `
  • {{ if .Dir }} {{ $name := print .Name "/" }} - {{ $name }} + {{ $name }} {{ else }} - {{ .Name }} + {{ .Name }} {{ .Size }} {{ end }}
  • @@ -371,11 +372,16 @@ func listDir(t *template.Template, pathInFs string, filesystem fs.FS, res http.R size = info.Size() } + link := &url.URL{Path: f.Name()} + if f.IsDir() { + link.Path += "/" + } data.Files = append(data.Files, struct { Name string + URL string Dir bool Size string - }{f.Name(), f.IsDir(), format(size)}) + }{f.Name(), link.String(), f.IsDir(), format(size)}) } return t.Execute(res, data) diff --git a/middleware/static_test.go b/middleware/static_test.go index 5db1be2c7..a97e73df1 100644 --- a/middleware/static_test.go +++ b/middleware/static_test.go @@ -4,6 +4,7 @@ package middleware import ( + "html" "io/fs" "net/http" "net/http/httptest" @@ -108,6 +109,54 @@ func TestStaticHTML5PreservesMatchedHandlerNotFound(t *testing.T) { } } +func TestStaticBrowseEscapesNames(t *testing.T) { + for _, tc := range []struct { + name string + dir bool + }{ + {name: "plain.txt"}, + {name: "hash#file.txt"}, + {name: "query?.txt"}, + {name: "percent%.txt"}, + {name: "with space.txt"}, + {name: "colon:name.txt"}, + {name: "amp&file.txt"}, + {name: "hash#dir", dir: true}, + } { + t.Run(tc.name, func(t *testing.T) { + fileName := tc.name + if tc.dir { + fileName += "/index.html" + } + e := echo.New() + e.Use(StaticWithConfig(StaticConfig{ + Filesystem: fstest.MapFS{fileName: {Data: []byte("file contents")}}, + Browse: true, + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + _, rest, found := strings.Cut(rec.Body.String(), `href="`) + require.True(t, found) + href, _, found := strings.Cut(rest, `"`) + require.True(t, found) + target, err := req.URL.Parse(html.UnescapeString(href)) + require.NoError(t, err) + // Browsers do not send URL fragments to the server. + target.Fragment = "" + target.RawFragment = "" + + rec = httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target.String(), nil)) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "file contents", rec.Body.String()) + }) + } +} + func TestStatic(t *testing.T) { var testCases = []struct { name string