🎩 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.2
to
v4.15.3
+79
echo_fs_encoded_separator_test.go
// 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 @@ }

@@ -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 {

@@ -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 {