Repository: labstack/echo
Pull request #2254 (2022-08-25 19:30:19)

error handling on closing body


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fcb2ff..e8f4220 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
 # Changelog
 
+## v4.9.0 - 2022-09-04
+
+**Security**
+
+* Fix open redirect vulnerability in handlers serving static directories (e.Static, e.StaticFs, echo.StaticDirectoryHandler) [#2260](https://github.com/labstack/echo/pull/2260)
+
+**Enhancements**
+
+* Allow configuring ErrorHandler in CSRF middleware [#2257](https://github.com/labstack/echo/pull/2257)
+* Replace HTTP method constants in tests with stdlib constants [#2247](https://github.com/labstack/echo/pull/2247)
+
+
 ## v4.8.0 - 2022-08-10
 
 **Most notable things**
diff --git a/context_fs.go b/context_fs.go
index 11ee84b..1038f89 100644
--- a/context_fs.go
+++ b/context_fs.go
@@ -1,33 +1,49 @@
-//go:build !go1.16
-// +build !go1.16
-
 package echo
 
 import (
+	"errors"
+	"io"
+	"io/fs"
 	"net/http"
-	"os"
 	"path/filepath"
 )
 
-func (c *context) File(file string) (err error) {
-	f, err := os.Open(file)
+func (c *context) File(file string) error {
+	return fsFile(c, file, c.echo.Filesystem)
+}
+
+// FileFS serves file from given file system.
+//
+// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
+// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
+// including `assets/images` as their prefix.
+func (c *context) FileFS(file string, filesystem fs.FS) error {
+	return fsFile(c, file, filesystem)
+}
+
+func fsFile(c Context, file string, filesystem fs.FS) error {
+	f, err := filesystem.Open(file)
 	if err != nil {
-		return NotFoundHandler(c)
+		return ErrNotFound
 	}
 	defer f.Close()
 
 	fi, _ := f.Stat()
 	if fi.IsDir() {
-		file = filepath.Join(file, indexPage)
-		f, err = os.Open(file)
+		file = filepath.ToSlash(filepath.Join(file, indexPage)) // ToSlash is necessary for Windows. fs.Open and os.Open are different in that aspect.
+		f, err = filesystem.Open(file)
 		if err != nil {
-			return NotFoundHandler(c)
+			return ErrNotFound
 		}
 		defer f.Close()
 		if fi, err = f.Stat(); err != nil {
-			return
+			return err
 		}
 	}
-	http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), f)
-	return
+	ff, ok := f.(io.ReadSeeker)
+	if !ok {
+		return errors.New("file does not implement io.ReadSeeker")
+	}
+	http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), ff)
+	return nil
 }
diff --git a/context_fs_go1.16.go b/context_fs_go1.16.go
deleted file mode 100644
index c1c724a..0000000
--- a/context_fs_go1.16.go
+++ /dev/null
@@ -1,52 +0,0 @@
-//go:build go1.16
-// +build go1.16
-
-package echo
-
-import (
-	"errors"
-	"io"
-	"io/fs"
-	"net/http"
-	"path/filepath"
-)
-
-func (c *context) File(file string) error {
-	return fsFile(c, file, c.echo.Filesystem)
-}
-
-// FileFS serves file from given file system.
-//
-// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
-// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
-// including `assets/images` as their prefix.
-func (c *context) FileFS(file string, filesystem fs.FS) error {
-	return fsFile(c, file, filesystem)
-}
-
-func fsFile(c Context, file string, filesystem fs.FS) error {
-	f, err := filesystem.Open(file)
-	if err != nil {
-		return ErrNotFound
-	}
-	defer f.Close()
-
-	fi, _ := f.Stat()
-	if fi.IsDir() {
-		file = filepath.ToSlash(filepath.Join(file, indexPage)) // ToSlash is necessary for Windows. fs.Open and os.Open are different in that aspect.
-		f, err = filesystem.Open(file)
-		if err != nil {
-			return ErrNotFound
-		}
-		defer f.Close()
-		if fi, err = f.Stat(); err != nil {
-			return err
-		}
-	}
-	ff, ok := f.(io.ReadSeeker)
-	if !ok {
-		return errors.New("file does not implement io.ReadSeeker")
-	}
-	http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), ff)
-	return nil
-}
diff --git a/echo.go b/echo.go
index 291c404..5ae8a14 100644
--- a/echo.go
+++ b/echo.go
@@ -248,7 +248,7 @@ const (
 
 const (
 	// Version of Echo
-	Version = "4.8.0"
+	Version = "4.9.0"
 	website = "https://echo.labstack.com"
 	// http://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Echo
 	banner = `
diff --git a/echo_fs.go b/echo_fs.go
index c379054..b8526da 100644
--- a/echo_fs.go
+++ b/echo_fs.go
@@ -1,62 +1,175 @@
-//go:build !go1.16
-// +build !go1.16
-
 package echo
 
 import (
+	"fmt"
+	"io/fs"
 	"net/http"
 	"net/url"
 	"os"
 	"path/filepath"
+	"runtime"
+	"strings"
 )
 
 type filesystem struct {
+	// Filesystem is file system used by Static and File handlers to access files.
+	// Defaults to os.DirFS(".")
+	//
+	// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
+	// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
+	// including `assets/images` as their prefix.
+	Filesystem fs.FS
 }
 
 func createFilesystem() filesystem {
-	return filesystem{}
+	return filesystem{
+		Filesystem: newDefaultFS(),
+	}
 }
 
-// Static registers a new route with path prefix to serve static files from the
-// provided root directory.
-func (e *Echo) Static(prefix, root string) *Route {
-	if root == "" {
-		root = "." // For security we want to restrict to CWD.
-	}
-	return e.static(prefix, root, e.GET)
+// Static registers a new route with path prefix to serve static files from the provided root directory.
+func (e *Echo) Static(pathPrefix, fsRoot string) *Route {
+	subFs := MustSubFS(e.Filesystem, fsRoot)
+	return e.Add(
+		http.MethodGet,
+		pathPrefix+"*",
+		StaticDirectoryHandler(subFs, false),
+	)
 }
 
-func (common) static(prefix, root string, get func(string, HandlerFunc, ...MiddlewareFunc) *Route) *Route {
-	h := func(c Context) error {
-		p, err := url.PathUnescape(c.Param("*"))
-		if err != nil {
-			return err
+// StaticFS registers a new route with path prefix to serve static files from the provided file system.
+//
+// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
+// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
+// including `assets/images` as their prefix.
+func (e *Echo) StaticFS(pathPrefix string, filesystem fs.FS) *Route {
+	return e.Add(
+		http.MethodGet,
+		pathPrefix+"*",
+		StaticDirectoryHandler(filesystem, false),
+	)
+}
+
+// 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.
+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
+			tmpPath, err := url.PathUnescape(p)
+			if err != nil {
+				return fmt.Errorf("failed to unescape path variable: %w", err)
+			}
+			p = tmpPath
 		}
 
-		name := filepath.Join(root, filepath.Clean("/"+p)) // "/"+ for security
-		fi, err := os.Stat(name)
+		// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
+		name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
+		fi, err := fs.Stat(fileSystem, name)
 		if err != nil {
-			// The access path does not exist
-			return NotFoundHandler(c)
+			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 fi.IsDir() && p[len(p)-1] != '/' {
+		if fi.IsDir() && len(p) > 0 && p[len(p)-1] != '/' {
 			// Redirect to ends with "/"
-			return c.Redirect(http.StatusMovedPermanently, p+"/")
+			return c.Redirect(http.StatusMovedPermanently, sanitizeURI(p+"/"))
 		}
-		return c.File(name)
-	}
-	// Handle added routes based on trailing slash:
-	// 	/prefix  => exact route "/prefix" + any route "/prefix/*"
-	// 	/prefix/ => only any route "/prefix/*"
-	if prefix != "" {
-		if prefix[len(prefix)-1] == '/' {
-			// Only add any route for intentional trailing slash
-			return get(prefix+"*", h)
+		return fsFile(c, name, fileSystem)
+	}
+}
+
+// FileFS registers a new route with path to serve file from the provided file system.
+func (e *Echo) FileFS(path, file string, filesystem fs.FS, m ...MiddlewareFunc) *Route {
+	return e.GET(path, StaticFileHandler(file, filesystem), m...)
+}
+
+// StaticFileHandler creates handler function to serve file from provided file system
+func StaticFileHandler(file string, filesystem fs.FS) HandlerFunc {
+	return func(c Context) error {
+		return fsFile(c, file, filesystem)
+	}
+}
+
+// defaultFS exists to preserve pre v4.7.0 behaviour where files were open by `os.Open`.
+// v4.7 introduced `echo.Filesystem` field which is Go1.16+ `fs.Fs` interface.
+// Difference between `os.Open` and `fs.Open` is that FS does not allow opening path that start with `.`, `..` or `/`
+// etc. For example previously you could have `../images` in your application but `fs := os.DirFS("./")` would not
+// allow you to use `fs.Open("../images")` and this would break all old applications that rely on being able to
+// traverse up from current executable run path.
+// NB: private because you really should use fs.FS implementation instances
+type defaultFS struct {
+	prefix string
+	fs     fs.FS
+}
+
+func newDefaultFS() *defaultFS {
+	dir, _ := os.Getwd()
+	return &defaultFS{
+		prefix: dir,
+		fs:     nil,
+	}
+}
+
+func (fs defaultFS) Open(name string) (fs.File, error) {
+	if fs.fs == nil {
+		return os.Open(name)
+	}
+	return fs.fs.Open(name)
+}
+
+func subFS(currentFs fs.FS, root string) (fs.FS, error) {
+	root = filepath.ToSlash(filepath.Clean(root)) // note: fs.FS operates only with slashes. `ToSlash` is necessary for Windows
+	if dFS, ok := currentFs.(*defaultFS); ok {
+		// we need to make exception for `defaultFS` instances as it interprets root prefix differently from fs.FS.
+		// fs.Fs.Open does not like relative paths ("./", "../") and absolute paths at all but prior echo.Filesystem we
+		// were able to use paths like `./myfile.log`, `/etc/hosts` and these would work fine with `os.Open` but not with fs.Fs
+		if isRelativePath(root) {
+			root = filepath.Join(dFS.prefix, root)
 		}
-		get(prefix, h)
+		return &defaultFS{
+			prefix: root,
+			fs:     os.DirFS(root),
+		}, nil
+	}
+	return fs.Sub(currentFs, root)
+}
+
+func isRelativePath(path string) bool {
+	if path == "" {
+		return true
+	}
+	if path[0] == '/' {
+		return false
+	}
+	if runtime.GOOS == "windows" && strings.IndexByte(path, ':') != -1 {
+		// https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file?redirectedfrom=MSDN#file_and_directory_names
+		// https://docs.microsoft.com/en-us/dotnet/standard/io/file-path-formats
+		return false
+	}
+	return true
+}
+
+// MustSubFS creates sub FS from current filesystem or panic on failure.
+// Panic happens when `fsRoot` contains invalid path according to `fs.ValidPath` rules.
+//
+// MustSubFS is helpful when dealing with `embed.FS` because for example `//go:embed assets/images` embeds files with
+// paths including `assets/images` as their prefix. In that case use `fs := echo.MustSubFS(fs, "rootDirectory") to
+// create sub fs which uses necessary prefix for directory path.
+func MustSubFS(currentFs fs.FS, fsRoot string) fs.FS {
+	subFs, err := subFS(currentFs, fsRoot)
+	if err != nil {
+		panic(fmt.Errorf("can not create sub FS, invalid root given, err: %w", err))
+	}
+	return subFs
+}
+
+func sanitizeURI(uri string) string {
+	// double slash `\\`, `//` or even `\/` is absolute uri for browsers and by redirecting request to that uri
+	// we are vulnerable to open redirect attack. so replace all slashes from the beginning with single slash
+	if len(uri) > 1 && (uri[0] == '\\' || uri[0] == '/') && (uri[1] == '\\' || uri[1] == '/') {
+		uri = "/" + strings.TrimLeft(uri, `/\`)
 	}
-	return get(prefix+"/*", h)
+	return uri
 }
diff --git a/echo_fs_go1.16.go b/echo_fs_go1.16.go
deleted file mode 100644
index eb17768..0000000
--- a/echo_fs_go1.16.go
+++ /dev/null
@@ -1,169 +0,0 @@
-//go:build go1.16
-// +build go1.16
-
-package echo
-
-import (
-	"fmt"
-	"io/fs"
-	"net/http"
-	"net/url"
-	"os"
-	"path/filepath"
-	"runtime"
-	"strings"
-)
-
-type filesystem struct {
-	// Filesystem is file system used by Static and File handlers to access files.
-	// Defaults to os.DirFS(".")
-	//
-	// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
-	// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
-	// including `assets/images` as their prefix.
-	Filesystem fs.FS
-}
-
-func createFilesystem() filesystem {
-	return filesystem{
-		Filesystem: newDefaultFS(),
-	}
-}
-
-// Static registers a new route with path prefix to serve static files from the provided root directory.
-func (e *Echo) Static(pathPrefix, fsRoot string) *Route {
-	subFs := MustSubFS(e.Filesystem, fsRoot)
-	return e.Add(
-		http.MethodGet,
-		pathPrefix+"*",
-		StaticDirectoryHandler(subFs, false),
-	)
-}
-
-// StaticFS registers a new route with path prefix to serve static files from the provided file system.
-//
-// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
-// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
-// including `assets/images` as their prefix.
-func (e *Echo) StaticFS(pathPrefix string, filesystem fs.FS) *Route {
-	return e.Add(
-		http.MethodGet,
-		pathPrefix+"*",
-		StaticDirectoryHandler(filesystem, false),
-	)
-}
-
-// 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.
-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
-			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
-		name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
-		fi, err := fs.Stat(fileSystem, name)
-		if err != nil {
-			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 fi.IsDir() && len(p) > 0 && p[len(p)-1] != '/' {
-			// Redirect to ends with "/"
-			return c.Redirect(http.StatusMovedPermanently, p+"/")
-		}
-		return fsFile(c, name, fileSystem)
-	}
-}
-
-// FileFS registers a new route with path to serve file from the provided file system.
-func (e *Echo) FileFS(path, file string, filesystem fs.FS, m ...MiddlewareFunc) *Route {
-	return e.GET(path, StaticFileHandler(file, filesystem), m...)
-}
-
-// StaticFileHandler creates handler function to serve file from provided file system
-func StaticFileHandler(file string, filesystem fs.FS) HandlerFunc {
-	return func(c Context) error {
-		return fsFile(c, file, filesystem)
-	}
-}
-
-// defaultFS exists to preserve pre v4.7.0 behaviour where files were open by `os.Open`.
-// v4.7 introduced `echo.Filesystem` field which is Go1.16+ `fs.Fs` interface.
-// Difference between `os.Open` and `fs.Open` is that FS does not allow opening path that start with `.`, `..` or `/`
-// etc. For example previously you could have `../images` in your application but `fs := os.DirFS("./")` would not
-// allow you to use `fs.Open("../images")` and this would break all old applications that rely on being able to
-// traverse up from current executable run path.
-// NB: private because you really should use fs.FS implementation instances
-type defaultFS struct {
-	prefix string
-	fs     fs.FS
-}
-
-func newDefaultFS() *defaultFS {
-	dir, _ := os.Getwd()
-	return &defaultFS{
-		prefix: dir,
-		fs:     nil,
-	}
-}
-
-func (fs defaultFS) Open(name string) (fs.File, error) {
-	if fs.fs == nil {
-		return os.Open(name)
-	}
-	return fs.fs.Open(name)
-}
-
-func subFS(currentFs fs.FS, root string) (fs.FS, error) {
-	root = filepath.ToSlash(filepath.Clean(root)) // note: fs.FS operates only with slashes. `ToSlash` is necessary for Windows
-	if dFS, ok := currentFs.(*defaultFS); ok {
-		// we need to make exception for `defaultFS` instances as it interprets root prefix differently from fs.FS.
-		// fs.Fs.Open does not like relative paths ("./", "../") and absolute paths at all but prior echo.Filesystem we
-		// were able to use paths like `./myfile.log`, `/etc/hosts` and these would work fine with `os.Open` but not with fs.Fs
-		if isRelativePath(root) {
-			root = filepath.Join(dFS.prefix, root)
-		}
-		return &defaultFS{
-			prefix: root,
-			fs:     os.DirFS(root),
-		}, nil
-	}
-	return fs.Sub(currentFs, root)
-}
-
-func isRelativePath(path string) bool {
-	if path == "" {
-		return true
-	}
-	if path[0] == '/' {
-		return false
-	}
-	if runtime.GOOS == "windows" && strings.IndexByte(path, ':') != -1 {
-		// https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file?redirectedfrom=MSDN#file_and_directory_names
-		// https://docs.microsoft.com/en-us/dotnet/standard/io/file-path-formats
-		return false
-	}
-	return true
-}
-
-// MustSubFS creates sub FS from current filesystem or panic on failure.
-// Panic happens when `fsRoot` contains invalid path according to `fs.ValidPath` rules.
-//
-// MustSubFS is helpful when dealing with `embed.FS` because for example `//go:embed assets/images` embeds files with
-// paths including `assets/images` as their prefix. In that case use `fs := echo.MustSubFS(fs, "rootDirectory") to
-// create sub fs which uses necessary prefix for directory path.
-func MustSubFS(currentFs fs.FS, fsRoot string) fs.FS {
-	subFs, err := subFS(currentFs, fsRoot)
-	if err != nil {
-		panic(fmt.Errorf("can not create sub FS, invalid root given, err: %w", err))
-	}
-	return subFs
-}
diff --git a/group_fs.go b/group_fs.go
index 0a1ce4a..aedc4c6 100644
--- a/group_fs.go
+++ b/group_fs.go
@@ -1,9 +1,30 @@
-//go:build !go1.16
-// +build !go1.16
-
 package echo
 
+import (
+	"io/fs"
+	"net/http"
+)
+
 // Static implements `Echo#Static()` for sub-routes within the Group.
-func (g *Group) Static(prefix, root string) {
-	g.static(prefix, root, g.GET)
+func (g *Group) Static(pathPrefix, fsRoot string) {
+	subFs := MustSubFS(g.echo.Filesystem, fsRoot)
+	g.StaticFS(pathPrefix, subFs)
+}
+
+// StaticFS implements `Echo#StaticFS()` for sub-routes within the Group.
+//
+// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
+// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
+// including `assets/images` as their prefix.
+func (g *Group) StaticFS(pathPrefix string, filesystem fs.FS) {
+	g.Add(
+		http.MethodGet,
+		pathPrefix+"*",
+		StaticDirectoryHandler(filesystem, false),
+	)
+}
+
+// FileFS implements `Echo#FileFS()` for sub-routes within the Group.
+func (g *Group) FileFS(path, file string, filesystem fs.FS, m ...MiddlewareFunc) *Route {
+	return g.GET(path, StaticFileHandler(file, filesystem), m...)
 }
diff --git a/group_fs_go1.16.go b/group_fs_go1.16.go
deleted file mode 100644
index 2ba52b5..0000000
--- a/group_fs_go1.16.go
+++ /dev/null
@@ -1,33 +0,0 @@
-//go:build go1.16
-// +build go1.16
-
-package echo
-
-import (
-	"io/fs"
-	"net/http"
-)
-
-// Static implements `Echo#Static()` for sub-routes within the Group.
-func (g *Group) Static(pathPrefix, fsRoot string) {
-	subFs := MustSubFS(g.echo.Filesystem, fsRoot)
-	g.StaticFS(pathPrefix, subFs)
-}
-
-// StaticFS implements `Echo#StaticFS()` for sub-routes within the Group.
-//
-// When dealing with `embed.FS` use `fs := echo.MustSubFS(fs, "rootDirectory") to create sub fs which uses necessary
-// prefix for directory path. This is necessary as `//go:embed assets/images` embeds files with paths
-// including `assets/images` as their prefix.
-func (g *Group) StaticFS(pathPrefix string, filesystem fs.FS) {
-	g.Add(
-		http.MethodGet,
-		pathPrefix+"*",
-		StaticDirectoryHandler(filesystem, false),
-	)
-}
-
-// FileFS implements `Echo#FileFS()` for sub-routes within the Group.
-func (g *Group) FileFS(path, file string, filesystem fs.FS, m ...MiddlewareFunc) *Route {
-	return g.GET(path, StaticFileHandler(file, filesystem), m...)
-}
diff --git a/middleware/cors.go b/middleware/cors.go
index 1625951..25cf983 100644
--- a/middleware/cors.go
+++ b/middleware/cors.go
@@ -15,46 +15,85 @@ type (
 		// Skipper defines a function to skip middleware.
 		Skipper Skipper
 
-		// AllowOrigin defines a list of origins that may access the resource.
+		// AllowOrigins determines the value of the Access-Control-Allow-Origin
+		// response header.  This header defines a list of origins that may access the
+		// resource.  The wildcard characters '*' and '?' are supported and are
+		// converted to regex fragments '.*' and '.' accordingly.
+		//
+		// Security: use extreme caution when handling the origin, and carefully
+		// validate any logic. Remember that attackers may register hostile domain names.
+		// See https://blog.portswigger.net/2016/10/exploiting-cors-misconfigurations-for.html
+		//
 		// Optional. Default value []string{"*"}.
+		//
+		// See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
 		AllowOrigins []string `yaml:"allow_origins"`
 
 		// AllowOriginFunc is a custom function to validate the origin. It takes the
 		// origin as an argument and returns true if allowed or false otherwise. If
 		// an error is returned, it is returned by the handler. If this option is
 		// set, AllowOrigins is ignored.
+		//
+		// Security: use extreme caution when handling the origin, and carefully
+		// validate any logic. Remember that attackers may register hostile domain names.
+		// See https://blog.portswigger.net/2016/10/exploiting-cors-misconfigurations-for.html
+		//
 		// Optional.
 		AllowOriginFunc func(origin string) (bool, error) `yaml:"allow_origin_func"`
 
-		// AllowMethods defines a list methods allowed when accessing the resource.
-		// This is used in response to a preflight request.
+		// AllowMethods determines the value of the Access-Control-Allow-Methods
+		// response header.  This header specified the list of methods allowed when
+		// accessing the resource.  This is used in response to a preflight request.
+		//
 		// Optional. Default value DefaultCORSConfig.AllowMethods.
-		// If `allowMethods` is left empty will fill for preflight request `Access-Control-Allow-Methods` header value
+		// If `allowMethods` is left empty, this middleware will fill for preflight
+		// request `Access-Control-Allow-Methods` header value
 		// from `Allow` header that echo.Router set into context.
+		//
+		// See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
 		AllowMethods []string `yaml:"allow_methods"`
 
-		// AllowHeaders defines a list of request headers that can be used when
-		// making the actual request. This is in response to a preflight request.
+		// AllowHeaders determines the value of the Access-Control-Allow-Headers
+		// response header.  This header is used in response to a preflight request to
+		// indicate which HTTP headers can be used when making the actual request.
+		//
 		// Optional. Default value []string{}.
+		//
+		// See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
 		AllowHeaders []string `yaml:"allow_headers"`
 
-		// AllowCredentials indicates whether or not the response to the request
-		// can be exposed when the credentials flag is true. When used as part of
-		// a response to a preflight request, this indicates whether or not the
-		// actual request can be made using credentials.
-		// Optional. Default value false.
+		// AllowCredentials determines the value of the
+		// Access-Control-Allow-Credentials response header.  This header indicates
+		// whether or not the response to the request can be exposed when the
+		// credentials mode (Request.credentials) is true. When used as part of a
+		// response to a preflight request, this indicates whether or not the actual
+		// request can be made using credentials.  See also
+		// [MDN: Access-Control-Allow-Credentials].
+		//
+		// Optional. Default value false, in which case the header is not set.
+		//
 		// Security: avoid using `AllowCredentials = true` with `AllowOrigins = *`.
-		// See http://blog.portswigger.net/2016/10/exploiting-cors-misconfigurations-for.html
+		// See "Exploiting CORS misconfigurations for Bitcoins and bounties",
+		// https://blog.portswigger.net/2016/10/exploiting-cors-misconfigurations-for.html
+		//
+		// See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
 		AllowCredentials bool `yaml:"allow_credentials"`
 
-		// ExposeHeaders defines a whitelist headers that clients are allowed to
-		// access.
-		// Optional. Default value []string{}.
+		// ExposeHeaders determines the value of Access-Control-Expose-Headers, which
+		// defines a list of headers that clients are allowed to access.
+		//
+		// Optional. Default value []string{}, in which case the header is not set.
+		//
+		// See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Header
 		ExposeHeaders []string `yaml:"expose_headers"`
 
-		// MaxAge indicates how long (in seconds) the results of a preflight request
-		// can be cached.
-		// Optional. Default value 0.
+		// MaxAge determines the value of the Access-Control-Max-Age response header.
+		// This header indicates how long (in seconds) the results of a preflight
+		// request can be cached.
+		//
+		// Optional. Default value 0.  The header is set only if MaxAge > 0.
+		//
+		// See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
 		MaxAge int `yaml:"max_age"`
 	}
 )
@@ -69,13 +108,22 @@ var (
 )
 
 // CORS returns a Cross-Origin Resource Sharing (CORS) middleware.
-// See: https://developer.mozilla.org/en/docs/Web/HTTP/Access_control_CORS
+// See also [MDN: Cross-Origin Resource Sharing (CORS)].
+//
+// Security: Poorly configured CORS can compromise security because it allows
+// relaxation of the browser's Same-Origin policy.  See [Exploiting CORS
+// misconfigurations for Bitcoins and bounties] and [Portswigger: Cross-origin
+// resource sharing (CORS)] for more details.
+//
+// [MDN: Cross-Origin Resource Sharing (CORS)]: https://developer.mozilla.org/en/docs/Web/HTTP/Access_control_CORS
+// [Exploiting CORS misconfigurations for Bitcoins and bounties]: https://blog.portswigger.net/2016/10/exploiting-cors-misconfigurations-for.html
+// [Portswigger: Cross-origin resource sharing (CORS)]: https://portswigger.net/web-security/cors
 func CORS() echo.MiddlewareFunc {
 	return CORSWithConfig(DefaultCORSConfig)
 }
 
 // CORSWithConfig returns a CORS middleware with config.
-// See: `CORS()`.
+// See: [CORS].
 func CORSWithConfig(config CORSConfig) echo.MiddlewareFunc {
 	// Defaults
 	if config.Skipper == nil {
diff --git a/middleware/csrf.go b/middleware/csrf.go
index 61299f5..ea90fdb 100644
--- a/middleware/csrf.go
+++ b/middleware/csrf.go
@@ -61,7 +61,13 @@ type (
 		// Indicates SameSite mode of the CSRF cookie.
 		// Optional. Default value SameSiteDefaultMode.
 		CookieSameSite http.SameSite `yaml:"cookie_same_site"`
+
+		// ErrorHandler defines a function which is executed for returning custom errors.
+		ErrorHandler CSRFErrorHandler
 	}
+
+	// CSRFErrorHandler is a function which is executed for creating custom errors.
+	CSRFErrorHandler func(err error, c echo.Context) error
 )
 
 // ErrCSRFInvalid is returned when CSRF check fails
@@ -154,8 +160,9 @@ func CSRFWithConfig(config CSRFConfig) echo.MiddlewareFunc {
 						lastTokenErr = ErrCSRFInvalid
 					}
 				}
+				var finalErr error
 				if lastTokenErr != nil {
-					return lastTokenErr
+					finalErr = lastTokenErr
 				} else if lastExtractorErr != nil {
 					// ugly part to preserve backwards compatible errors. someone could rely on them
 					if lastExtractorErr == errQueryExtractorValueMissing {
@@ -167,7 +174,14 @@ func CSRFWithConfig(config CSRFConfig) echo.MiddlewareFunc {
 					} else {
 						lastExtractorErr = echo.NewHTTPError(http.StatusBadRequest, lastExtractorErr.Error())
 					}
-					return lastExtractorErr
+					finalErr = lastExtractorErr
+				}
+
+				if finalErr != nil {
+					if config.ErrorHandler != nil {
+						return config.ErrorHandler(finalErr, c)
+					}
+					return finalErr
 				}
 			}
 


Test patch:
diff --git a/binder_go1.15_test.go b/binder_go1.15_test.go
deleted file mode 100644
index 018628c..0000000
--- a/binder_go1.15_test.go
+++ /dev/null
@@ -1,265 +0,0 @@
-// +build go1.15
-
-package echo
-
-/**
-	Since version 1.15 time.Time and time.Duration error message pattern has changed (values are wrapped now in \"\")
-	So pre 1.15 these tests fail with similar error:
-
-  expected: "code=400, message=failed to bind field value to Duration, internal=time: invalid duration \"nope\", field=param"
-  actual  : "code=400, message=failed to bind field value to Duration, internal=time: invalid duration nope, field=param"
-*/
-
-import (
-	"errors"
-	"github.com/stretchr/testify/assert"
-	"io"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-	"time"
-)
-
-func createTestContext15(URL string, body io.Reader, pathParams map[string]string) Context {
-	e := New()
-	req := httptest.NewRequest(http.MethodGet, URL, body)
-	if body != nil {
-		req.Header.Set(HeaderContentType, MIMEApplicationJSON)
-	}
-	rec := httptest.NewRecorder()
-	c := e.NewContext(req, rec)
-
-	if len(pathParams) > 0 {
-		names := make([]string, 0)
-		values := make([]string, 0)
-		for name, value := range pathParams {
-			names = append(names, name)
-			values = append(values, value)
-		}
-		c.SetParamNames(names...)
-		c.SetParamValues(values...)
-	}
-
-	return c
-}
-
-func TestValueBinder_TimeError(t *testing.T) {
-	var testCases = []struct {
-		name            string
-		givenFailFast   bool
-		givenBindErrors []error
-		whenURL         string
-		whenMust        bool
-		whenLayout      string
-		expectValue     time.Time
-		expectError     string
-	}{
-		{
-			name:        "nok, conversion fails, value is not changed",
-			whenURL:     "/search?param=nope&param=100",
-			expectValue: time.Time{},
-			expectError: "code=400, message=failed to bind field value to Time, internal=parsing time \"nope\": extra text: \"nope\", field=param",
-		},
-		{
-			name:        "nok (must), conversion fails, value is not changed",
-			whenMust:    true,
-			whenURL:     "/search?param=nope&param=100",
-			expectValue: time.Time{},
-			expectError: "code=400, message=failed to bind field value to Time, internal=parsing time \"nope\": extra text: \"nope\", field=param",
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			c := createTestContext15(tc.whenURL, nil, nil)
-			b := QueryParamsBinder(c).FailFast(tc.givenFailFast)
-			if tc.givenFailFast {
-				b.errors = []error{errors.New("previous error")}
-			}
-
-			dest := time.Time{}
-			var err error
-			if tc.whenMust {
-				err = b.MustTime("param", &dest, tc.whenLayout).BindError()
-			} else {
-				err = b.Time("param", &dest, tc.whenLayout).BindError()
-			}
-
-			assert.Equal(t, tc.expectValue, dest)
-			if tc.expectError != "" {
-				assert.EqualError(t, err, tc.expectError)
-			} else {
-				assert.NoError(t, err)
-			}
-		})
-	}
-}
-
-func TestValueBinder_TimesError(t *testing.T) {
-	var testCases = []struct {
-		name            string
-		givenFailFast   bool
-		givenBindErrors []error
-		whenURL         string
-		whenMust        bool
-		whenLayout      string
-		expectValue     []time.Time
-		expectError     string
-	}{
-		{
-			name:          "nok, fail fast without binding value",
-			givenFailFast: true,
-			whenURL:       "/search?param=1&param=100",
-			expectValue:   []time.Time(nil),
-			expectError:   "code=400, message=failed to bind field value to Time, internal=parsing time \"1\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"1\" as \"2006\", field=param",
-		},
-		{
-			name:        "nok, conversion fails, value is not changed",
-			whenURL:     "/search?param=nope&param=100",
-			expectValue: []time.Time(nil),
-			expectError: "code=400, message=failed to bind field value to Time, internal=parsing time \"nope\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"nope\" as \"2006\", field=param",
-		},
-		{
-			name:        "nok (must), conversion fails, value is not changed",
-			whenMust:    true,
-			whenURL:     "/search?param=nope&param=100",
-			expectValue: []time.Time(nil),
-			expectError: "code=400, message=failed to bind field value to Time, internal=parsing time \"nope\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"nope\" as \"2006\", field=param",
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			c := createTestContext15(tc.whenURL, nil, nil)
-			b := QueryParamsBinder(c).FailFast(tc.givenFailFast)
-			b.errors = tc.givenBindErrors
-
-			layout := time.RFC3339
-			if tc.whenLayout != "" {
-				layout = tc.whenLayout
-			}
-
-			var dest []time.Time
-			var err error
-			if tc.whenMust {
-				err = b.MustTimes("param", &dest, layout).BindError()
-			} else {
-				err = b.Times("param", &dest, layout).BindError()
-			}
-
-			assert.Equal(t, tc.expectValue, dest)
-			if tc.expectError != "" {
-				assert.EqualError(t, err, tc.expectError)
-			} else {
-				assert.NoError(t, err)
-			}
-		})
-	}
-}
-
-func TestValueBinder_DurationError(t *testing.T) {
-	var testCases = []struct {
-		name            string
-		givenFailFast   bool
-		givenBindErrors []error
-		whenURL         string
-		whenMust        bool
-		expectValue     time.Duration
-		expectError     string
-	}{
-		{
-			name:        "nok, conversion fails, value is not changed",
-			whenURL:     "/search?param=nope&param=100",
-			expectValue: 0,
-			expectError: "code=400, message=failed to bind field value to Duration, internal=time: invalid duration \"nope\", field=param",
-		},
-		{
-			name:        "nok (must), conversion fails, value is not changed",
-			whenMust:    true,
-			whenURL:     "/search?param=nope&param=100",
-			expectValue: 0,
-			expectError: "code=400, message=failed to bind field value to Duration, internal=time: invalid duration \"nope\", field=param",
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			c := createTestContext15(tc.whenURL, nil, nil)
-			b := QueryParamsBinder(c).FailFast(tc.givenFailFast)
-			if tc.givenFailFast {
-				b.errors = []error{errors.New("previous error")}
-			}
-
-			var dest time.Duration
-			var err error
-			if tc.whenMust {
-				err = b.MustDuration("param", &dest).BindError()
-			} else {
-				err = b.Duration("param", &dest).BindError()
-			}
-
-			assert.Equal(t, tc.expectValue, dest)
-			if tc.expectError != "" {
-				assert.EqualError(t, err, tc.expectError)
-			} else {
-				assert.NoError(t, err)
-			}
-		})
-	}
-}
-
-func TestValueBinder_DurationsError(t *testing.T) {
-	var testCases = []struct {
-		name            string
-		givenFailFast   bool
-		givenBindErrors []error
-		whenURL         string
-		whenMust        bool
-		expectValue     []time.Duration
-		expectError     string
-	}{
-		{
-			name:          "nok, fail fast without binding value",
-			givenFailFast: true,
-			whenURL:       "/search?param=1&param=100",
-			expectValue:   []time.Duration(nil),
-			expectError:   "code=400, message=failed to bind field value to Duration, internal=time: missing unit in duration \"1\", field=param",
-		},
-		{
-			name:        "nok, conversion fails, value is not changed",
-			whenURL:     "/search?param=nope&param=100",
-			expectValue: []time.Duration(nil),
-			expectError: "code=400, message=failed to bind field value to Duration, internal=time: invalid duration \"nope\", field=param",
-		},
-		{
-			name:        "nok (must), conversion fails, value is not changed",
-			whenMust:    true,
-			whenURL:     "/search?param=nope&param=100",
-			expectValue: []time.Duration(nil),
-			expectError: "code=400, message=failed to bind field value to Duration, internal=time: invalid duration \"nope\", field=param",
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			c := createTestContext15(tc.whenURL, nil, nil)
-			b := QueryParamsBinder(c).FailFast(tc.givenFailFast)
-			b.errors = tc.givenBindErrors
-
-			var dest []time.Duration
-			var err error
-			if tc.whenMust {
-				err = b.MustDurations("param", &dest).BindError()
-			} else {
-				err = b.Durations("param", &dest).BindError()
-			}
-
-			assert.Equal(t, tc.expectValue, dest)
-			if tc.expectError != "" {
-				assert.EqualError(t, err, tc.expectError)
-			} else {
-				assert.NoError(t, err)
-			}
-		})
-	}
-}
diff --git a/binder_test.go b/binder_test.go
index 910bbfc..0b27cae 100644
--- a/binder_test.go
+++ b/binder_test.go
@@ -1,4 +1,3 @@
-// run tests as external package to get real feel for API
 package echo
 
 import (
@@ -3029,3 +3028,224 @@ func BenchmarkValueBinder_BindInt64_10_fields(b *testing.B) {
 		}
 	}
 }
+
+func TestValueBinder_TimeError(t *testing.T) {
+	var testCases = []struct {
+		name            string
+		givenFailFast   bool
+		givenBindErrors []error
+		whenURL         string
+		whenMust        bool
+		whenLayout      string
+		expectValue     time.Time
+		expectError     string
+	}{
+		{
+			name:        "nok, conversion fails, value is not changed",
+			whenURL:     "/search?param=nope&param=100",
+			expectValue: time.Time{},
+			expectError: "code=400, message=failed to bind field value to Time, internal=parsing time \"nope\": extra text: \"nope\", field=param",
+		},
+		{
+			name:        "nok (must), conversion fails, value is not changed",
+			whenMust:    true,
+			whenURL:     "/search?param=nope&param=100",
+			expectValue: time.Time{},
+			expectError: "code=400, message=failed to bind field value to Time, internal=parsing time \"nope\": extra text: \"nope\", field=param",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			c := createTestContext(tc.whenURL, nil, nil)
+			b := QueryParamsBinder(c).FailFast(tc.givenFailFast)
+			if tc.givenFailFast {
+				b.errors = []error{errors.New("previous error")}
+			}
+
+			dest := time.Time{}
+			var err error
+			if tc.whenMust {
+				err = b.MustTime("param", &dest, tc.whenLayout).BindError()
+			} else {
+				err = b.Time("param", &dest, tc.whenLayout).BindError()
+			}
+
+			assert.Equal(t, tc.expectValue, dest)
+			if tc.expectError != "" {
+				assert.EqualError(t, err, tc.expectError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestValueBinder_TimesError(t *testing.T) {
+	var testCases = []struct {
+		name            string
+		givenFailFast   bool
+		givenBindErrors []error
+		whenURL         string
+		whenMust        bool
+		whenLayout      string
+		expectValue     []time.Time
+		expectError     string
+	}{
+		{
+			name:          "nok, fail fast without binding value",
+			givenFailFast: true,
+			whenURL:       "/search?param=1&param=100",
+			expectValue:   []time.Time(nil),
+			expectError:   "code=400, message=failed to bind field value to Time, internal=parsing time \"1\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"1\" as \"2006\", field=param",
+		},
+		{
+			name:        "nok, conversion fails, value is not changed",
+			whenURL:     "/search?param=nope&param=100",
+			expectValue: []time.Time(nil),
+			expectError: "code=400, message=failed to bind field value to Time, internal=parsing time \"nope\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"nope\" as \"2006\", field=param",
+		},
+		{
+			name:        "nok (must), conversion fails, value is not changed",
+			whenMust:    true,
+			whenURL:     "/search?param=nope&param=100",
+			expectValue: []time.Time(nil),
+			expectError: "code=400, message=failed to bind field value to Time, internal=parsing time \"nope\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"nope\" as \"2006\", field=param",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			c := createTestContext(tc.whenURL, nil, nil)
+			b := QueryParamsBinder(c).FailFast(tc.givenFailFast)
+			b.errors = tc.givenBindErrors
+
+			layout := time.RFC3339
+			if tc.whenLayout != "" {
+				layout = tc.whenLayout
+			}
+
+			var dest []time.Time
+			var err error
+			if tc.whenMust {
+				err = b.MustTimes("param", &dest, layout).BindError()
+			} else {
+				err = b.Times("param", &dest, layout).BindError()
+			}
+
+			assert.Equal(t, tc.expectValue, dest)
+			if tc.expectError != "" {
+				assert.EqualError(t, err, tc.expectError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestValueBinder_DurationError(t *testing.T) {
+	var testCases = []struct {
+		name            string
+		givenFailFast   bool
+		givenBindErrors []error
+		whenURL         string
+		whenMust        bool
+		expectValue     time.Duration
+		expectError     string
+	}{
+		{
+			name:        "nok, conversion fails, value is not changed",
+			whenURL:     "/search?param=nope&param=100",
+			expectValue: 0,
+			expectError: "code=400, message=failed to bind field value to Duration, internal=time: invalid duration \"nope\", field=param",
+		},
+		{
+			name:        "nok (must), conversion fails, value is not changed",
+			whenMust:    true,
+			whenURL:     "/search?param=nope&param=100",
+			expectValue: 0,
+			expectError: "code=400, message=failed to bind field value to Duration, internal=time: invalid duration \"nope\", field=param",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			c := createTestContext(tc.whenURL, nil, nil)
+			b := QueryParamsBinder(c).FailFast(tc.givenFailFast)
+			if tc.givenFailFast {
+				b.errors = []error{errors.New("previous error")}
+			}
+
+			var dest time.Duration
+			var err error
+			if tc.whenMust {
+				err = b.MustDuration("param", &dest).BindError()
+			} else {
+				err = b.Duration("param", &dest).BindError()
+			}
+
+			assert.Equal(t, tc.expectValue, dest)
+			if tc.expectError != "" {
+				assert.EqualError(t, err, tc.expectError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestValueBinder_DurationsError(t *testing.T) {
+	var testCases = []struct {
+		name            string
+		givenFailFast   bool
+		givenBindErrors []error
+		whenURL         string
+		whenMust        bool
+		expectValue     []time.Duration
+		expectError     string
+	}{
+		{
+			name:          "nok, fail fast without binding value",
+			givenFailFast: true,
+			whenURL:       "/search?param=1&param=100",
+			expectValue:   []time.Duration(nil),
+			expectError:   "code=400, message=failed to bind field value to Duration, internal=time: missing unit in duration \"1\", field=param",
+		},
+		{
+			name:        "nok, conversion fails, value is not changed",
+			whenURL:     "/search?param=nope&param=100",
+			expectValue: []time.Duration(nil),
+			expectError: "code=400, message=failed to bind field value to Duration, internal=time: invalid duration \"nope\", field=param",
+		},
+		{
+			name:        "nok (must), conversion fails, value is not changed",
+			whenMust:    true,
+			whenURL:     "/search?param=nope&param=100",
+			expectValue: []time.Duration(nil),
+			expectError: "code=400, message=failed to bind field value to Duration, internal=time: invalid duration \"nope\", field=param",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			c := createTestContext(tc.whenURL, nil, nil)
+			b := QueryParamsBinder(c).FailFast(tc.givenFailFast)
+			b.errors = tc.givenBindErrors
+
+			var dest []time.Duration
+			var err error
+			if tc.whenMust {
+				err = b.MustDurations("param", &dest).BindError()
+			} else {
+				err = b.Durations("param", &dest).BindError()
+			}
+
+			assert.Equal(t, tc.expectValue, dest)
+			if tc.expectError != "" {
+				assert.EqualError(t, err, tc.expectError)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
diff --git a/context_fs_go1.16_test.go b/context_fs_test.go
similarity index 98%
rename from context_fs_go1.16_test.go
rename to context_fs_test.go
index 027d1c4..51346c9 100644
--- a/context_fs_go1.16_test.go
+++ b/context_fs_test.go
@@ -1,6 +1,3 @@
-//go:build go1.16
-// +build go1.16
-
 package echo
 
 import (
diff --git a/echo_fs_go1.16_test.go b/echo_fs_test.go
similarity index 95%
rename from echo_fs_go1.16_test.go
rename to echo_fs_test.go
index 07e5165..eb072a2 100644
--- a/echo_fs_go1.16_test.go
+++ b/echo_fs_test.go
@@ -1,6 +1,3 @@
-//go:build go1.16
-// +build go1.16
-
 package echo
 
 import (
@@ -139,6 +136,15 @@ func TestEcho_StaticFS(t *testing.T) {
 			expectStatus:         http.StatusNotFound,
 			expectBodyStartsWith: "{\"message\":\"Not Found\"}\n",
 		},
+		{
+			name:                 "open redirect vulnerability",
+			givenPrefix:          "/",
+			givenFs:              os.DirFS("_fixture/"),
+			whenURL:              "/open.redirect.hackercom%2f..",
+			expectStatus:         http.StatusMovedPermanently,
+			expectHeaderLocation: "/open.redirect.hackercom/../", // location starting with `//open` would be very bad
+			expectBodyStartsWith: "",
+		},
 	}
 
 	for _, tc := range testCases {
diff --git a/echo_test.go b/echo_test.go
index 64796b3..7fd77c8 100644
--- a/echo_test.go
+++ b/echo_test.go
@@ -6,6 +6,7 @@ import (
 	"crypto/tls"
 	"errors"
 	"fmt"
+	"io"
 	"io/ioutil"
 	"net"
 	"net/http"
@@ -235,7 +236,12 @@ func TestEchoStaticRedirectIndex(t *testing.T) {
 
 	addr := e.ListenerAddr().String()
 	if resp, err := http.Get("http://" + addr + "/static"); err == nil { // http.Get follows redirects by default
-		defer resp.Body.Close()
+		defer func(Body io.ReadCloser) {
+			err := Body.Close()
+			if err != nil {
+				assert.Fail(t, err.Error())
+			}
+		}(resp.Body)
 		assert.Equal(t, http.StatusOK, resp.StatusCode)
 
 		if body, err := ioutil.ReadAll(resp.Body); err == nil {
@@ -380,7 +386,10 @@ func TestEchoWrapHandler(t *testing.T) {
 	c := e.NewContext(req, rec)
 	h := WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.WriteHeader(http.StatusOK)
-		w.Write([]byte("test"))
+		_, err := w.Write([]byte("test"))
+		if err != nil {
+			assert.Fail(t, err.Error())
+		}
 	}))
 	if assert.NoError(t, h(c)) {
 		assert.Equal(t, http.StatusOK, rec.Code)
@@ -482,16 +491,16 @@ func TestEchoURL(t *testing.T) {
 	g := e.Group("/group")
 	g.GET("/users/:uid/files/:fid", getFile)
 
-	assert := assert.New(t)
+	assertion := assert.New(t)
 
-	assert.Equal("/static/file", e.URL(static))
-	assert.Equal("/users/:id", e.URL(getUser))
-	assert.Equal("/users/1", e.URL(getUser, "1"))
-	assert.Equal("/users/1", e.URL(getUser, "1"))
-	assert.Equal("/documents/foo.txt", e.URL(getAny, "foo.txt"))
-	assert.Equal("/documents/*", e.URL(getAny))
-	assert.Equal("/group/users/1/files/:fid", e.URL(getFile, "1"))
-	assert.Equal("/group/users/1/files/1", e.URL(getFile, "1", "1"))
+	assertion.Equal("/static/file", e.URL(static))
+	assertion.Equal("/users/:id", e.URL(getUser))
+	assertion.Equal("/users/1", e.URL(getUser, "1"))
+	assertion.Equal("/users/1", e.URL(getUser, "1"))
+	assertion.Equal("/documents/foo.txt", e.URL(getAny, "foo.txt"))
+	assertion.Equal("/documents/*", e.URL(getAny))
+	assertion.Equal("/group/users/1/files/:fid", e.URL(getFile, "1"))
+	assertion.Equal("/group/users/1/files/1", e.URL(getFile, "1", "1"))
 }
 
 func TestEchoRoutes(t *testing.T) {
@@ -598,7 +607,7 @@ func TestEchoServeHTTPPathEncoding(t *testing.T) {
 }
 
 func TestEchoHost(t *testing.T) {
-	assert := assert.New(t)
+	assertion := assert.New(t)
 
 	okHandler := func(c Context) error { return c.String(http.StatusOK, http.StatusText(http.StatusOK)) }
 	teapotHandler := func(c Context) error { return c.String(http.StatusTeapot, http.StatusText(http.StatusTeapot)) }
@@ -694,8 +703,8 @@ func TestEchoHost(t *testing.T) {
 
 			e.ServeHTTP(rec, req)
 
-			assert.Equal(tc.expectStatus, rec.Code)
-			assert.Equal(tc.expectBody, rec.Body.String())
+			assertion.Equal(tc.expectStatus, rec.Code)
+			assertion.Equal(tc.expectBody, rec.Body.String())
 		})
 	}
 }
@@ -1231,7 +1240,7 @@ func TestDefaultHTTPErrorHandler(t *testing.T) {
 	e := New()
 	e.Debug = true
 	e.Any("/plain", func(c Context) error {
-		return errors.New("An error occurred")
+		return errors.New("an error occurred")
 	})
 	e.Any("/badrequest", func(c Context) error {
 		return NewHTTPError(http.StatusBadRequest, "Invalid request")
@@ -1244,7 +1253,10 @@ func TestDefaultHTTPErrorHandler(t *testing.T) {
 		})
 	})
 	e.Any("/early-return", func(c Context) error {
-		c.String(http.StatusOK, "OK")
+		err := c.String(http.StatusOK, "OK")
+		if err != nil {
+			assert.Fail(t, err.Error())
+		}
 		return errors.New("ERROR")
 	})
 	e.GET("/internal-error", func(c Context) error {
@@ -1255,7 +1267,7 @@ func TestDefaultHTTPErrorHandler(t *testing.T) {
 	// With Debug=true plain response contains error message
 	c, b := request(http.MethodGet, "/plain", e)
 	assert.Equal(t, http.StatusInternalServerError, c)
-	assert.Equal(t, "{\n  \"error\": \"An error occurred\",\n  \"message\": \"Internal Server Error\"\n}\n", b)
+	assert.Equal(t, "{\n  \"error\": \"an error occurred\",\n  \"message\": \"Internal Server Error\"\n}\n", b)
 	// and special handling for HTTPError
 	c, b = request(http.MethodGet, "/badrequest", e)
 	assert.Equal(t, http.StatusBadRequest, c)
@@ -1379,7 +1391,12 @@ func TestEchoListenerNetwork(t *testing.T) {
 			assert.NoError(t, err)
 
 			if resp, err := http.Get(fmt.Sprintf("http://%s/ok", tt.address)); err == nil {
-				defer resp.Body.Close()
+				defer func(Body io.ReadCloser) {
+					err := Body.Close()
+					if err != nil {
+						assert.Fail(t, err.Error())
+					}
+				}(resp.Body)
 				assert.Equal(t, http.StatusOK, resp.StatusCode)
 
 				if body, err := ioutil.ReadAll(resp.Body); err == nil {
diff --git a/group_fs_go1.16_test.go b/group_fs_test.go
similarity index 98%
rename from group_fs_go1.16_test.go
rename to group_fs_test.go
index d0caa33..958d9ef 100644
--- a/group_fs_go1.16_test.go
+++ b/group_fs_test.go
@@ -1,6 +1,3 @@
-//go:build go1.16
-// +build go1.16
-
 package echo
 
 import (
diff --git a/middleware/csrf_test.go b/middleware/csrf_test.go
index 9aff82a..6bccdbe 100644
--- a/middleware/csrf_test.go
+++ b/middleware/csrf_test.go
@@ -358,3 +358,25 @@ func TestCSRFConfig_skipper(t *testing.T) {
 		})
 	}
 }
+
+func TestCSRFErrorHandling(t *testing.T) {
+	cfg := CSRFConfig{
+		ErrorHandler: func(err error, c echo.Context) error {
+			return echo.NewHTTPError(http.StatusTeapot, "error_handler_executed")
+		},
+	}
+
+	e := echo.New()
+	e.POST("/", func(c echo.Context) error {
+		return c.String(http.StatusNotImplemented, "should not end up here")
+	})
+
+	e.Use(CSRFWithConfig(cfg))
+
+	req := httptest.NewRequest(http.MethodPost, "/", nil)
+	res := httptest.NewRecorder()
+	e.ServeHTTP(res, req)
+
+	assert.Equal(t, http.StatusTeapot, res.Code)
+	assert.Equal(t, "{\"message\":\"error_handler_executed\"}\n", res.Body.String())
+}
diff --git a/middleware/static_1_16_test.go b/middleware/static_1_16_test.go
deleted file mode 100644
index 53e02f7..0000000
--- a/middleware/static_1_16_test.go
+++ /dev/null
@@ -1,106 +0,0 @@
-// +build go1.16
-
-package middleware
-
-import (
-	"io/fs"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"testing"
-	"testing/fstest"
-
-	"github.com/labstack/echo/v4"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestStatic_CustomFS(t *testing.T) {
-	var testCases = []struct {
-		name           string
-		filesystem     fs.FS
-		root           string
-		whenURL        string
-		expectContains string
-		expectCode     int
-	}{
-		{
-			name:           "ok, serve index with Echo message",
-			whenURL:        "/",
-			filesystem:     os.DirFS("../_fixture"),
-			expectCode:     http.StatusOK,
-			expectContains: "<title>Echo</title>",
-		},
-
-		{
-			name:           "ok, serve index with Echo message",
-			whenURL:        "/_fixture/",
-			filesystem:     os.DirFS(".."),
-			expectCode:     http.StatusOK,
-			expectContains: "<title>Echo</title>",
-		},
-		{
-			name:    "ok, serve file from map fs",
-			whenURL: "/file.txt",
-			filesystem: fstest.MapFS{
-				"file.txt": &fstest.MapFile{Data: []byte("file.txt is ok")},
-			},
-			expectCode:     http.StatusOK,
-			expectContains: "file.txt is ok",
-		},
-		{
-			name:       "nok, missing file in map fs",
-			whenURL:    "/file.txt",
-			expectCode: http.StatusNotFound,
-			filesystem: fstest.MapFS{
-				"file2.txt": &fstest.MapFile{Data: []byte("file2.txt is ok")},
-			},
-		},
-		{
-			name:    "nok, file is not a subpath of root",
-			whenURL: `/../../secret.txt`,
-			root:    "/nested/folder",
-			filesystem: fstest.MapFS{
-				"secret.txt": &fstest.MapFile{Data: []byte("this is a secret")},
-			},
-			expectCode: http.StatusNotFound,
-		},
-		{
-			name:       "nok, backslash is forbidden",
-			whenURL:    `/..\..\secret.txt`,
-			expectCode: http.StatusNotFound,
-			root:       "/nested/folder",
-			filesystem: fstest.MapFS{
-				"secret.txt": &fstest.MapFile{Data: []byte("this is a secret")},
-			},
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			e := echo.New()
-
-			config := StaticConfig{
-				Root:       ".",
-				Filesystem: http.FS(tc.filesystem),
-			}
-
-			if tc.root != "" {
-				config.Root = tc.root
-			}
-
-			middlewareFunc := StaticWithConfig(config)
-			e.Use(middlewareFunc)
-
-			req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
-			rec := httptest.NewRecorder()
-
-			e.ServeHTTP(rec, req)
-
-			assert.Equal(t, tc.expectCode, rec.Code)
-			if tc.expectContains != "" {
-				responseBody := rec.Body.String()
-				assert.Contains(t, responseBody, tc.expectContains)
-			}
-		})
-	}
-}
diff --git a/middleware/static_test.go b/middleware/static_test.go
index af6641f..f26d97a 100644
--- a/middleware/static_test.go
+++ b/middleware/static_test.go
@@ -1,10 +1,13 @@
 package middleware
 
 import (
+	"io/fs"
 	"net/http"
 	"net/http/httptest"
+	"os"
 	"strings"
 	"testing"
+	"testing/fstest"
 
 	"github.com/labstack/echo/v4"
 	"github.com/stretchr/testify/assert"
@@ -207,6 +210,15 @@ func TestStatic_GroupWithStatic(t *testing.T) {
 			expectHeaderLocation: "/group/folder/",
 			expectBodyStartsWith: "",
 		},
+		{
+			name:                 "Directory redirect",
+			givenPrefix:          "/",
+			givenRoot:            "../_fixture",
+			whenURL:              "/group/folder%2f..",
+			expectStatus:         http.StatusMovedPermanently,
+			expectHeaderLocation: "/group/folder/../",
+			expectBodyStartsWith: "",
+		},
 		{
 			name:                 "Prefixed directory 404 (request URL without slash)",
 			givenGroup:           "_fixture",
@@ -306,3 +318,94 @@ func TestStatic_GroupWithStatic(t *testing.T) {
 		})
 	}
 }
+
+func TestStatic_CustomFS(t *testing.T) {
+	var testCases = []struct {
+		name           string
+		filesystem     fs.FS
+		root           string
+		whenURL        string
+		expectContains string
+		expectCode     int
+	}{
+		{
+			name:           "ok, serve index with Echo message",
+			whenURL:        "/",
+			filesystem:     os.DirFS("../_fixture"),
+			expectCode:     http.StatusOK,
+			expectContains: "<title>Echo</title>",
+		},
+
+		{
+			name:           "ok, serve index with Echo message",
+			whenURL:        "/_fixture/",
+			filesystem:     os.DirFS(".."),
+			expectCode:     http.StatusOK,
+			expectContains: "<title>Echo</title>",
+		},
+		{
+			name:    "ok, serve file from map fs",
+			whenURL: "/file.txt",
+			filesystem: fstest.MapFS{
+				"file.txt": &fstest.MapFile{Data: []byte("file.txt is ok")},
+			},
+			expectCode:     http.StatusOK,
+			expectContains: "file.txt is ok",
+		},
+		{
+			name:       "nok, missing file in map fs",
+			whenURL:    "/file.txt",
+			expectCode: http.StatusNotFound,
+			filesystem: fstest.MapFS{
+				"file2.txt": &fstest.MapFile{Data: []byte("file2.txt is ok")},
+			},
+		},
+		{
+			name:    "nok, file is not a subpath of root",
+			whenURL: `/../../secret.txt`,
+			root:    "/nested/folder",
+			filesystem: fstest.MapFS{
+				"secret.txt": &fstest.MapFile{Data: []byte("this is a secret")},
+			},
+			expectCode: http.StatusNotFound,
+		},
+		{
+			name:       "nok, backslash is forbidden",
+			whenURL:    `/..\..\secret.txt`,
+			expectCode: http.StatusNotFound,
+			root:       "/nested/folder",
+			filesystem: fstest.MapFS{
+				"secret.txt": &fstest.MapFile{Data: []byte("this is a secret")},
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := echo.New()
+
+			config := StaticConfig{
+				Root:       ".",
+				Filesystem: http.FS(tc.filesystem),
+			}
+
+			if tc.root != "" {
+				config.Root = tc.root
+			}
+
+			middlewareFunc := StaticWithConfig(config)
+			e.Use(middlewareFunc)
+
+			req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
+			rec := httptest.NewRecorder()
+
+			e.ServeHTTP(rec, req)
+
+			assert.Equal(t, tc.expectCode, rec.Code)
+			if tc.expectContains != "" {
+				responseBody := rec.Body.String()
+				assert.Contains(t, responseBody, tc.expectContains)
+			}
+		})
+	}
+}