Skip to content

Commit 78c3d95

Browse files
authored
revert PR #3009 changes to just disabling path escaping by default in static methods/middleware (#3016)
revert PR #3009 changes to just disabling path escaping by default in static methods/middleware (#3016)
1 parent 5786024 commit 78c3d95

13 files changed

Lines changed: 423 additions & 154 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
public/admin/private.txt - private file
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
hello world file

echo.go

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,6 @@ import (
5959
"sync"
6060
"sync/atomic"
6161
"syscall"
62-
63-
"github.com/labstack/echo/v5/internal/pathutil"
6462
)
6563

6664
// Echo is the top-level framework instance.
@@ -111,6 +109,8 @@ type Echo struct {
111109

112110
// formParseMaxMemory is passed to Context for multipart form parsing (See http.Request.ParseMultipartForm)
113111
formParseMaxMemory int64
112+
113+
enablePathUnescapingStaticFiles bool
114114
}
115115

116116
// JSONSerializer is the interface that encodes and decodes JSON to and from interfaces.
@@ -299,6 +299,31 @@ type Config struct {
299299
// FormParseMaxMemory is default value for memory limit that is used
300300
// when parsing multipart forms (See (*http.Request).ParseMultipartForm)
301301
FormParseMaxMemory int64
302+
303+
// EnablePathUnescapingStaticFiles enables path parameter (param: *) unescaping for static file methods.
304+
// Default false (safe): encoded slashes (%2f) in the wildcard param are NOT decoded,
305+
// preventing ACL bypass where /admin%2fprivate.txt bypasses a /admin/* route guard by
306+
// not matching that route but having its wildcard param decoded to admin/private.txt.
307+
// Set to true only when serving files whose names contain URL-encoded characters
308+
// (e.g. "hello world.txt" via /hello%20world.txt) and you are not relying on
309+
// route-based ACL guards to restrict access.
310+
// If you are enabling this option, make sure you understand the security implications.
311+
// See: https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq
312+
//
313+
// Enabling RouterConfig.UseEscapedPathForMatching makes this field irrelevant and can lead to security issues when
314+
// using different Routes to exclude some of the files from being served.
315+
// e.g. if you serve files from directory as such and use different route to exclude some of the files from being served.
316+
// 0. given folder structure:
317+
// public/
318+
// public/index.html
319+
// public/admin/private.txt
320+
// 1. share `public/` folder contents from the server root with `e.Static("/", "public")`
321+
// 2. naively assume that everything under /admin folder is now forbidden
322+
// e.GET("/admin/*", func(c *Context) error { return echo.ErrForbidden })
323+
// Then request to `/assets/../admin%2fprivate.txt` will be served as router does not match it to guarded route.
324+
//
325+
// Applies to methods: Echo.Static, Echo.StaticFS, Group.Static, Group.StaticFS.
326+
EnablePathUnescapingStaticFiles bool
302327
}
303328

304329
// NewWithConfig creates an instance of Echo with given configuration.
@@ -337,6 +362,8 @@ func NewWithConfig(config Config) *Echo {
337362
if config.FormParseMaxMemory > 0 {
338363
e.formParseMaxMemory = config.FormParseMaxMemory
339364
}
365+
e.enablePathUnescapingStaticFiles = config.EnablePathUnescapingStaticFiles
366+
340367
return e
341368
}
342369

@@ -567,7 +594,7 @@ func (e *Echo) Static(pathPrefix, fsRoot string, middleware ...MiddlewareFunc) R
567594
return e.Add(
568595
http.MethodGet,
569596
pathPrefix+"*",
570-
StaticDirectoryHandler(subFs, false),
597+
StaticDirectoryHandler(subFs, !e.enablePathUnescapingStaticFiles),
571598
middleware...,
572599
)
573600
}
@@ -581,35 +608,35 @@ func (e *Echo) StaticFS(pathPrefix string, filesystem fs.FS, middleware ...Middl
581608
return e.Add(
582609
http.MethodGet,
583610
pathPrefix+"*",
584-
StaticDirectoryHandler(filesystem, false),
611+
StaticDirectoryHandler(filesystem, !e.enablePathUnescapingStaticFiles),
585612
middleware...,
586613
)
587614
}
588615

589616
// StaticDirectoryHandler creates handler function to serve files from provided file system
590617
// When disablePathUnescaping is set then file name from path is not unescaped and is served as is.
618+
//
619+
// Note: when disablePathUnescaping=false, the handler decodes the wildcard param before serving.
620+
// If route guards (e.g. e.GET("/admin/*", forbidden)) are used to restrict parts of the
621+
// filesystem, an encoded separator (%2F) or encoded dot-dot (%2E%2E) in the URL can resolve to
622+
// a path that the router never matched against the guard route. Enabling
623+
// RouterConfig.UseEscapedPathForMatching does NOT fix this — it changes which path the router
624+
// uses for matching but still lets path.Clean resolve ".." segments into a guarded directory.
625+
// Do not rely on route guards alone to restrict a filesystem served by this handler.
626+
// See https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq
591627
func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) HandlerFunc {
592628
return func(c *Context) error {
593629
p := c.Param("*")
594630
if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
595-
// By default the router matches routes against the raw, still-encoded request path
596-
// (unless UseEscapedPathForMatching is enabled), so an encoded path separator is not
597-
// treated as a segment boundary during routing. Unescaping it here would let it act as
598-
// a separator and resolve a file outside the path the router authorized, bypassing
599-
// route-level middleware (e.g. auth on a sibling route). No real filename contains a
600-
// separator, so reject it as not found, carrying the reason internally for operators.
601-
if pathutil.HasEncodedPathSeparator(p) {
602-
return NewHTTPError(http.StatusNotFound, http.StatusText(http.StatusNotFound)).
603-
Wrap(fmt.Errorf("rejected encoded path separator in static path %q", p))
604-
}
605631
tmpPath, err := url.PathUnescape(p)
606632
if err != nil {
607633
return fmt.Errorf("failed to unescape path variable: %w", err)
608634
}
609635
p = tmpPath
610636
}
611637

612-
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid.
638+
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/`
639+
// as invalid
613640
// Use path.Clean (not filepath.Clean): fs.FS paths are always forward-slash, so a backslash must stay a literal
614641
// character rather than being interpreted as a separator on Windows (which would resolve a file across a boundary
615642
// the router never matched on, the same Windows backslash traversal class as GHSA-pgvm-wxw2-hrv9).
@@ -619,10 +646,9 @@ func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) Handle
619646
return ErrNotFound
620647
}
621648

622-
// If the request is for a directory and does not end with "/"
623-
p = c.Request().URL.Path // path must not be empty.
649+
// If the request is for a directory and does not end with "/" redirect to path which ends with "/"
650+
p = c.Request().URL.Path
624651
if fi.IsDir() && len(p) > 0 && p[len(p)-1] != '/' {
625-
// Redirect to ends with "/"
626652
return c.Redirect(http.StatusMovedPermanently, sanitizeURI(p+"/"))
627653
}
628654
return fsFile(c, name, fileSystem)

echo_test.go

Lines changed: 184 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"runtime"
2121
"strings"
2222
"testing"
23+
"testing/fstest"
2324
"time"
2425

2526
"github.com/stretchr/testify/assert"
@@ -133,15 +134,21 @@ func TestNewDefaultFS(t *testing.T) {
133134
}
134135

135136
func TestEcho_StaticFS(t *testing.T) {
137+
dotsInFilenameFS := fstest.MapFS{
138+
// On Windows filename `%2f..` can not be created but in Linux it is possible.
139+
"%2f..": {Data: []byte("This filename is escaped to `/..` in URL.Path")},
140+
}
141+
136142
var testCases = []struct {
137-
givenFs fs.FS
138-
name string
139-
givenPrefix string
140-
givenFsRoot string
141-
whenURL string
142-
expectHeaderLocation string
143-
expectBodyStartsWith string
144-
expectStatus int
143+
givenFs fs.FS
144+
name string
145+
givenPrefix string
146+
givenFsRoot string
147+
givenEnablePathUnescapingStaticFiles bool
148+
whenURL string
149+
expectHeaderLocation string
150+
expectBodyStartsWith string
151+
expectStatus int
145152
}{
146153
{
147154
name: "ok",
@@ -250,6 +257,33 @@ func TestEcho_StaticFS(t *testing.T) {
250257
expectStatus: http.StatusNotFound,
251258
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
252259
},
260+
{
261+
name: "do not accept encoded dots in path (%2E%2E is `..`) to traverse within filesystem boundary",
262+
givenPrefix: "/",
263+
givenFs: os.DirFS("_fixture/"),
264+
givenEnablePathUnescapingStaticFiles: false,
265+
whenURL: `/dist/public/%2E%2E/private.txt`, // `/dist/public/../private.txt`
266+
expectStatus: http.StatusNotFound,
267+
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
268+
},
269+
{
270+
name: "allow encoded dots in path (%2E%2E is `..`) to traverse within filesystem",
271+
givenPrefix: "/",
272+
givenFs: os.DirFS("_fixture/"),
273+
givenEnablePathUnescapingStaticFiles: true,
274+
whenURL: `/dist/public/%2E%2E/private.txt`, // `/dist/public/../private.txt`
275+
expectStatus: http.StatusOK,
276+
expectBodyStartsWith: "private file",
277+
},
278+
{
279+
name: "ok, file with space in name is served when path unescaping is enabled",
280+
givenPrefix: "/",
281+
givenFs: os.DirFS("_fixture/dist/public"),
282+
givenEnablePathUnescapingStaticFiles: true,
283+
whenURL: "/hello%20world.txt",
284+
expectStatus: http.StatusOK,
285+
expectBodyStartsWith: "hello world file",
286+
},
253287
{
254288
name: "do not allow directory traversal (slash - unix separator)",
255289
givenPrefix: "/",
@@ -259,22 +293,42 @@ func TestEcho_StaticFS(t *testing.T) {
259293
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
260294
},
261295
{
262-
// An encoded slash (%2f) is rejected outright (GHSA-vfp3-v2gw-7wfq): by default the
263-
// router matches on the raw path so %2f is not a separator, and unescaping it here
264-
// would let it act as one. No redirect is emitted, closing the open-redirect vector.
265-
name: "encoded slash is rejected, not redirected",
296+
name: "do not unescape path variables by default",
266297
givenPrefix: "/",
267298
givenFs: os.DirFS("_fixture/"),
268299
whenURL: "/open.redirect.hackercom%2f..",
269300
expectStatus: http.StatusNotFound,
270-
expectHeaderLocation: "",
271301
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
272302
},
303+
{
304+
name: "possible open redirect vulnerability when unescaping path variables in static handler",
305+
givenPrefix: "/",
306+
givenFs: os.DirFS("_fixture/"),
307+
givenEnablePathUnescapingStaticFiles: true,
308+
// `//open.redirect.hackercom/..` resolves to directory but does not end with `/` so redirect is done but this
309+
// redirect can not be to path starting with `//` or `\\` (open redirect)
310+
whenURL: "/%2fopen.redirect.hackercom%2f..",
311+
expectStatus: http.StatusMovedPermanently,
312+
expectHeaderLocation: "/open.redirect.hackercom/../", // location starting with `//open` would be very bad
313+
expectBodyStartsWith: "",
314+
},
315+
{
316+
name: "possible open redirect vulnerability when not unescaping path variables in static handler",
317+
givenPrefix: "/",
318+
givenFs: dotsInFilenameFS,
319+
givenEnablePathUnescapingStaticFiles: false,
320+
whenURL: "/%2f..",
321+
expectStatus: http.StatusOK,
322+
expectHeaderLocation: "",
323+
expectBodyStartsWith: "This filename is escaped to `/..` in URL.Path",
324+
},
273325
}
274326

275327
for _, tc := range testCases {
276328
t.Run(tc.name, func(t *testing.T) {
277-
e := New()
329+
e := NewWithConfig(Config{
330+
EnablePathUnescapingStaticFiles: tc.givenEnablePathUnescapingStaticFiles,
331+
})
278332

279333
tmpFs := tc.givenFs
280334
if tc.givenFsRoot != "" {
@@ -305,6 +359,122 @@ func TestEcho_StaticFS(t *testing.T) {
305359
}
306360
}
307361

362+
func TestStaticDirectoryHandlerAndRouterInconsistentEscaping(t *testing.T) {
363+
var testCases = []struct {
364+
name string
365+
givenEnablePathUnescapingStaticFiles bool
366+
givenRouterUnescapePathParamValues bool
367+
givenRouterUseEscapedPathForMatching bool
368+
whenURL string
369+
expectBody string
370+
expectStatus int
371+
}{
372+
{
373+
name: "ok, file is served from not-forbidden path",
374+
givenEnablePathUnescapingStaticFiles: false,
375+
whenURL: "/test.txt",
376+
expectBody: "test.txt contents",
377+
expectStatus: http.StatusOK,
378+
},
379+
{
380+
name: "ok, forbidden path is matched by route wildcard and forbidden by that",
381+
givenEnablePathUnescapingStaticFiles: false,
382+
whenURL: "/admin/private.txt",
383+
expectBody: "{\"message\":\"Forbidden\"}",
384+
expectStatus: http.StatusForbidden,
385+
},
386+
{
387+
name: "ok, escaped filename from forbidden path is routed to guarded route",
388+
givenEnablePathUnescapingStaticFiles: false,
389+
givenRouterUnescapePathParamValues: false,
390+
givenRouterUseEscapedPathForMatching: true, // Router uses escaped path (req.URL.RawPath) for matching
391+
whenURL: "/admin%2fprivate.txt",
392+
expectBody: "{\"message\":\"Forbidden\"}",
393+
expectStatus: http.StatusForbidden,
394+
},
395+
{
396+
name: "ok, escaped filename from forbidden path is not unescaped and results 404",
397+
givenEnablePathUnescapingStaticFiles: false, // router path escaping and StaticDirectoryHandler is consistent
398+
whenURL: "/admin%2fprivate.txt",
399+
expectBody: "{\"message\":\"Not Found\"}",
400+
expectStatus: http.StatusNotFound,
401+
},
402+
{
403+
name: "nok, escaped filename from forbidden path is unescaped and returns file contents (handler unescapes)",
404+
givenEnablePathUnescapingStaticFiles: true, // router path escaping and StaticDirectoryHandler is NOT consistent
405+
givenRouterUnescapePathParamValues: false,
406+
whenURL: "/admin%2fprivate.txt",
407+
expectBody: "public/admin/private.txt - private file",
408+
expectStatus: http.StatusOK,
409+
},
410+
{
411+
name: "nok, escaped filename from forbidden path is unescaped and returns file contents (router unescapes)",
412+
givenEnablePathUnescapingStaticFiles: false,
413+
givenRouterUnescapePathParamValues: true, // router path escaping and StaticDirectoryHandler is NOT consistent
414+
whenURL: "/admin%2fprivate.txt",
415+
expectBody: "public/admin/private.txt - private file",
416+
expectStatus: http.StatusOK,
417+
},
418+
{
419+
name: "nok, unescaped filename from forbidden path is escaped and returns file contents (router unescapes and method unescapes)",
420+
givenEnablePathUnescapingStaticFiles: true,
421+
givenRouterUnescapePathParamValues: true, // consistent path unescaping - makes no difference
422+
whenURL: "/admin%2fprivate.txt",
423+
expectBody: "public/admin/private.txt - private file",
424+
expectStatus: http.StatusOK,
425+
},
426+
{
427+
name: "nok, escaped filename, resolves to from forbidden path is not routed to guarded route and includes guarded file",
428+
givenEnablePathUnescapingStaticFiles: false,
429+
givenRouterUnescapePathParamValues: false,
430+
// Router uses escaped path (req.URL.RawPath) for matching, but that file resolves to `admin/private.txt` after path.Clean()
431+
givenRouterUseEscapedPathForMatching: true,
432+
whenURL: "/assets/../admin%2fprivate.txt",
433+
expectBody: "public/admin/private.txt - private file",
434+
expectStatus: http.StatusOK,
435+
},
436+
}
437+
for _, tc := range testCases {
438+
t.Run(tc.name, func(t *testing.T) {
439+
r := NewRouter(RouterConfig{
440+
UnescapePathParamValues: tc.givenRouterUnescapePathParamValues,
441+
UseEscapedPathForMatching: tc.givenRouterUseEscapedPathForMatching,
442+
})
443+
e := NewWithConfig(Config{
444+
EnablePathUnescapingStaticFiles: tc.givenEnablePathUnescapingStaticFiles,
445+
Router: r,
446+
Filesystem: os.DirFS("./_fixture/dist"),
447+
})
448+
449+
// 0.
450+
// given folder structure:
451+
// private.txt
452+
// public/
453+
// public/index.html
454+
// public/text.txt
455+
// public/admin/private.txt
456+
457+
// 1. share `public/` folder contents from the server root. This folder actually contains subfolder `admin` which
458+
// contents we want to forbid from downloading
459+
e.Static("/", "public")
460+
461+
// 2. naively assume that everything under /admin folder is now forbidden
462+
e.GET("/admin/*", func(c *Context) error {
463+
return ErrForbidden
464+
})
465+
466+
req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
467+
rec := httptest.NewRecorder()
468+
469+
e.ServeHTTP(rec, req)
470+
471+
assert.Equal(t, tc.expectStatus, rec.Code)
472+
body := strings.TrimRight(rec.Body.String(), "\r\n")
473+
assert.Equal(t, tc.expectBody, body)
474+
})
475+
}
476+
}
477+
308478
func TestEcho_FileFS(t *testing.T) {
309479
var testCases = []struct {
310480
whenFS fs.FS

group.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ func (g *Group) StaticFS(pathPrefix string, filesystem fs.FS, middleware ...Midd
123123
return g.Add(
124124
http.MethodGet,
125125
pathPrefix+"*",
126-
StaticDirectoryHandler(filesystem, false),
126+
StaticDirectoryHandler(filesystem, !g.echo.enablePathUnescapingStaticFiles),
127127
middleware...,
128128
)
129129
}

0 commit comments

Comments
 (0)