Skip to content

Commit bc657a9

Browse files
vishrclaude
andcommitted
fix(static): reject encoded path separator to prevent route-level auth bypass
The router matches routes against the raw, still-encoded request path, so an encoded slash (%2F) is not treated as a segment separator -- /admin%2Fsecret.txt is a single segment and never matches a protected /admin/* group. The request then falls through to the static handler, which unescaped %2F back to "/" before resolving the file, so admin/secret.txt was served from disk while the group's auth middleware never ran. This bypasses route-level access control and discloses static files (GHSA-vfp3-v2gw-7wfq, CWE-22). Reject a static wildcard containing an encoded path separator (%2F/%2f or %5C/%5c) with 404 before unescaping, keeping the router and the file handler consistent about what is a separator. No real filename contains a separator, so legitimate requests are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c9477eb commit bc657a9

3 files changed

Lines changed: 97 additions & 4 deletions

File tree

echo.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,14 @@ func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) Handle
589589
return func(c *Context) error {
590590
p := c.Param("*")
591591
if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
592+
// The router matches routes against the raw, still-encoded request path, so an
593+
// encoded path separator (%2F or %5C) is not treated as a segment boundary during
594+
// routing. Unescaping it here would let it act as a separator and resolve a file
595+
// outside the path the router authorized, bypassing route-level middleware (e.g. auth
596+
// on a sibling route). No real filename contains a separator, so reject it as not found.
597+
if hasEncodedPathSeparator(p) {
598+
return ErrNotFound
599+
}
592600
tmpPath, err := url.PathUnescape(p)
593601
if err != nil {
594602
return fmt.Errorf("failed to unescape path variable: %w", err)
@@ -613,6 +621,25 @@ func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) Handle
613621
}
614622
}
615623

624+
// hasEncodedPathSeparator reports whether s contains a percent-encoded path
625+
// separator: %2F/%2f (slash) or %5C/%5c (backslash). Such sequences let an
626+
// attacker smuggle a separator past the router, which matches on the raw encoded
627+
// path, so they must be rejected before unescaping when resolving static files.
628+
func hasEncodedPathSeparator(s string) bool {
629+
for i := 0; i+2 < len(s); i++ {
630+
if s[i] != '%' {
631+
continue
632+
}
633+
switch {
634+
case s[i+1] == '2' && (s[i+2] == 'f' || s[i+2] == 'F'): // %2F
635+
return true
636+
case s[i+1] == '5' && (s[i+2] == 'c' || s[i+2] == 'C'): // %5C
637+
return true
638+
}
639+
}
640+
return false
641+
}
642+
616643
// FileFS registers a new route with path to serve file from the provided file system.
617644
//
618645
// Avoid using the leading `/` slash as most of the Go standard library fs.FS implementations require relative paths for

echo_test.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,13 +259,16 @@ func TestEcho_StaticFS(t *testing.T) {
259259
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
260260
},
261261
{
262-
name: "open redirect vulnerability",
262+
// An encoded slash (%2f) is rejected outright (GHSA-vfp3-v2gw-7wfq): the router
263+
// matches on the raw path so %2f is not a separator, and unescaping it here would
264+
// let it act as one. No redirect is emitted, closing the open-redirect vector.
265+
name: "encoded slash is rejected, not redirected",
263266
givenPrefix: "/",
264267
givenFs: os.DirFS("_fixture/"),
265268
whenURL: "/open.redirect.hackercom%2f..",
266-
expectStatus: http.StatusMovedPermanently,
267-
expectHeaderLocation: "/open.redirect.hackercom/../", // location starting with `//open` would be very bad
268-
expectBodyStartsWith: "",
269+
expectStatus: http.StatusNotFound,
270+
expectHeaderLocation: "",
271+
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
269272
},
270273
}
271274

static_encoded_separator_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package echo
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
"testing/fstest"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
// Regression for GHSA-vfp3-v2gw-7wfq: an encoded slash (%2F) must not let a static
13+
// file request resolve across a path separator and bypass route-level middleware.
14+
func TestStaticDirectoryHandler_EncodedSeparatorDoesNotBypassRoute(t *testing.T) {
15+
fsys := fstest.MapFS{
16+
"admin/secret.txt": {Data: []byte("TOP-SECRET")},
17+
"index.html": {Data: []byte("public")},
18+
}
19+
e := New()
20+
g := e.Group("/admin", func(next HandlerFunc) HandlerFunc {
21+
return func(c *Context) error { return c.String(http.StatusForbidden, "denied") }
22+
})
23+
g.GET("/*", func(c *Context) error { return c.String(http.StatusOK, "reached-protected-handler") })
24+
e.StaticFS("/", fsys)
25+
26+
cases := []struct {
27+
target string
28+
wantCode int
29+
wantBody string
30+
}{
31+
{"/admin/secret.txt", http.StatusForbidden, "denied"}, // protected route fires
32+
{"/admin%2Fsecret.txt", http.StatusNotFound, ""}, // encoded slash rejected, no disclosure
33+
{"/admin%2fsecret.txt", http.StatusNotFound, ""}, // lower-case hex variant
34+
{"/index.html", http.StatusOK, "public"}, // legitimate static file still served
35+
}
36+
for _, tc := range cases {
37+
req := httptest.NewRequest(http.MethodGet, tc.target, nil)
38+
rec := httptest.NewRecorder()
39+
e.ServeHTTP(rec, req)
40+
assert.Equal(t, tc.wantCode, rec.Code, "GET %s", tc.target)
41+
if tc.wantBody != "" {
42+
assert.Equal(t, tc.wantBody, rec.Body.String(), "GET %s", tc.target)
43+
}
44+
assert.NotContains(t, rec.Body.String(), "TOP-SECRET", "GET %s leaked protected file", tc.target)
45+
}
46+
}
47+
48+
func TestHasEncodedPathSeparator(t *testing.T) {
49+
for s, want := range map[string]bool{
50+
"foo/bar.txt": false,
51+
"100%25.txt": false, // encoded percent, not a separator
52+
"a%2Fb": true,
53+
"a%2fb": true,
54+
"a%5Cb": true,
55+
"a%5cb": true,
56+
"trailing%2F": true,
57+
"%2F": true,
58+
"%2": false, // truncated, not a full sequence
59+
"": false,
60+
} {
61+
assert.Equal(t, want, hasEncodedPathSeparator(s), "input=%q", s)
62+
}
63+
}

0 commit comments

Comments
 (0)