github.com/labstack/echo/v4
Advanced tools
| // SPDX-License-Identifier: MIT | ||
| // SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors | ||
| package echo | ||
| import ( | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
| "testing/fstest" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
| // Regression for GHSA-vfp3-v2gw-7wfq (v4 backport): an encoded path separator (%2F or %5C) | ||
| // must not let a static file request resolve across a separator and bypass route-level middleware. | ||
| func TestStaticDirectoryHandler_EncodedSeparatorDoesNotBypassRoute(t *testing.T) { | ||
| fsys := fstest.MapFS{ | ||
| "admin/secret.txt": {Data: []byte("TOP-SECRET")}, | ||
| "index.html": {Data: []byte("public")}, | ||
| } | ||
| e := New() | ||
| g := e.Group("/admin", func(next HandlerFunc) HandlerFunc { | ||
| return func(c Context) error { return c.String(http.StatusForbidden, "denied") } | ||
| }) | ||
| g.GET("/*", func(c Context) error { return c.String(http.StatusOK, "reached-protected-handler") }) | ||
| e.StaticFS("/", fsys) | ||
| cases := []struct { | ||
| target string | ||
| wantCode int | ||
| wantBody string | ||
| }{ | ||
| {"/admin/secret.txt", http.StatusForbidden, "denied"}, // protected route fires | ||
| {"/admin%2Fsecret.txt", http.StatusNotFound, ""}, // encoded slash rejected, no disclosure | ||
| {"/admin%2fsecret.txt", http.StatusNotFound, ""}, // lower-case hex variant | ||
| {"/admin%5Csecret.txt", http.StatusNotFound, ""}, // encoded backslash (Windows separator) neutralized by path.Clean | ||
| {"/admin%252Fsecret.txt", http.StatusNotFound, ""}, // double-encoded: single unescape -> literal filename, not a separator | ||
| {"/index.html", http.StatusOK, "public"}, // legitimate static file still served | ||
| } | ||
| for _, tc := range cases { | ||
| req := httptest.NewRequest(http.MethodGet, tc.target, nil) | ||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
| assert.Equal(t, tc.wantCode, rec.Code, "GET %s", tc.target) | ||
| if tc.wantBody != "" { | ||
| assert.Equal(t, tc.wantBody, rec.Body.String(), "GET %s", tc.target) | ||
| } | ||
| assert.NotContains(t, rec.Body.String(), "TOP-SECRET", "GET %s leaked protected file", tc.target) | ||
| } | ||
| } | ||
| // A Group-mounted StaticFS shares StaticDirectoryHandler, so it must reject the | ||
| // same encoded separators when served under a non-root prefix. | ||
| func TestGroupStaticFS_EncodedSeparatorDoesNotBypassRoute(t *testing.T) { | ||
| fsys := fstest.MapFS{ | ||
| "admin/secret.txt": {Data: []byte("TOP-SECRET")}, | ||
| "index.html": {Data: []byte("public")}, | ||
| } | ||
| e := New() | ||
| g := e.Group("/files") | ||
| g.StaticFS("/", fsys) | ||
| cases := []struct { | ||
| target string | ||
| wantCode int | ||
| }{ | ||
| {"/files/index.html", http.StatusOK}, | ||
| {"/files/admin%2Fsecret.txt", http.StatusNotFound}, | ||
| {"/files/admin%5Csecret.txt", http.StatusNotFound}, | ||
| } | ||
| for _, tc := range cases { | ||
| req := httptest.NewRequest(http.MethodGet, tc.target, nil) | ||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
| assert.Equal(t, tc.wantCode, rec.Code, "GET %s", tc.target) | ||
| assert.NotContains(t, rec.Body.String(), "TOP-SECRET", "GET %s leaked protected file", tc.target) | ||
| } | ||
| } |
| // SPDX-License-Identifier: MIT | ||
| // SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors | ||
| package pathutil | ||
| import ( | ||
| "testing" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
| func TestHasEncodedPathSeparator(t *testing.T) { | ||
| for s, want := range map[string]bool{ | ||
| "foo/bar.txt": false, | ||
| "100%25.txt": false, // encoded percent, not a separator | ||
| "a%2Fb": true, | ||
| "a%2fb": true, | ||
| "a%5Cb": true, | ||
| "a%5cb": true, | ||
| "trailing%2F": true, | ||
| "%2F": true, | ||
| "%2": false, // truncated, not a full sequence | ||
| "": false, | ||
| } { | ||
| assert.Equal(t, want, HasEncodedPathSeparator(s), "input=%q", s) | ||
| } | ||
| } |
| // SPDX-License-Identifier: MIT | ||
| // SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors | ||
| // Package pathutil holds internal helpers for safely handling request and file | ||
| // paths. It is internal so it can be shared between the echo package and the | ||
| // middleware package without becoming part of the public API. | ||
| package pathutil | ||
| // HasEncodedPathSeparator reports whether s contains a percent-encoded path | ||
| // separator, case-insensitively: %2F/%2f (forward slash) or %5C/%5c (backslash). | ||
| // Backslash is included as defense-in-depth against Windows-style separators even | ||
| // though fs.FS itself only uses forward slashes. | ||
| // | ||
| // Such sequences let an attacker smuggle a separator past the router, which | ||
| // matches on the raw encoded path, so they must be rejected before unescaping | ||
| // when resolving static files. | ||
| func HasEncodedPathSeparator(s string) bool { | ||
| for i := 0; i+2 < len(s); i++ { | ||
| if s[i] != '%' { | ||
| continue | ||
| } | ||
| switch { | ||
| case s[i+1] == '2' && (s[i+2] == 'f' || s[i+2] == 'F'): // %2F | ||
| return true | ||
| case s[i+1] == '5' && (s[i+2] == 'c' || s[i+2] == 'C'): // %5C | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } |
| // SPDX-License-Identifier: MIT | ||
| // SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors | ||
| package middleware | ||
| import ( | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| "github.com/labstack/echo/v4" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
| // Regression for GHSA-vfp3-v2gw-7wfq (v4 backport): the static middleware mounted on a | ||
| // group must not let an encoded separator in the wildcard bypass route-level middleware | ||
| // and disclose a file the matched route never authorized. | ||
| func TestStatic_EncodedSeparatorDoesNotBypassRoute(t *testing.T) { | ||
| root := t.TempDir() | ||
| assert.NoError(t, os.MkdirAll(filepath.Join(root, "admin"), 0o755)) | ||
| assert.NoError(t, os.WriteFile(filepath.Join(root, "admin", "secret.txt"), []byte("TOP-SECRET"), 0o644)) | ||
| assert.NoError(t, os.WriteFile(filepath.Join(root, "index.html"), []byte("public"), 0o644)) | ||
| e := echo.New() | ||
| g := e.Group("/files", StaticWithConfig(StaticConfig{Root: root})) | ||
| g.GET("/*", func(c echo.Context) error { return echo.ErrNotFound }) | ||
| cases := []struct { | ||
| target string | ||
| wantCode int | ||
| }{ | ||
| {"/files/index.html", http.StatusOK}, | ||
| {"/files/admin%2Fsecret.txt", http.StatusNotFound}, | ||
| {"/files/admin%2fsecret.txt", http.StatusNotFound}, | ||
| } | ||
| for _, tc := range cases { | ||
| req := httptest.NewRequest(http.MethodGet, tc.target, nil) | ||
| rec := httptest.NewRecorder() | ||
| e.ServeHTTP(rec, req) | ||
| assert.Equal(t, tc.wantCode, rec.Code, "GET %s", tc.target) | ||
| assert.NotContains(t, rec.Body.String(), "TOP-SECRET", "GET %s leaked protected file", tc.target) | ||
| } | ||
| } |
+9
-0
| # Changelog | ||
| ## v4.15.3 - 2026-06-14 | ||
| **Security** | ||
| * fix(static): reject encoded path separators that bypass route-level middleware by @vishr in https://github.com/labstack/echo/pull/3011 | ||
| Fixes [GHSA-vfp3-v2gw-7wfq](https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq): an encoded path separator (`%2F` or `%5C`) in a static file URL could bypass route-level middleware (e.g. authentication on a sibling route) and disclose static files. Both `StaticDirectoryHandler` (used by `Static`/`StaticFS`) and the `Static` middleware are affected. Backport of the v5 fix (#3009). Thanks to @a-tt-om and @oran-gugu for reporting. | ||
| ## v4.15.2 - 2026-05-01 | ||
@@ -4,0 +13,0 @@ |
+7
-4
@@ -143,9 +143,12 @@ // SPDX-License-Identifier: MIT | ||
| { | ||
| name: "open redirect vulnerability", | ||
| // An encoded slash (%2f) is rejected outright (GHSA-vfp3-v2gw-7wfq): the router matches | ||
| // on the raw path so %2f is not a separator, and unescaping it here would let it act as | ||
| // one. No redirect is emitted, closing the open-redirect vector. | ||
| name: "encoded slash is rejected, not redirected", | ||
| givenPrefix: "/", | ||
| givenFs: os.DirFS("_fixture/"), | ||
| whenURL: "/open.redirect.hackercom%2f..", | ||
| expectStatus: http.StatusMovedPermanently, | ||
| expectHeaderLocation: "/open.redirect.hackercom/../", // location starting with `//open` would be very bad | ||
| expectBodyStartsWith: "", | ||
| expectStatus: http.StatusNotFound, | ||
| expectHeaderLocation: "", | ||
| expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", | ||
| }, | ||
@@ -152,0 +155,0 @@ } |
+16
-2
@@ -12,4 +12,7 @@ // SPDX-License-Identifier: MIT | ||
| "os" | ||
| "path" | ||
| "path/filepath" | ||
| "strings" | ||
| "github.com/labstack/echo/v4/internal/pathutil" | ||
| ) | ||
@@ -62,2 +65,10 @@ | ||
| if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice | ||
| // The router matches routes against the raw, still-encoded request path, so an | ||
| // encoded path separator (%2F or %5C) is not treated as a segment boundary during | ||
| // routing. Unescaping it here would let it act as a separator and resolve a file | ||
| // outside the path the router authorized, bypassing route-level middleware (e.g. auth | ||
| // on a sibling route). No real filename contains a separator, so reject it as not found. | ||
| if pathutil.HasEncodedPathSeparator(p) { | ||
| return ErrNotFound | ||
| } | ||
| tmpPath, err := url.PathUnescape(p) | ||
@@ -70,4 +81,7 @@ if err != nil { | ||
| // fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid | ||
| name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/"))) | ||
| // fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid. | ||
| // Use path.Clean (not filepath.Clean): fs.FS paths are always forward-slash, so a backslash must stay a literal | ||
| // character rather than being interpreted as a separator on Windows (which would resolve a file across a boundary | ||
| // the router never matched on). | ||
| name := path.Clean(strings.TrimPrefix(p, "/")) | ||
| fi, err := fs.Stat(fileSystem, name) | ||
@@ -74,0 +88,0 @@ if err != nil { |
+1
-1
@@ -270,3 +270,3 @@ // SPDX-License-Identifier: MIT | ||
| // Version of Echo | ||
| Version = "4.15.2" | ||
| Version = "4.15.3" | ||
| website = "https://echo.labstack.com" | ||
@@ -273,0 +273,0 @@ // http://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Echo |
@@ -259,9 +259,11 @@ // SPDX-License-Identifier: MIT | ||
| { | ||
| name: "Directory redirect", | ||
| // %2f (encoded slash) is now rejected before unescaping (GHSA-vfp3-v2gw-7wfq), so it no | ||
| // longer resolves a directory and emits a redirect — it is a 404 with no Location header. | ||
| name: "Encoded slash is rejected, not redirected", | ||
| givenPrefix: "/", | ||
| givenRoot: "../_fixture", | ||
| whenURL: "/group/folder%2f..", | ||
| expectStatus: http.StatusMovedPermanently, | ||
| expectHeaderLocation: "/group/folder/../", | ||
| expectBodyStartsWith: "", | ||
| expectStatus: http.StatusNotFound, | ||
| expectHeaderLocation: "", | ||
| expectBodyStartsWith: "{\"message\":\"Not Found\"}\n", | ||
| }, | ||
@@ -268,0 +270,0 @@ { |
@@ -17,2 +17,3 @@ // SPDX-License-Identifier: MIT | ||
| "github.com/labstack/echo/v4" | ||
| "github.com/labstack/echo/v4/internal/pathutil" | ||
| "github.com/labstack/gommon/bytes" | ||
@@ -174,2 +175,9 @@ ) | ||
| } | ||
| // The router matched on the raw, still-encoded path, so an encoded path separator in | ||
| // the wildcard would only now become a real separator and resolve a file the matched | ||
| // route never authorized, bypassing route-level middleware. Reject it before unescaping | ||
| // (see echo.StaticDirectoryHandler). | ||
| if pathutil.HasEncodedPathSeparator(p) { | ||
| return echo.ErrNotFound | ||
| } | ||
| p, err = url.PathUnescape(p) | ||
@@ -176,0 +184,0 @@ if err != nil { |