From 88d975a83de6a8cdf233512cfcf7c9295c91c778 Mon Sep 17 00:00:00 2001 From: toim Date: Fri, 6 Feb 2026 12:27:18 +0200 Subject: [PATCH 1/3] Fix directory traversal vulnerability under Windows in Static middleware when default Echo filesytem is used (effectively `middleware.StaticConfig{Filesystem: nil}`) --- CHANGELOG.md | 7 + _fixture/dist/public/test.txt | 1 + context.go | 2 + echo.go | 5 +- group_test.go | 34 +- middleware/static_test.go | 314 +++++------------- middleware/testdata/dist/private.txt | 1 + .../testdata/dist/public/assets/readme.md | 1 + .../dist/public/assets/subfolder/subfolder.md | 1 + middleware/testdata/dist/public/index.html | 1 + middleware/testdata/dist/public/test.txt | 1 + middleware/testdata/private.txt | 1 + version.go | 2 +- 13 files changed, 135 insertions(+), 236 deletions(-) create mode 100644 _fixture/dist/public/test.txt create mode 100644 middleware/testdata/dist/private.txt create mode 100644 middleware/testdata/dist/public/assets/readme.md create mode 100644 middleware/testdata/dist/public/assets/subfolder/subfolder.md create mode 100644 middleware/testdata/dist/public/index.html create mode 100644 middleware/testdata/dist/public/test.txt create mode 100644 middleware/testdata/private.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7cc4afd..723e0b449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v5.0.3 - 2026-02-06 + +**Security** + +* Fix directory traversal vulnerability under Windows in Static middleware when default Echo filesytem is used (effectively `middleware.StaticConfig{Filesystem: nil}`). + + ## v5.0.2 - 2026-02-02 **Security** diff --git a/_fixture/dist/public/test.txt b/_fixture/dist/public/test.txt new file mode 100644 index 000000000..dd937160d --- /dev/null +++ b/_fixture/dist/public/test.txt @@ -0,0 +1 @@ +test.txt contents diff --git a/context.go b/context.go index 3511cf7ac..f91ea7a60 100644 --- a/context.go +++ b/context.go @@ -15,6 +15,7 @@ import ( "net" "net/http" "net/url" + "path" "path/filepath" "strings" "sync" @@ -579,6 +580,7 @@ func (c *Context) FileFS(file string, filesystem fs.FS) error { } func fsFile(c *Context, file string, filesystem fs.FS) error { + file = path.Clean(file) // `os.Open` and `os.DirFs.Open()` behave differently, later does not like ``, `.`, `..` at all, but we allowed those now need to clean f, err := filesystem.Open(file) if err != nil { return ErrNotFound diff --git a/echo.go b/echo.go index 4e3899516..4855e8429 100644 --- a/echo.go +++ b/echo.go @@ -785,14 +785,11 @@ func newDefaultFS() *defaultFS { dir, _ := os.Getwd() return &defaultFS{ prefix: dir, - fs: nil, + fs: os.DirFS(dir), } } func (fs defaultFS) Open(name string) (fs.File, error) { - if fs.fs == nil { - return os.Open(name) // #nosec G304 - } return fs.fs.Open(name) } diff --git a/group_test.go b/group_test.go index 819b6df97..7078b6497 100644 --- a/group_test.go +++ b/group_test.go @@ -467,13 +467,14 @@ func TestGroup_Static(t *testing.T) { func TestGroup_StaticMultiTest(t *testing.T) { var testCases = []struct { - name string - givenPrefix string - givenRoot string - whenURL string - expectHeaderLocation string - expectBodyStartsWith string - expectStatus int + name string + givenPrefix string + givenRoot string + whenURL string + expectHeaderLocation string + expectBodyStartsWith string + expectBodyNotContains string + expectStatus int }{ { name: "ok", @@ -582,6 +583,22 @@ func TestGroup_StaticMultiTest(t *testing.T) { expectStatus: http.StatusOK, expectBodyStartsWith: "", }, + { + name: "nok, URL encoded path traversal (single encoding, slash - unix separator)", + givenRoot: "_fixture/dist/public", + whenURL: "/%2e%2e%2fprivate.txt", + expectStatus: http.StatusNotFound, + expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", + expectBodyNotContains: `private file`, + }, + { + name: "nok, URL encoded path traversal (single encoding, backslash - windows separator)", + givenRoot: "_fixture/dist/public", + whenURL: "/%2e%2e%5cprivate.txt", + expectStatus: http.StatusNotFound, + expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", + expectBodyNotContains: `private file`, + }, { name: "do not allow directory traversal (backslash - windows separator)", givenPrefix: "/", @@ -618,6 +635,9 @@ func TestGroup_StaticMultiTest(t *testing.T) { } else { assert.Equal(t, "", body) } + if tc.expectBodyNotContains != "" { + assert.NotContains(t, body, tc.expectBodyNotContains) + } if tc.expectHeaderLocation != "" { assert.Equal(t, tc.expectHeaderLocation, rec.Result().Header["Location"][0]) diff --git a/middleware/static_test.go b/middleware/static_test.go index d0f323531..2eff580e1 100644 --- a/middleware/static_test.go +++ b/middleware/static_test.go @@ -8,7 +8,6 @@ import ( "net/http" "net/http/httptest" "os" - "strings" "testing" "testing/fstest" @@ -21,9 +20,7 @@ func TestStatic_useCaseForApiAndSPAs(t *testing.T) { // serve single page application (SPA) files from server root e.Use(StaticWithConfig(StaticConfig{ - Root: ".", - // by default Echo filesystem is fixed to `./` but this does not allow `../` (moving up in folder structure past filesystem root) - Filesystem: os.DirFS("../_fixture"), + Root: "testdata/dist/public", })) // all requests to `/api/*` will end up in echo handlers (assuming there is not `api` folder and files) @@ -43,7 +40,7 @@ func TestStatic_useCaseForApiAndSPAs(t *testing.T) { rec = httptest.NewRecorder() e.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - assert.Contains(t, rec.Body.String(), "Echo") + assert.Contains(t, rec.Body.String(), "

Hello from index

\n") } @@ -54,6 +51,7 @@ func TestStatic(t *testing.T) { givenAttachedToGroup string whenURL string expectContains string + expectNotContains string expectLength string expectCode int }{ @@ -61,55 +59,56 @@ func TestStatic(t *testing.T) { name: "ok, serve index with Echo message", whenURL: "/", expectCode: http.StatusOK, - expectContains: "Echo", + expectContains: "

Hello from index

", }, { - name: "ok, serve file from subdirectory", - whenURL: "/images/walle.png", - expectCode: http.StatusOK, - expectLength: "219885", + name: "ok, serve file from subdirectory", + whenURL: "/assets/readme.md", + expectCode: http.StatusOK, + expectLength: "54", + expectContains: "This directory is used for the static middleware test", }, { name: "ok, when html5 mode serve index for any static file that does not exist", givenConfig: &StaticConfig{ - Root: "_fixture", + Root: "testdata/dist/public", HTML5: true, }, whenURL: "/random", expectCode: http.StatusOK, - expectContains: "Echo", + expectContains: "

Hello from index

", }, { name: "ok, serve index as directory index listing files directory", givenConfig: &StaticConfig{ - Root: "_fixture/certs", + Root: "testdata/dist/public/assets", Browse: true, }, whenURL: "/", expectCode: http.StatusOK, - expectContains: "cert.pem", + expectContains: `readme.md`, }, { name: "ok, serve directory index with IgnoreBase and browse", givenConfig: &StaticConfig{ - Root: "_fixture/_fixture/", // <-- last `_fixture/` is overlapping with group path and needs to be ignored + Root: "testdata/dist/public/assets/", // <-- last `assets/` is overlapping with group path and needs to be ignored IgnoreBase: true, Browse: true, }, - givenAttachedToGroup: "/_fixture", - whenURL: "/_fixture/", + givenAttachedToGroup: "/assets", + whenURL: "/assets/", expectCode: http.StatusOK, - expectContains: `README.md`, + expectContains: `readme.md`, }, { name: "ok, serve file with IgnoreBase", givenConfig: &StaticConfig{ - Root: "_fixture/_fixture/", // <-- last `_fixture/` is overlapping with group path and needs to be ignored + Root: "testdata/dist/public/assets", // <-- last `assets/` is overlapping with group path and needs to be ignored IgnoreBase: true, Browse: true, }, - givenAttachedToGroup: "/_fixture", - whenURL: "/_fixture/README.md", + givenAttachedToGroup: "/assets", + whenURL: "/assets/readme.md", expectCode: http.StatusOK, expectContains: "This directory is used for the static middleware test", }, @@ -119,18 +118,6 @@ func TestStatic(t *testing.T) { expectCode: http.StatusNotFound, expectContains: "{\"message\":\"Not Found\"}\n", }, - { - name: "nok, do not allow directory traversal (backslash - windows separator)", - whenURL: `/..\\middleware/basic_auth.go`, - expectCode: http.StatusNotFound, - expectContains: "{\"message\":\"Not Found\"}\n", - }, - { - name: "nok,do not allow directory traversal (slash - unix separator)", - whenURL: `/../middleware/basic_auth.go`, - expectCode: http.StatusNotFound, - expectContains: "{\"message\":\"Not Found\"}\n", - }, { name: "ok, when no file then a handler will care of the request", whenURL: "/regular-handler", @@ -140,7 +127,7 @@ func TestStatic(t *testing.T) { { name: "ok, skip middleware and serve handler", givenConfig: &StaticConfig{ - Root: "_fixture/images/", + Root: "testdata/dist/public", Skipper: func(c *echo.Context) bool { return true }, @@ -152,9 +139,9 @@ func TestStatic(t *testing.T) { { name: "nok, when html5 fail if the index file does not exist", givenConfig: &StaticConfig{ - Root: "_fixture", + Root: "testdata/dist/public", HTML5: true, - Index: "missing.html", + Index: "missing.html", // that folder contains `index.html` }, whenURL: "/random", expectCode: http.StatusInternalServerError, @@ -162,36 +149,68 @@ func TestStatic(t *testing.T) { { name: "ok, serve from http.FileSystem", givenConfig: &StaticConfig{ - Root: "_fixture", - Filesystem: os.DirFS(".."), + Root: "public", + Filesystem: os.DirFS("testdata/dist"), }, whenURL: "/", expectCode: http.StatusOK, - expectContains: "Echo", + expectContains: "

Hello from index

", }, { - name: "nok, URL encoded path traversal (single encoding)", - whenURL: "/%2e%2e%2fmiddleware/basic_auth.go", - expectCode: http.StatusNotFound, - expectContains: "{\"message\":\"Not Found\"}\n", + name: "nok, do not allow directory traversal (backslash - windows separator)", + whenURL: `/..\\private.txt`, + expectCode: http.StatusNotFound, + expectContains: "{\"message\":\"Not Found\"}\n", + expectNotContains: `private file`, }, { - name: "nok, URL encoded path traversal (double encoding)", - whenURL: "/%252e%252e%252fmiddleware/basic_auth.go", - expectCode: http.StatusNotFound, - expectContains: "{\"message\":\"Not Found\"}\n", + name: "nok,do not allow directory traversal (slash - unix separator)", + whenURL: `/../private.txt`, + expectCode: http.StatusNotFound, + expectContains: "{\"message\":\"Not Found\"}\n", + expectNotContains: `private file`, }, { - name: "nok, URL encoded path traversal (mixed encoding)", - whenURL: "/%2e%2e/middleware/basic_auth.go", - expectCode: http.StatusNotFound, - expectContains: "{\"message\":\"Not Found\"}\n", + name: "nok, URL encoded path traversal (single encoding, slash - unix separator)", + whenURL: "/%2e%2e%2fprivate.txt", + expectCode: http.StatusNotFound, + expectContains: "{\"message\":\"Not Found\"}\n", + expectNotContains: `private file`, }, { - name: "nok, backslash URL encoded", - whenURL: "/..%5c..%5cmiddleware/basic_auth.go", - expectCode: http.StatusNotFound, - expectContains: "{\"message\":\"Not Found\"}\n", + name: "nok, URL encoded path traversal (single encoding, backslash - windows separator)", + whenURL: "/%2e%2e%5cprivate.txt", + expectCode: http.StatusNotFound, + expectContains: "{\"message\":\"Not Found\"}\n", + expectNotContains: `private file`, + }, + { + name: "nok, URL encoded path traversal (double encoding, slash - unix separator)", + whenURL: "/%252e%252e%252fprivate.txt", + expectCode: http.StatusNotFound, + expectContains: "{\"message\":\"Not Found\"}\n", + expectNotContains: `private file`, + }, + { + name: "nok, URL encoded path traversal (double encoding, backslash - windows separator)", + whenURL: "/%252e%252e%255cprivate.txt", + expectCode: http.StatusNotFound, + expectContains: "{\"message\":\"Not Found\"}\n", + expectNotContains: `private file`, + }, + { + name: "nok, URL encoded path traversal (mixed encoding)", + whenURL: "/%2e%2e/private.txt", + expectCode: http.StatusNotFound, + expectContains: "{\"message\":\"Not Found\"}\n", + expectNotContains: `private file`, + }, + { + name: "nok, backslash URL encoded", + whenURL: "/..%5c..%5cprivate.txt", + expectCode: http.StatusNotFound, + expectContains: "{\"message\":\"Not Found\"}\n", + expectNotContains: `private file`, }, //{ // Under windows, %00 gets cleaned out by `http.ReadRequest` making this test to fail with different code // name: "nok, null byte injection", @@ -200,25 +219,26 @@ func TestStatic(t *testing.T) { // expectContains: "{\"message\":\"Internal Server Error\"}\n", //}, { - name: "nok, mixed backslash and forward slash traversal", - whenURL: "/..\\../middleware/basic_auth.go", - expectCode: http.StatusNotFound, - expectContains: "{\"message\":\"Not Found\"}\n", + name: "nok, mixed backslash and forward slash traversal", + whenURL: "/..\\../private.txt", + expectCode: http.StatusNotFound, + expectContains: "{\"message\":\"Not Found\"}\n", + expectNotContains: `private file`, }, { - name: "nok, trailing dots (Windows edge case)", - whenURL: "/../middleware/basic_auth.go...", - expectCode: http.StatusNotFound, - expectContains: "{\"message\":\"Not Found\"}\n", + name: "nok, trailing dots (Windows edge case)", + whenURL: "/../private.txt...", + expectCode: http.StatusNotFound, + expectContains: "{\"message\":\"Not Found\"}\n", + expectNotContains: `private file`, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { e := echo.New() - e.Filesystem = os.DirFS("../") - config := StaticConfig{Root: "_fixture"} + config := StaticConfig{Root: "testdata/dist/public"} if tc.givenConfig != nil { config = *tc.givenConfig } @@ -247,169 +267,15 @@ func TestStatic(t *testing.T) { e.ServeHTTP(rec, req) assert.Equal(t, tc.expectCode, rec.Code) + responseBody := rec.Body.String() if tc.expectContains != "" { - responseBody := rec.Body.String() assert.Contains(t, responseBody, tc.expectContains) } - if tc.expectLength != "" { - assert.Equal(t, rec.Header().Get(echo.HeaderContentLength), tc.expectLength) - } - }) - } -} - -func TestStatic_GroupWithStatic(t *testing.T) { - var testCases = []struct { - name string - givenGroup string - givenPrefix string - givenRoot string - whenURL string - expectStatus int - expectHeaderLocation string - expectBodyStartsWith string - }{ - { - name: "ok", - givenPrefix: "/images", - givenRoot: "_fixture/images", - whenURL: "/group/images/walle.png", - expectStatus: http.StatusOK, - expectBodyStartsWith: string([]byte{0x89, 0x50, 0x4e, 0x47}), - }, - { - name: "No file", - givenPrefix: "/images", - givenRoot: "_fixture/scripts", - whenURL: "/group/images/bolt.png", - expectStatus: http.StatusNotFound, - expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", - }, - { - name: "Directory not found (no trailing slash)", - givenPrefix: "/images", - givenRoot: "_fixture/images", - whenURL: "/group/images/", - expectStatus: http.StatusNotFound, - expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", - }, - { - name: "Directory redirect", - givenPrefix: "/", - givenRoot: "_fixture", - whenURL: "/group/folder", - expectStatus: http.StatusMovedPermanently, - expectHeaderLocation: "/group/folder/", - expectBodyStartsWith: "", - }, - { - name: "Directory redirect", - givenPrefix: "/", - givenRoot: "_fixture", - whenURL: "/group/folder%2f..", - expectStatus: http.StatusMovedPermanently, - expectHeaderLocation: "/group/folder/../", - expectBodyStartsWith: "", - }, - { - name: "Prefixed directory 404 (request URL without slash)", - givenGroup: "_fixture", - givenPrefix: "/folder/", // trailing slash will intentionally not match "/folder" - givenRoot: "_fixture", - whenURL: "/_fixture/folder", // no trailing slash - expectStatus: http.StatusNotFound, - expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", - }, - { - name: "Prefixed directory redirect (without slash redirect to slash)", - givenGroup: "_fixture", - givenPrefix: "/folder", // no trailing slash shall match /folder and /folder/* - givenRoot: "_fixture", - whenURL: "/_fixture/folder", // no trailing slash - expectStatus: http.StatusMovedPermanently, - expectHeaderLocation: "/_fixture/folder/", - expectBodyStartsWith: "", - }, - { - name: "Directory with index.html", - givenPrefix: "/", - givenRoot: "_fixture", - whenURL: "/group/", - expectStatus: http.StatusOK, - expectBodyStartsWith: "", - }, - { - name: "Prefixed directory with index.html (prefix ending with slash)", - givenPrefix: "/assets/", - givenRoot: "_fixture", - whenURL: "/group/assets/", - expectStatus: http.StatusOK, - expectBodyStartsWith: "", - }, - { - name: "Prefixed directory with index.html (prefix ending without slash)", - givenPrefix: "/assets", - givenRoot: "_fixture", - whenURL: "/group/assets/", - expectStatus: http.StatusOK, - expectBodyStartsWith: "", - }, - { - name: "Sub-directory with index.html", - givenPrefix: "/", - givenRoot: "_fixture", - whenURL: "/group/folder/", - expectStatus: http.StatusOK, - expectBodyStartsWith: "", - }, - { - name: "do not allow directory traversal (backslash - windows separator)", - givenPrefix: "/", - givenRoot: "_fixture/", - whenURL: `/group/..\\middleware/basic_auth.go`, - expectStatus: http.StatusNotFound, - expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", - }, - { - name: "do not allow directory traversal (slash - unix separator)", - givenPrefix: "/", - givenRoot: "_fixture/", - whenURL: `/group/../middleware/basic_auth.go`, - expectStatus: http.StatusNotFound, - expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - e := echo.New() - e.Filesystem = os.DirFS("../") // so we can access test files - - group := "/group" - if tc.givenGroup != "" { - group = tc.givenGroup - } - g := e.Group(group) - g.Static(tc.givenPrefix, tc.givenRoot) - - req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil) - rec := httptest.NewRecorder() - - e.ServeHTTP(rec, req) - - assert.Equal(t, tc.expectStatus, rec.Code) - body := rec.Body.String() - if tc.expectBodyStartsWith != "" { - assert.True(t, strings.HasPrefix(body, tc.expectBodyStartsWith)) - } else { - assert.Equal(t, "", body) + if tc.expectNotContains != "" { + assert.NotContains(t, responseBody, tc.expectNotContains) } - - if tc.expectHeaderLocation != "" { - assert.Equal(t, tc.expectHeaderLocation, rec.Header().Get(echo.HeaderLocation)) - } else { - _, ok := rec.Result().Header[echo.HeaderLocation] - assert.False(t, ok) + if tc.expectLength != "" { + assert.Equal(t, tc.expectLength, rec.Header().Get(echo.HeaderContentLength)) } }) } diff --git a/middleware/testdata/dist/private.txt b/middleware/testdata/dist/private.txt new file mode 100644 index 000000000..0f9d2435b --- /dev/null +++ b/middleware/testdata/dist/private.txt @@ -0,0 +1 @@ +private file diff --git a/middleware/testdata/dist/public/assets/readme.md b/middleware/testdata/dist/public/assets/readme.md new file mode 100644 index 000000000..1d5f82787 --- /dev/null +++ b/middleware/testdata/dist/public/assets/readme.md @@ -0,0 +1 @@ +This directory is used for the static middleware test diff --git a/middleware/testdata/dist/public/assets/subfolder/subfolder.md b/middleware/testdata/dist/public/assets/subfolder/subfolder.md new file mode 100644 index 000000000..74c928b2f --- /dev/null +++ b/middleware/testdata/dist/public/assets/subfolder/subfolder.md @@ -0,0 +1 @@ +file inside subfolder diff --git a/middleware/testdata/dist/public/index.html b/middleware/testdata/dist/public/index.html new file mode 100644 index 000000000..df6d9015a --- /dev/null +++ b/middleware/testdata/dist/public/index.html @@ -0,0 +1 @@ +

Hello from index

diff --git a/middleware/testdata/dist/public/test.txt b/middleware/testdata/dist/public/test.txt new file mode 100644 index 000000000..dd937160d --- /dev/null +++ b/middleware/testdata/dist/public/test.txt @@ -0,0 +1 @@ +test.txt contents diff --git a/middleware/testdata/private.txt b/middleware/testdata/private.txt new file mode 100644 index 000000000..0f9d2435b --- /dev/null +++ b/middleware/testdata/private.txt @@ -0,0 +1 @@ +private file diff --git a/version.go b/version.go index d05562626..527adb394 100644 --- a/version.go +++ b/version.go @@ -5,5 +5,5 @@ package echo const ( // Version of Echo - Version = "5.0.2" + Version = "5.0.3" ) From 6c162596b43157c8a4d6c7d88a7db9f45be95ef2 Mon Sep 17 00:00:00 2001 From: toim Date: Fri, 6 Feb 2026 15:03:55 +0200 Subject: [PATCH 2/3] Fix directory traversal vulnerability under Windows in Static middleware when default Echo filesytem is used (effectively `middleware.StaticConfig{Filesystem: nil}`) --- CHANGELOG.md | 9 ++++++- middleware/static.go | 47 +++++++++++++++++++++++++++--------- middleware/static_other.go | 21 +++++++++++++--- middleware/static_test.go | 2 +- middleware/static_windows.go | 34 -------------------------- 5 files changed, 62 insertions(+), 51 deletions(-) delete mode 100644 middleware/static_windows.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 723e0b449..37d1adb66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,14 @@ **Security** -* Fix directory traversal vulnerability under Windows in Static middleware when default Echo filesytem is used (effectively `middleware.StaticConfig{Filesystem: nil}`). +* Fix directory traversal vulnerability under Windows in Static middleware when default Echo filesystem is used. Reported by @shblue21. + +This applies to cases when: +- Windows is used as OS +- `middleware.StaticConfig.Filesystem` is `nil` (default) +- `echo.Filesystem` is has not been set explicitly (default) + +Exposure is restricted to the active process working directory and its subfolders. ## v5.0.2 - 2026-02-02 diff --git a/middleware/static.go b/middleware/static.go index ee1c8bee9..452603325 100644 --- a/middleware/static.go +++ b/middleware/static.go @@ -15,6 +15,7 @@ import ( "path" "strconv" "strings" + "sync" "github.com/labstack/echo/v5" ) @@ -118,13 +119,12 @@ const directoryListHTMLTemplate = `