Skip to content

Commit f6f4af7

Browse files
committed
Fix Static Middleware and Router Inconsistent Escaping leading to possible Unauthorized static file disclosure
Fix StaticDirectoryHandler and Router Inconsistent Escaping leading to possible Unauthorized static file disclosure
1 parent 5786024 commit f6f4af7

9 files changed

Lines changed: 236 additions & 113 deletions

File tree

_fixture/dist/%2f..

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This filename is escaped to `/..` in URL.Path
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
private file

echo.go

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Learn more at https://echo.labstack.com
4343
package echo
4444

4545
import (
46+
"cmp"
4647
stdContext "context"
4748
"encoding/json"
4849
"errors"
@@ -53,14 +54,11 @@ import (
5354
"net/url"
5455
"os"
5556
"os/signal"
56-
"path"
5757
"path/filepath"
5858
"strings"
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,19 @@ 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+
// Enabling RouterConfig.UseEscapedPathForMatching makes path escaping in static files and router consistent.
313+
// Applies to methods: Echo.Static, Echo.StaticFS, Group.Static, Group.StaticFS.
314+
EnablePathUnescapingStaticFiles bool
302315
}
303316

304317
// NewWithConfig creates an instance of Echo with given configuration.
@@ -337,6 +350,8 @@ func NewWithConfig(config Config) *Echo {
337350
if config.FormParseMaxMemory > 0 {
338351
e.formParseMaxMemory = config.FormParseMaxMemory
339352
}
353+
e.enablePathUnescapingStaticFiles = config.EnablePathUnescapingStaticFiles
354+
340355
return e
341356
}
342357

@@ -567,7 +582,7 @@ func (e *Echo) Static(pathPrefix, fsRoot string, middleware ...MiddlewareFunc) R
567582
return e.Add(
568583
http.MethodGet,
569584
pathPrefix+"*",
570-
StaticDirectoryHandler(subFs, false),
585+
StaticDirectoryHandler(subFs, !e.enablePathUnescapingStaticFiles),
571586
middleware...,
572587
)
573588
}
@@ -581,49 +596,45 @@ func (e *Echo) StaticFS(pathPrefix string, filesystem fs.FS, middleware ...Middl
581596
return e.Add(
582597
http.MethodGet,
583598
pathPrefix+"*",
584-
StaticDirectoryHandler(filesystem, false),
599+
StaticDirectoryHandler(filesystem, !e.enablePathUnescapingStaticFiles),
585600
middleware...,
586601
)
587602
}
588603

589604
// StaticDirectoryHandler creates handler function to serve files from provided file system
590605
// When disablePathUnescaping is set then file name from path is not unescaped and is served as is.
606+
//
607+
// Note: Router is matching against unescaped path and using disablePathUnescaping=false here can lead to serving files
608+
// outside of the intended directory - if there are Routes that are meant to forbit some subset of the served filesystem
609+
// to be accessed, but inconsistency between how Router sees an unescaped path and this function will use an escaped path.
610+
// Enabling RouterConfig.UseEscapedPathForMatching makes path escaping in static files and router consistent.
611+
// See https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq
591612
func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) HandlerFunc {
592613
return func(c *Context) error {
593614
p := c.Param("*")
594615
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-
}
605616
tmpPath, err := url.PathUnescape(p)
606617
if err != nil {
607618
return fmt.Errorf("failed to unescape path variable: %w", err)
608619
}
609620
p = tmpPath
610621
}
611622

612-
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid.
613-
// Use path.Clean (not filepath.Clean): fs.FS paths are always forward-slash, so a backslash must stay a literal
614-
// character rather than being interpreted as a separator on Windows (which would resolve a file across a boundary
615-
// the router never matched on, the same Windows backslash traversal class as GHSA-pgvm-wxw2-hrv9).
616-
name := path.Clean(strings.TrimPrefix(p, "/"))
623+
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/`
624+
// as invalid
625+
name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
617626
fi, err := fs.Stat(fileSystem, name)
618627
if err != nil {
619628
return ErrNotFound
620629
}
621630

622-
// If the request is for a directory and does not end with "/"
623-
p = c.Request().URL.Path // path must not be empty.
631+
// If the request is for a directory and does not end with "/" redirect to path which ends with "/"
632+
p = c.Request().URL.Path
624633
if fi.IsDir() && len(p) > 0 && p[len(p)-1] != '/' {
625-
// Redirect to ends with "/"
626-
return c.Redirect(http.StatusMovedPermanently, sanitizeURI(p+"/"))
634+
// prefer RawPath in redirect to avoid inconsistency when router is possible already escaping or not escaping
635+
// as we are redirecting to same path, except added "/" at the end the RawPath is more consistent behavior
636+
redirectPath := cmp.Or(c.Request().URL.RawPath, c.Request().URL.Path)
637+
return c.Redirect(http.StatusMovedPermanently, sanitizeURI(redirectPath+"/"))
627638
}
628639
return fsFile(c, name, fileSystem)
629640
}

echo_test.go

Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,15 @@ func TestNewDefaultFS(t *testing.T) {
134134

135135
func TestEcho_StaticFS(t *testing.T) {
136136
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
137+
givenFs fs.FS
138+
name string
139+
givenPrefix string
140+
givenFsRoot string
141+
givenEnablePathUnescapingStaticFiles bool
142+
whenURL string
143+
expectHeaderLocation string
144+
expectBodyStartsWith string
145+
expectStatus int
145146
}{
146147
{
147148
name: "ok",
@@ -259,22 +260,40 @@ func TestEcho_StaticFS(t *testing.T) {
259260
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
260261
},
261262
{
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",
263+
name: "do not unescape path variables by default",
266264
givenPrefix: "/",
267265
givenFs: os.DirFS("_fixture/"),
268266
whenURL: "/open.redirect.hackercom%2f..",
269267
expectStatus: http.StatusNotFound,
270-
expectHeaderLocation: "",
271268
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
272269
},
270+
{
271+
name: "possible open redirect vulnerability when unescaping path variables in static handler",
272+
givenPrefix: "/",
273+
givenFs: os.DirFS("_fixture/"),
274+
givenEnablePathUnescapingStaticFiles: true,
275+
whenURL: "/open.redirect.hackercom%2f..",
276+
expectStatus: http.StatusMovedPermanently,
277+
expectHeaderLocation: "/open.redirect.hackercom%2f../", // location starting with `//open` would be very bad
278+
expectBodyStartsWith: "",
279+
},
280+
{
281+
name: "possible open redirect vulnerability when not unescaping path variables in static handler",
282+
givenPrefix: "/",
283+
givenFs: os.DirFS("_fixture/dist/"),
284+
givenEnablePathUnescapingStaticFiles: false,
285+
whenURL: "/%2f..",
286+
expectStatus: http.StatusOK,
287+
expectHeaderLocation: "",
288+
expectBodyStartsWith: "This filename is escaped to `/..` in URL.Path\n",
289+
},
273290
}
274291

275292
for _, tc := range testCases {
276293
t.Run(tc.name, func(t *testing.T) {
277-
e := New()
294+
e := NewWithConfig(Config{
295+
EnablePathUnescapingStaticFiles: tc.givenEnablePathUnescapingStaticFiles,
296+
})
278297

279298
tmpFs := tc.givenFs
280299
if tc.givenFsRoot != "" {
@@ -305,6 +324,87 @@ func TestEcho_StaticFS(t *testing.T) {
305324
}
306325
}
307326

327+
func TestStaticDirectoryHandlerAndRouterInconsistentEscaping(t *testing.T) {
328+
var testCases = []struct {
329+
name string
330+
givenEnablePathUnescapingStaticFiles bool
331+
givenRouterUnescapePathParamValues bool
332+
whenURL string
333+
expectBodyStartsWith string
334+
expectStatus int
335+
}{
336+
{
337+
name: "ok, file is served from not-forbidden path",
338+
givenEnablePathUnescapingStaticFiles: false,
339+
whenURL: "/test.txt",
340+
expectBodyStartsWith: "test.txt contents\n",
341+
expectStatus: http.StatusOK,
342+
},
343+
{
344+
name: "ok, forbidden path is matched by route wildcard and forbidden by that",
345+
givenEnablePathUnescapingStaticFiles: false,
346+
whenURL: "/admin/private.txt",
347+
expectBodyStartsWith: "{\"message\":\"Forbidden\"}\n",
348+
expectStatus: http.StatusForbidden,
349+
},
350+
{
351+
name: "ok, escaped filename from forbidden path is not escaped and results 404",
352+
givenEnablePathUnescapingStaticFiles: false, // router path escaping and StaticDirectoryHandler is consistent
353+
whenURL: "/admin%2fprivate.txt",
354+
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
355+
expectStatus: http.StatusNotFound,
356+
},
357+
{
358+
name: "nok, escaped filename from forbidden path is escaped and returns file contents (handler escapes)",
359+
givenEnablePathUnescapingStaticFiles: true, // router path escaping and StaticDirectoryHandler is NOT consistent
360+
whenURL: "/admin%2fprivate.txt",
361+
expectBodyStartsWith: "private file\n",
362+
expectStatus: http.StatusOK,
363+
},
364+
{
365+
name: "nok, escaped filename from forbidden path is escaped and returns file contents (router escapes)",
366+
givenRouterUnescapePathParamValues: true, // router path escaping and StaticDirectoryHandler is NOT consistent
367+
whenURL: "/admin%2fprivate.txt",
368+
expectBodyStartsWith: "private file\n",
369+
expectStatus: http.StatusOK,
370+
},
371+
}
372+
for _, tc := range testCases {
373+
t.Run(tc.name, func(t *testing.T) {
374+
r := NewRouter(RouterConfig{
375+
UnescapePathParamValues: tc.givenRouterUnescapePathParamValues,
376+
})
377+
e := NewWithConfig(Config{
378+
EnablePathUnescapingStaticFiles: tc.givenEnablePathUnescapingStaticFiles,
379+
Router: r,
380+
Filesystem: os.DirFS("./_fixture/dist"),
381+
})
382+
383+
// 1. share folder contents from the server root. This folder actually contains folder `admin` which contents we
384+
// want to forbid from downloading
385+
e.Static("/", "public")
386+
387+
// 2. naively assume that everything under /admin folder is now forbidden
388+
e.GET("/admin/*", func(c *Context) error {
389+
return ErrForbidden
390+
})
391+
392+
req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
393+
rec := httptest.NewRecorder()
394+
395+
e.ServeHTTP(rec, req)
396+
397+
assert.Equal(t, tc.expectStatus, rec.Code)
398+
body := rec.Body.String()
399+
if tc.expectBodyStartsWith != "" {
400+
assert.True(t, strings.HasPrefix(body, tc.expectBodyStartsWith))
401+
} else {
402+
assert.Equal(t, "", body)
403+
}
404+
})
405+
}
406+
}
407+
308408
func TestEcho_FileFS(t *testing.T) {
309409
var testCases = []struct {
310410
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
}

internal/pathutil/pathutil.go

Lines changed: 0 additions & 30 deletions
This file was deleted.

internal/pathutil/pathutil_test.go

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)