🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

github.com/labstack/echo/v4

Package Overview
Dependencies
Versions
509
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

github.com/labstack/echo/v4 - go Package Compare versions

Comparing version
v4.15.3
to
v4.15.4
+4
-2
.github/workflows/checks.yml

@@ -7,5 +7,7 @@ name: Run checks

- master
- v4
pull_request:
branches:
- master
- v4
workflow_dispatch:

@@ -25,6 +27,6 @@

- name: Checkout Code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Go ${{ matrix.go }}
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:

@@ -31,0 +33,0 @@ go-version: ${{ env.LATEST_GO_VERSION }}

@@ -7,5 +7,7 @@ name: Run Tests

- master
- v4
pull_request:
branches:
- master
- v4
workflow_dispatch:

@@ -27,4 +29,4 @@

# Echo tests with last four major releases (unless there are pressing vulnerabilities)
# As we depend on `golang.org/x/` libraries which only support last 2 Go releases we could have situations when
# we derive from last four major releases promise.
# As we depend on `golang.org/x/` libraries which only support the last 2 Go releases, we could have situations when
# we derive from the last four major releases promise.
go: ["1.25", "1.26"]

@@ -35,6 +37,6 @@ name: ${{ matrix.os }} @ Go ${{ matrix.go }}

- name: Checkout Code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Go ${{ matrix.go }}
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:

@@ -48,3 +50,3 @@ go-version: ${{ matrix.go }}

if: success() && matrix.go == env.LATEST_GO_VERSION && matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:

@@ -60,3 +62,3 @@ token:

- name: Checkout Code (Previous)
uses: actions/checkout@v5
uses: actions/checkout@v6
with:

@@ -67,3 +69,3 @@ ref: ${{ github.base_ref }}

- name: Checkout Code (New)
uses: actions/checkout@v5
uses: actions/checkout@v6
with:

@@ -73,3 +75,3 @@ path: new

- name: Set up Go ${{ matrix.go }}
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:

@@ -76,0 +78,0 @@ go-version: ${{ env.LATEST_GO_VERSION }}

# Changelog
## v4.15.4 - 2026-06-15
**Security**
Fixes [GHSA-vfp3-v2gw-7wfq](https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq)
Make serving static file releated methods and middleware not unescape path by default - so how the way Router interprets paths and Static methods/middleware is consistent.
Given following situation:
```go
// 0.
// given folder structure:
// private.txt
// public/
// public/index.html
// public/text.txt
// public/admin/private.txt
// 1. share `public/` folder contents from the server root. This folder actually contains subfolder `admin` which
// contents we want to forbid from downloading
e.Static("/", "public")
// 2. naively assume that everything under /admin folder is now forbidden
e.GET("/admin/*", func(c *Context) error {
return ErrForbidden
})
```
Then requests to `/admin%2fprivate.txt` would not be matched to `GET /admin/*` route (routing does not look unescaped path) and static file serving will use unescaped path to serve the file.
Note: this way of "guarding" subfolders will never work for for paths like `/assets/../admin%2fprivate.txt` which will `path.Clean("/assets/../admin%2fprivate.txt")` to `/admin/private.txt` and are servable if static file serving is configured to unescape paths.
If you want to guard routes - use middlewares on `Static*` methods and before `Static` middleware.
**Breaking change / migration:** If you serve files whose names contain URL-encoded characters (e.g., `/hello%20world.txt` → `hello world.txt`), you must now opt in:
```go
e := echo.New()
e.EnablePathUnescapingStaticFiles = true // <-- enable old behavior
e.Static("/", "public")
```
for static middleware
```go
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
EnablePathUnescaping: true, // <-- enable old behavior
}))
```
## v4.15.3 - 2026-06-14

@@ -4,0 +53,0 @@

@@ -18,14 +18,4 @@ // SPDX-License-Identifier: MIT

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 {
var testCases = []struct {
name string
target string

@@ -35,18 +25,61 @@ wantCode int

}{
{"/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
{
name: "protected route fires",
target: "/admin/secret.txt",
wantCode: http.StatusForbidden,
wantBody: "denied",
},
{
name: "encoded slash rejected, no disclosure",
target: "/admin%2Fsecret.txt",
wantCode: http.StatusNotFound,
wantBody: "",
},
{
name: "lower-case hex variant",
target: "/admin%2fsecret.txt",
wantCode: http.StatusNotFound,
wantBody: "",
},
{
name: "encoded backslash variant - Windows specific related",
target: "/admin%5Csecret.txt",
wantCode: http.StatusNotFound,
wantBody: "",
},
{
name: "double-encoded: single unescape -> literal filename, not a separator",
target: "/admin%252Fsecret.txt",
wantCode: http.StatusNotFound,
wantBody: "",
},
{
name: "legitimate static file still served",
target: "/index.html",
wantCode: http.StatusOK,
wantBody: "public",
},
}
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)
for _, tc := range testCases {
t.Run(tc.name, func(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)
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)
})
}

@@ -58,25 +91,40 @@ }

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 {
var testCases = []struct {
name string
target string
wantCode int
}{
{"/files/index.html", http.StatusOK},
{"/files/admin%2Fsecret.txt", http.StatusNotFound},
{"/files/admin%5Csecret.txt", http.StatusNotFound},
{
name: "ok",
target: "/files/index.html",
wantCode: http.StatusOK,
},
{
name: "nok, encoded slash",
target: "/files/admin%2Fsecret.txt",
wantCode: http.StatusNotFound,
},
{
name: "nok encoded backslash",
target: "/files/admin%5Csecret.txt",
wantCode: 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)
for _, tc := range testCases {
t.Run(tc.name, func(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)
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)
})
}
}

@@ -18,10 +18,11 @@ // SPDX-License-Identifier: MIT

var testCases = []struct {
name string
givenPrefix string
givenFs fs.FS
givenFsRoot string
whenURL string
expectStatus int
expectHeaderLocation string
expectBodyStartsWith string
name string
givenPrefix string
givenFs fs.FS
givenFsRoot string
givenEnablePathUnescapingStaticFiles bool
whenURL string
expectStatus int
expectHeaderLocation string
expectBodyStartsWith string
}{

@@ -144,6 +145,3 @@ {

{
// 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",
name: "do not unescape path variables by default",
givenPrefix: "/",

@@ -156,2 +154,20 @@ givenFs: os.DirFS("_fixture/"),

},
{
name: "do not accept encoded dots in path (%2E%2E is `..`) to traverse within filesystem boundary",
givenPrefix: "/",
givenFs: os.DirFS("_fixture/"),
givenEnablePathUnescapingStaticFiles: false,
whenURL: `/folder/%2E%2E/index.html`, // `/folder/../index.html`
expectStatus: http.StatusNotFound,
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
},
{
name: "allow encoded dots in path (%2E%2E is `..`) to traverse within filesystem when path unescaping is enabled",
givenPrefix: "/",
givenFs: os.DirFS("_fixture/"),
givenEnablePathUnescapingStaticFiles: true,
whenURL: `/folder/%2E%2E/index.html`, // `/folder/../index.html`
expectStatus: http.StatusOK,
expectBodyStartsWith: "<!doctype html>",
},
}

@@ -162,2 +178,3 @@

e := New()
e.EnablePathUnescapingStaticFiles = tc.givenEnablePathUnescapingStaticFiles

@@ -164,0 +181,0 @@ tmpFs := tc.givenFs

@@ -15,4 +15,2 @@ // SPDX-License-Identifier: MIT

"strings"
"github.com/labstack/echo/v4/internal/pathutil"
)

@@ -42,3 +40,3 @@

pathPrefix+"*",
StaticDirectoryHandler(subFs, false),
StaticDirectoryHandler(subFs, !e.EnablePathUnescapingStaticFiles),
)

@@ -56,8 +54,15 @@ }

pathPrefix+"*",
StaticDirectoryHandler(filesystem, false),
StaticDirectoryHandler(filesystem, !e.EnablePathUnescapingStaticFiles),
)
}
// StaticDirectoryHandler creates handler function to serve files from provided file system
// StaticDirectoryHandler creates handler function to serve files from provided file system.
// When disablePathUnescaping is set then file name from path is not unescaped and is served as is.
//
// Note: when disablePathUnescaping=false, the handler decodes the wildcard param before serving.
// If route guards (e.g. e.GET("/admin/*", forbidden)) are used to restrict parts of the
// filesystem, an encoded separator (%2F) or encoded dot-dot (%2E%2E) in the URL can resolve to
// a path that the router never matched against the guard route. Do not rely on route guards
// alone to restrict a filesystem served by this handler.
// See https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq
func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) HandlerFunc {

@@ -67,10 +72,2 @@ return func(c Context) error {

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)

@@ -77,0 +74,0 @@ if err != nil {

+14
-1

@@ -62,2 +62,3 @@ // SPDX-License-Identifier: MIT

"golang.org/x/net/http2"
//lint:ignore SA1019 h2c is required until v4 is supported (end of 2026)
"golang.org/x/net/http2/h2c"

@@ -109,2 +110,13 @@ )

HidePort bool
// EnablePathUnescapingStaticFiles enables path parameter (param: *) unescaping for Static/StaticFS methods.
// Default false (safe): encoded slashes (%2f) in the wildcard param are NOT decoded,
// preventing ACL bypass where /admin%2fprivate.txt bypasses a /admin/* route guard by
// not matching that route but having its wildcard param decoded to admin/private.txt.
// Set to true only when serving files whose names contain URL-encoded characters
// (e.g. "hello world.txt" via /hello%20world.txt) and you are not relying on
// route-based ACL guards to restrict access.
// If you are enabling this option, make sure you understand the security implications.
// See: https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq
EnablePathUnescapingStaticFiles bool
}

@@ -272,3 +284,3 @@

// Version of Echo
Version = "4.15.3"
Version = "4.15.4"
website = "https://echo.labstack.com"

@@ -851,2 +863,3 @@ // http://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Echo

s.ErrorLog = e.StdLogger
//lint:ignore SA1019 h2c is required until v4 is supported (end of 2026)
s.Handler = h2c.NewHandler(e, h2s)

@@ -853,0 +866,0 @@ if e.Debug {

+5
-5

@@ -9,4 +9,4 @@ module github.com/labstack/echo/v4

github.com/valyala/fasttemplate v1.2.2
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
golang.org/x/crypto v0.53.0
golang.org/x/net v0.56.0
golang.org/x/time v0.15.0

@@ -17,9 +17,9 @@ )

github.com/davecgh/go-spew v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+10
-10

@@ -5,4 +5,4 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=

github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=

@@ -18,10 +18,10 @@ github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=

github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=

@@ -28,0 +28,0 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=

@@ -26,3 +26,3 @@ // SPDX-License-Identifier: MIT

pathPrefix+"*",
StaticDirectoryHandler(filesystem, false),
StaticDirectoryHandler(filesystem, !g.echo.EnablePathUnescapingStaticFiles),
)

@@ -29,0 +29,0 @@ }

@@ -21,26 +21,61 @@ // SPDX-License-Identifier: MIT

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 {
var testCases = []struct {
name string
config StaticConfig
target string
wantCode int
}{
{"/files/index.html", http.StatusOK},
{"/files/admin%2Fsecret.txt", http.StatusNotFound},
{"/files/admin%2fsecret.txt", http.StatusNotFound},
{
name: "ok, legitimate file is served",
target: "/files/index.html",
wantCode: http.StatusOK,
},
{
// With EnablePathUnescaping=false (default/safe), the wildcard param "admin%2Fsecret.txt"
// is NOT decoded, so the FS lookup is for literal "admin%2Fsecret.txt" which does
// not exist → falls through → 404. ACL is not bypassed.
name: "ok, encoded slash returns 404 with default safe config (EnablePathUnescaping=false)",
target: "/files/admin%2Fsecret.txt",
wantCode: http.StatusNotFound,
},
{
name: "ok, lower-case encoded slash also returns 404",
target: "/files/admin%2fsecret.txt",
wantCode: http.StatusNotFound,
},
{
// With EnablePathUnescaping=true, the wildcard param "admin%2Fsecret.txt" IS decoded
// to "admin/secret.txt" before the FS lookup. The router already routed to /* so the
// ACL guard on /admin/* never ran. The file is served — ACL bypass.
// Only use EnablePathUnescaping: true when not relying on route-based ACL guards.
name: "nok, encoded slash bypasses ACL when EnablePathUnescaping=true",
config: StaticConfig{EnablePathUnescaping: true},
target: "/files/admin%2Fsecret.txt",
wantCode: http.StatusOK,
},
}
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)
for _, tc := range testCases {
t.Run(tc.name, func(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))
cfg := tc.config
cfg.Root = root
e := echo.New()
g := e.Group("/files", StaticWithConfig(cfg))
g.GET("/*", func(c echo.Context) error { return echo.ErrNotFound })
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.wantCode != http.StatusOK {
assert.NotContains(t, rec.Body.String(), "TOP-SECRET", "GET %s leaked protected file", tc.target)
}
})
}
}

@@ -11,2 +11,3 @@ // SPDX-License-Identifier: MIT

"os"
"path/filepath"
"strings"

@@ -215,2 +216,78 @@ "testing"

func TestStaticMiddlewareAndRouterInconsistentEscaping(t *testing.T) {
var testCases = []struct {
name string
givenConfig StaticConfig
whenURL string
expectCode int
expectBodyContains string
expectBodyNotContains string
}{
{
name: "ok, normal file is served",
givenConfig: StaticConfig{},
whenURL: "/test.txt",
expectCode: http.StatusOK,
expectBodyContains: "test",
},
{
name: "ok, direct request to restricted path is blocked by ACL route",
givenConfig: StaticConfig{},
whenURL: "/admin/secret.txt",
expectCode: http.StatusForbidden,
},
{
// With EnablePathUnescaping=false (default/safe), the wildcard param "admin%2fsecret.txt"
// is NOT decoded, so the FS lookup is for literal "admin%2fsecret.txt" which does
// not exist → falls through to the /* handler → 404. ACL is not bypassed.
name: "ok, encoded slash returns 404 with default safe config (EnablePathUnescaping=false)",
givenConfig: StaticConfig{},
whenURL: "/admin%2fsecret.txt",
expectCode: http.StatusNotFound,
expectBodyNotContains: "TOP-SECRET",
},
{
// With EnablePathUnescaping=true, the wildcard param "admin%2fsecret.txt" IS decoded
// to "admin/secret.txt". The router already routed to /* (encoded %2f prevented matching
// /admin/*), so the ACL guard never ran. The file is served — ACL bypass.
// Only use EnablePathUnescaping: true when not relying on route-based ACL guards.
name: "nok, encoded slash bypasses ACL when EnablePathUnescaping=true",
givenConfig: StaticConfig{EnablePathUnescaping: true},
whenURL: "/admin%2fsecret.txt",
expectCode: http.StatusOK,
expectBodyContains: "TOP-SECRET",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(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, "test.txt"), []byte("test content"), 0o644))
cfg := tc.givenConfig
cfg.Root = root
e := echo.New()
e.Use(StaticWithConfig(cfg))
e.GET("/*", func(c echo.Context) error { return echo.ErrNotFound })
e.GET("/admin/*", func(c echo.Context) error { return echo.ErrForbidden })
req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, tc.expectCode, rec.Code)
body := rec.Body.String()
if tc.expectBodyContains != "" {
assert.Contains(t, body, tc.expectBodyContains)
}
if tc.expectBodyNotContains != "" {
assert.NotContains(t, body, tc.expectBodyNotContains)
}
})
}
}
func TestStatic_GroupWithStatic(t *testing.T) {

@@ -217,0 +294,0 @@ var testCases = []struct {

@@ -17,3 +17,2 @@ // SPDX-License-Identifier: MIT

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/internal/pathutil"
"github.com/labstack/gommon/bytes"

@@ -53,2 +52,11 @@ )

Filesystem http.FileSystem `yaml:"-"`
// EnablePathUnescaping enables path parameter (param: *) unescaping.
// Default false (safe): encoded slashes (%2f) in the wildcard param are NOT decoded,
// preventing ACL bypass where /admin%2fprivate.txt bypasses a /admin/* route guard by
// not matching that route but having its wildcard param decoded to admin/private.txt.
// Set to true only when serving files whose names contain URL-encoded characters
// (e.g. "hello world.txt" via /hello%20world.txt) and you are not relying on
// route-based ACL guards to restrict access.
EnablePathUnescaping bool `yaml:"enablePathUnescaping"`
}

@@ -176,13 +184,8 @@

}
// 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
if config.EnablePathUnescaping {
p, err = url.PathUnescape(p)
if err != nil {
return
}
}
p, err = url.PathUnescape(p)
if err != nil {
return
}
// Security: We use path.Clean() (not filepath.Clean()) because:

@@ -189,0 +192,0 @@ // 1. HTTP URLs always use forward slashes, regardless of server OS

// 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
}