Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## v5.0.3 - 2026-02-06

**Security**

* Fix directory traversal vulnerability under Windows in Static middleware when default Echo filesystem is used. Reported by @shblue21.

This applies to cases when:
- Windows is used as OS
- `middleware.StaticConfig.Filesystem` is `nil` (default)
- `echo.Filesystem` is has not been set explicitly (default)

Exposure is restricted to the active process working directory and its subfolders.


## v5.0.2 - 2026-02-02

**Security**
Expand Down
1 change: 1 addition & 0 deletions _fixture/dist/public/test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test.txt contents
2 changes: 2 additions & 0 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"net"
"net/http"
"net/url"
"path"
"path/filepath"
"strings"
"sync"
Expand Down Expand Up @@ -579,6 +580,7 @@ func (c *Context) FileFS(file string, filesystem fs.FS) error {
}

func fsFile(c *Context, file string, filesystem fs.FS) error {
file = path.Clean(file) // `os.Open` and `os.DirFs.Open()` behave differently, later does not like ``, `.`, `..` at all, but we allowed those now need to clean
f, err := filesystem.Open(file)
if err != nil {
return ErrNotFound
Expand Down
5 changes: 1 addition & 4 deletions echo.go
Original file line number Diff line number Diff line change
Expand Up @@ -785,14 +785,11 @@ func newDefaultFS() *defaultFS {
dir, _ := os.Getwd()
return &defaultFS{
prefix: dir,
fs: nil,
fs: os.DirFS(dir),
}
}

func (fs defaultFS) Open(name string) (fs.File, error) {
if fs.fs == nil {
return os.Open(name) // #nosec G304
}
return fs.fs.Open(name)
}

Expand Down
34 changes: 27 additions & 7 deletions group_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,13 +467,14 @@ func TestGroup_Static(t *testing.T) {

func TestGroup_StaticMultiTest(t *testing.T) {
var testCases = []struct {
name string
givenPrefix string
givenRoot string
whenURL string
expectHeaderLocation string
expectBodyStartsWith string
expectStatus int
name string
givenPrefix string
givenRoot string
whenURL string
expectHeaderLocation string
expectBodyStartsWith string
expectBodyNotContains string
expectStatus int
}{
{
name: "ok",
Expand Down Expand Up @@ -582,6 +583,22 @@ func TestGroup_StaticMultiTest(t *testing.T) {
expectStatus: http.StatusOK,
expectBodyStartsWith: "<!doctype html>",
},
{
name: "nok, URL encoded path traversal (single encoding, slash - unix separator)",
givenRoot: "_fixture/dist/public",
whenURL: "/%2e%2e%2fprivate.txt",
expectStatus: http.StatusNotFound,
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
expectBodyNotContains: `private file`,
},
{
name: "nok, URL encoded path traversal (single encoding, backslash - windows separator)",
givenRoot: "_fixture/dist/public",
whenURL: "/%2e%2e%5cprivate.txt",
expectStatus: http.StatusNotFound,
expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
expectBodyNotContains: `private file`,
},
{
name: "do not allow directory traversal (backslash - windows separator)",
givenPrefix: "/",
Expand Down Expand Up @@ -618,6 +635,9 @@ func TestGroup_StaticMultiTest(t *testing.T) {
} else {
assert.Equal(t, "", body)
}
if tc.expectBodyNotContains != "" {
assert.NotContains(t, body, tc.expectBodyNotContains)
}

if tc.expectHeaderLocation != "" {
assert.Equal(t, tc.expectHeaderLocation, rec.Result().Header["Location"][0])
Expand Down
47 changes: 35 additions & 12 deletions middleware/static.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"path"
"strconv"
"strings"
"sync"

"github.com/labstack/echo/v5"
)
Expand Down Expand Up @@ -118,13 +119,12 @@ const directoryListHTMLTemplate = `
</header>
<ul>
{{ range .Files }}
{{ $href := .Name }}{{ if ne $.Name "/" }}{{ $href = print $.Name "/" .Name }}{{ end }}
<li>
{{ if .Dir }}
{{ $name := print .Name "/" }}
<a class="dir" href="{{ $href }}">{{ $name }}</a>
<a class="dir" href="{{ $name }}">{{ $name }}</a>
{{ else }}
<a class="file" href="{{ $href }}">{{ .Name }}</a>
<a class="file" href="{{ .Name }}">{{ .Name }}</a>
<span>{{ .Size }}</span>
{{ end }}
</li>
Expand Down Expand Up @@ -157,7 +157,10 @@ func (config StaticConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
// Defaults
if config.Root == "" {
config.Root = "." // For security we want to restrict to CWD.
} else {
config.Root = path.Clean(config.Root) // fs.Open is very picky about ``, `.`, `..` in paths, so remove some of them up.
}

if config.Skipper == nil {
config.Skipper = DefaultStaticConfig.Skipper
}
Expand All @@ -173,6 +176,19 @@ func (config StaticConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
return nil, fmt.Errorf("echo static middleware directory list template parsing error: %w", tErr)
}

var once *sync.Once
var fsErr error
currentFS := config.Filesystem
if config.Filesystem == nil {
once = &sync.Once{}
} else if config.Root != "." {
tmpFs, fErr := fs.Sub(config.Filesystem, path.Join(".", config.Root))
if fErr != nil {
return nil, fmt.Errorf("static middleware failed to create sub-filesystem from config.Root, error: %w", fErr)
}
currentFS = tmpFs
}

return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) (err error) {
if config.Skipper(c) {
Expand All @@ -197,8 +213,7 @@ func (config StaticConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
// 3. The "/" prefix forces absolute path interpretation, removing ".." components
// 4. Backslashes are treated as literal characters (not path separators), preventing traversal
// See static_windows.go for Go 1.20+ filepath.Clean compatibility notes
requestedPath := path.Clean("/" + p) // "/"+ for security
filePath := path.Join(config.Root, requestedPath)
filePath := path.Clean("./" + p)

if config.IgnoreBase {
routePath := path.Base(strings.TrimRight(c.Path(), "/*"))
Expand All @@ -209,9 +224,17 @@ func (config StaticConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
}
}

currentFS := config.Filesystem
if currentFS == nil {
currentFS = c.Echo().Filesystem
if once != nil {
once.Do(func() {
if tmp, tmpErr := fs.Sub(c.Echo().Filesystem, config.Root); tmpErr != nil {
fsErr = fmt.Errorf("static middleware failed to create sub-filesystem: %w", tmpErr)
} else {
currentFS = tmp
}
})
if fsErr != nil {
return fsErr
}
}

file, err := currentFS.Open(filePath)
Expand All @@ -231,7 +254,7 @@ func (config StaticConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
return err
}
// is case HTML5 mode is enabled + echo 404 we serve index to the client
file, err = currentFS.Open(path.Join(config.Root, config.Index))
file, err = currentFS.Open(config.Index)
if err != nil {
return err
}
Expand All @@ -248,7 +271,7 @@ func (config StaticConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
index, err := currentFS.Open(path.Join(filePath, config.Index))
if err != nil {
if config.Browse {
return listDir(dirListTemplate, requestedPath, filePath, currentFS, c.Response())
return listDir(dirListTemplate, filePath, currentFS, c.Response())
}

return next(c)
Expand Down Expand Up @@ -278,7 +301,7 @@ func serveFile(c *echo.Context, file fs.File, info os.FileInfo) error {
return nil
}

func listDir(t *template.Template, requestedPath string, pathInFs string, filesystem fs.FS, res http.ResponseWriter) error {
func listDir(t *template.Template, pathInFs string, filesystem fs.FS, res http.ResponseWriter) error {
files, err := fs.ReadDir(filesystem, pathInFs)
if err != nil {
return fmt.Errorf("static middleware failed to read directory for listing: %w", err)
Expand All @@ -290,7 +313,7 @@ func listDir(t *template.Template, requestedPath string, pathInFs string, filesy
Name string
Files []any
}{
Name: requestedPath,
Name: pathInFs,
}

for _, f := range files {
Expand Down
21 changes: 18 additions & 3 deletions middleware/static_other.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors

//go:build !windows

package middleware

import (
"errors"
"io/fs"
"os"
)

// We ignore these errors as there could be handler that matches request path.
func isIgnorableOpenFileError(err error) bool {
return os.IsNotExist(err)
if os.IsNotExist(err) {
return true
}
// As of Go 1.20 Windows path checks are more strict on the provided path and considers [UNC](https://en.wikipedia.org/wiki/Path_(computing)#UNC)
// paths with missing host etc parts as invalid. Previously it would result you `fs.ErrNotExist`.
// Also `fs.Open` on all OSes does not accept ``, `.`, `..` at all.
//
// so we need to treat those errors the same as `fs.ErrNotExists` so we can continue handling
// errors in the middleware/handler chain. Otherwise we might end up with status 500 instead of finding a route
// or return 404 not found.
var pErr *fs.PathError
if errors.As(err, &pErr) {
err = pErr.Err
return err.Error() == "invalid argument"
}
return false
}
Loading
Loading