Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions _fixture/dist/public/admin/private.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
private file
53 changes: 34 additions & 19 deletions echo.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Learn more at https://echo.labstack.com
package echo

import (
"cmp"
stdContext "context"
"encoding/json"
"errors"
Expand All @@ -59,8 +60,6 @@ import (
"sync"
"sync/atomic"
"syscall"

"github.com/labstack/echo/v5/internal/pathutil"
)

// Echo is the top-level framework instance.
Expand Down Expand Up @@ -111,6 +110,8 @@ type Echo struct {

// formParseMaxMemory is passed to Context for multipart form parsing (See http.Request.ParseMultipartForm)
formParseMaxMemory int64

enablePathUnescapingStaticFiles bool
}

// JSONSerializer is the interface that encodes and decodes JSON to and from interfaces.
Expand Down Expand Up @@ -299,6 +300,19 @@ type Config struct {
// FormParseMaxMemory is default value for memory limit that is used
// when parsing multipart forms (See (*http.Request).ParseMultipartForm)
FormParseMaxMemory int64

// EnablePathUnescapingStaticFiles enables path parameter (param: *) unescaping for static file 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
// Enabling RouterConfig.UseEscapedPathForMatching makes path escaping in static files and router consistent.
// Applies to methods: Echo.Static, Echo.StaticFS, Group.Static, Group.StaticFS.
EnablePathUnescapingStaticFiles bool
}

// NewWithConfig creates an instance of Echo with given configuration.
Expand Down Expand Up @@ -337,6 +351,8 @@ func NewWithConfig(config Config) *Echo {
if config.FormParseMaxMemory > 0 {
e.formParseMaxMemory = config.FormParseMaxMemory
}
e.enablePathUnescapingStaticFiles = config.EnablePathUnescapingStaticFiles

return e
}

Expand Down Expand Up @@ -567,7 +583,7 @@ func (e *Echo) Static(pathPrefix, fsRoot string, middleware ...MiddlewareFunc) R
return e.Add(
http.MethodGet,
pathPrefix+"*",
StaticDirectoryHandler(subFs, false),
StaticDirectoryHandler(subFs, !e.enablePathUnescapingStaticFiles),
middleware...,
)
}
Expand All @@ -581,35 +597,32 @@ func (e *Echo) StaticFS(pathPrefix string, filesystem fs.FS, middleware ...Middl
return e.Add(
http.MethodGet,
pathPrefix+"*",
StaticDirectoryHandler(filesystem, false),
StaticDirectoryHandler(filesystem, !e.enablePathUnescapingStaticFiles),
middleware...,
)
}

// 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: Router is matching against unescaped path and using disablePathUnescaping=false here can lead to serving files
// outside of the intended directory - if there are Routes that are meant to forbit some subset of the served filesystem
// to be accessed, but inconsistency between how Router sees an unescaped path and this function will use an escaped path.
// Enabling RouterConfig.UseEscapedPathForMatching makes path escaping in static files and router consistent.
// See https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq
func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) HandlerFunc {
return func(c *Context) error {
p := c.Param("*")
if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
// By default the router matches routes against the raw, still-encoded request path
// (unless UseEscapedPathForMatching is enabled), so an encoded path separator 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, carrying the reason internally for operators.
if pathutil.HasEncodedPathSeparator(p) {
return NewHTTPError(http.StatusNotFound, http.StatusText(http.StatusNotFound)).
Wrap(fmt.Errorf("rejected encoded path separator in static path %q", p))
}
tmpPath, err := url.PathUnescape(p)
if err != nil {
return fmt.Errorf("failed to unescape path variable: %w", err)
}
p = tmpPath
}

// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid.
// 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, the same Windows backslash traversal class as GHSA-pgvm-wxw2-hrv9).
Expand All @@ -619,11 +632,13 @@ func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) Handle
return ErrNotFound
}

// If the request is for a directory and does not end with "/"
p = c.Request().URL.Path // path must not be empty.
// If the request is for a directory and does not end with "/" redirect to path which ends with "/"
p = c.Request().URL.Path
if fi.IsDir() && len(p) > 0 && p[len(p)-1] != '/' {
// Redirect to ends with "/"
return c.Redirect(http.StatusMovedPermanently, sanitizeURI(p+"/"))
// prefer RawPath in redirect to avoid inconsistency when router is possible already escaping or not escaping
// as we are redirecting to same path, except added "/" at the end the RawPath is more consistent behavior
redirectPath := cmp.Or(c.Request().URL.RawPath, c.Request().URL.Path)
return c.Redirect(http.StatusMovedPermanently, sanitizeURI(redirectPath+"/"))
}
return fsFile(c, name, fileSystem)
}
Expand Down
134 changes: 120 additions & 14 deletions echo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"runtime"
"strings"
"testing"
"testing/fstest"
"time"

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

func TestEcho_StaticFS(t *testing.T) {
dotsInFilenameFS := fstest.MapFS{
// On Windows filename `%2f..` can not be created but in Linux it is possible.
"%2f..": {Data: []byte("This filename is escaped to `/..` in URL.Path")},
}

var testCases = []struct {
givenFs fs.FS
name string
givenPrefix string
givenFsRoot string
whenURL string
expectHeaderLocation string
expectBodyStartsWith string
expectStatus int
givenFs fs.FS
name string
givenPrefix string
givenFsRoot string
givenEnablePathUnescapingStaticFiles bool
whenURL string
expectHeaderLocation string
expectBodyStartsWith string
expectStatus int
}{
{
name: "ok",
Expand Down Expand Up @@ -259,22 +266,40 @@ func TestEcho_StaticFS(t *testing.T) {
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
},
{
// An encoded slash (%2f) is rejected outright (GHSA-vfp3-v2gw-7wfq): by default 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: "/",
givenFs: os.DirFS("_fixture/"),
whenURL: "/open.redirect.hackercom%2f..",
expectStatus: http.StatusNotFound,
expectHeaderLocation: "",
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
},
{
name: "possible open redirect vulnerability when unescaping path variables in static handler",
givenPrefix: "/",
givenFs: os.DirFS("_fixture/"),
givenEnablePathUnescapingStaticFiles: true,
whenURL: "/open.redirect.hackercom%2f..",
expectStatus: http.StatusMovedPermanently,
expectHeaderLocation: "/open.redirect.hackercom%2f../", // location starting with `//open` would be very bad
expectBodyStartsWith: "",
},
{
name: "possible open redirect vulnerability when not unescaping path variables in static handler",
givenPrefix: "/",
givenFs: dotsInFilenameFS,
givenEnablePathUnescapingStaticFiles: false,
whenURL: "/%2f..",
expectStatus: http.StatusOK,
expectHeaderLocation: "",
expectBodyStartsWith: "This filename is escaped to `/..` in URL.Path",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
e := New()
e := NewWithConfig(Config{
EnablePathUnescapingStaticFiles: tc.givenEnablePathUnescapingStaticFiles,
})

tmpFs := tc.givenFs
if tc.givenFsRoot != "" {
Expand Down Expand Up @@ -305,6 +330,87 @@ func TestEcho_StaticFS(t *testing.T) {
}
}

func TestStaticDirectoryHandlerAndRouterInconsistentEscaping(t *testing.T) {
var testCases = []struct {
name string
givenEnablePathUnescapingStaticFiles bool
givenRouterUnescapePathParamValues bool
whenURL string
expectBodyStartsWith string
expectStatus int
}{
{
name: "ok, file is served from not-forbidden path",
givenEnablePathUnescapingStaticFiles: false,
whenURL: "/test.txt",
expectBodyStartsWith: "test.txt contents\n",
expectStatus: http.StatusOK,
},
{
name: "ok, forbidden path is matched by route wildcard and forbidden by that",
givenEnablePathUnescapingStaticFiles: false,
whenURL: "/admin/private.txt",
expectBodyStartsWith: "{\"message\":\"Forbidden\"}\n",
expectStatus: http.StatusForbidden,
},
{
name: "ok, escaped filename from forbidden path is not escaped and results 404",
givenEnablePathUnescapingStaticFiles: false, // router path escaping and StaticDirectoryHandler is consistent
whenURL: "/admin%2fprivate.txt",
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
expectStatus: http.StatusNotFound,
},
{
name: "nok, escaped filename from forbidden path is escaped and returns file contents (handler escapes)",
givenEnablePathUnescapingStaticFiles: true, // router path escaping and StaticDirectoryHandler is NOT consistent
whenURL: "/admin%2fprivate.txt",
expectBodyStartsWith: "private file\n",
expectStatus: http.StatusOK,
},
{
name: "nok, escaped filename from forbidden path is escaped and returns file contents (router escapes)",
givenRouterUnescapePathParamValues: true, // router path escaping and StaticDirectoryHandler is NOT consistent
whenURL: "/admin%2fprivate.txt",
expectBodyStartsWith: "private file\n",
expectStatus: http.StatusOK,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
r := NewRouter(RouterConfig{
UnescapePathParamValues: tc.givenRouterUnescapePathParamValues,
})
e := NewWithConfig(Config{
EnablePathUnescapingStaticFiles: tc.givenEnablePathUnescapingStaticFiles,
Router: r,
Filesystem: os.DirFS("./_fixture/dist"),
})

// 1. share folder contents from the server root. This folder actually contains folder `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
})

req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
rec := httptest.NewRecorder()

e.ServeHTTP(rec, req)

assert.Equal(t, tc.expectStatus, rec.Code)
body := rec.Body.String()
if tc.expectBodyStartsWith != "" {
assert.True(t, strings.HasPrefix(body, tc.expectBodyStartsWith))
} else {
assert.Equal(t, "", body)
}
})
}
}

func TestEcho_FileFS(t *testing.T) {
var testCases = []struct {
whenFS fs.FS
Expand Down
2 changes: 1 addition & 1 deletion group.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func (g *Group) StaticFS(pathPrefix string, filesystem fs.FS, middleware ...Midd
return g.Add(
http.MethodGet,
pathPrefix+"*",
StaticDirectoryHandler(filesystem, false),
StaticDirectoryHandler(filesystem, !g.echo.enablePathUnescapingStaticFiles),
middleware...,
)
}
Expand Down
30 changes: 0 additions & 30 deletions internal/pathutil/pathutil.go

This file was deleted.

27 changes: 0 additions & 27 deletions internal/pathutil/pathutil_test.go

This file was deleted.

Loading
Loading