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