Repository: labstack/echo
Pull request #2182 (2022-05-16 22:40:52)

Remove square brackets from ipv6 addresses in XFF
Some loadbalancers (eg citrix ADC / netscaler) add square brackets around the ipv6 address in the X-Forwarded-For header.
This PR removes them so that RealIP() and friends work correctly.


Patch:
diff --git a/.github/workflows/echo.yml b/.github/workflows/echo.yml
index 69535f0..e35e7f1 100644
--- a/.github/workflows/echo.yml
+++ b/.github/workflows/echo.yml
@@ -27,8 +27,10 @@ jobs:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
         # Each major Go release is supported until there are two newer major releases. https://golang.org/doc/devel/release.html#policy
-        # Echo tests with last four major releases
-        go: [1.16, 1.17, 1.18]
+        # Echo tests with last four major releases (unless there are pressing vulnerabilities)
+        # As we depend on `golang.org/x/` libraries which only support last 2 Go releases we could have situations when
+        # we derive from last four major releases promise.
+        go: [1.17, 1.18, 1.19]
     name: ${{ matrix.os }} @ Go ${{ matrix.go }}
     runs-on: ${{ matrix.os }}
     steps:
@@ -42,17 +44,23 @@ jobs:
         with:
           go-version: ${{ matrix.go }}
 
-      - name: Install Dependencies
-        run: go install golang.org/x/lint/golint@latest
-
       - name: Run Tests
+        run: go test -race --coverprofile=coverage.coverprofile --covermode=atomic ./...
+
+      - name: Install dependencies for checks
         run: |
-          golint -set_exit_status ./...
-          go test -race --coverprofile=coverage.coverprofile --covermode=atomic ./...
+          go install golang.org/x/lint/golint@latest
+          go install honnef.co/go/tools/cmd/staticcheck@latest
+
+      - name: Run golint
+        run: golint -set_exit_status ./...
+
+      - name: Run staticcheck
+        run: staticcheck ./...
 
       - name: Upload coverage to Codecov
-        if: success() && matrix.go == 1.18 && matrix.os == 'ubuntu-latest'
-        uses: codecov/codecov-action@v1
+        if: success() && matrix.go == 1.19 && matrix.os == 'ubuntu-latest'
+        uses: codecov/codecov-action@v3
         with:
           token:
           fail_ci_if_error: false
@@ -61,7 +69,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest]
-        go: [1.18]
+        go: [1.19]
     name: Benchmark comparison ${{ matrix.os }} @ Go ${{ matrix.go }}
     runs-on: ${{ matrix.os }}
     steps:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba75d71..c629350 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,74 @@
 # Changelog
 
+## v4.10.0 - 2022-xx-xx
+
+**Security**
+
+This minor version bumps minimum Go version to 1.17 (from 1.16) due `golang.org/x/` packages we depend on. There are 
+several vulnerabilities fixed in these libraries. 
+
+Echo still tries to support last 4 Go versions but there are occasions we can not guarantee this promise.
+
+
+
+
+## v4.9.1 - 2022-10-12
+
+**Fixes**
+
+* Fix logger panicing (when template is set to empty) by bumping dependency version [#2295](https://github.com/labstack/echo/issues/2295)
+
+**Enhancements**
+
+* Improve CORS documentation [#2272](https://github.com/labstack/echo/pull/2272)
+* Update readme about supported Go versions [#2291](https://github.com/labstack/echo/pull/2291)
+* Tests: improve error handling on closing body [#2254](https://github.com/labstack/echo/pull/2254)
+* Tests: refactor some of the assertions in tests [#2275](https://github.com/labstack/echo/pull/2275)
+* Tests: refactor assertions [#2301](https://github.com/labstack/echo/pull/2301)
+
+## 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**
+
+You can now add any arbitrary HTTP method type as a route [#2237](https://github.com/labstack/echo/pull/2237)
+```go
+e.Add("COPY", "/*", func(c echo.Context) error 
+  return c.String(http.StatusOK, "OK COPY")
+})
+```
+
+You can add custom 404 handler for specific paths [#2217](https://github.com/labstack/echo/pull/2217)
+```go
+e.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })
+
+g := e.Group("/images")
+g.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })
+```
+
+**Enhancements**
+
+* Add new value binding methods (UnixTimeMilli,TextUnmarshaler,JSONUnmarshaler) to Valuebinder [#2127](https://github.com/labstack/echo/pull/2127)
+* Refactor: body_limit middleware unit test [#2145](https://github.com/labstack/echo/pull/2145)
+* Refactor: Timeout mw: rework how test waits for timeout. [#2187](https://github.com/labstack/echo/pull/2187)
+* BasicAuth middleware returns 500 InternalServerError on invalid base64 strings but should return 400 [#2191](https://github.com/labstack/echo/pull/2191)
+* Refactor: duplicated findStaticChild process at findChildWithLabel [#2176](https://github.com/labstack/echo/pull/2176)
+* Allow different param names in different methods with same path scheme [#2209](https://github.com/labstack/echo/pull/2209)
+* Add support for registering handlers for different 404 routes [#2217](https://github.com/labstack/echo/pull/2217)
+* Middlewares should use errors.As() instead of type assertion on HTTPError [#2227](https://github.com/labstack/echo/pull/2227)
+* Allow arbitrary HTTP method types to be added as routes [#2237](https://github.com/labstack/echo/pull/2237)
+
 ## v4.7.2 - 2022-03-16
 
 **Fixes**
diff --git a/Makefile b/Makefile
index a6c4aaa..6aff6a8 100644
--- a/Makefile
+++ b/Makefile
@@ -10,8 +10,10 @@ check: lint vet race ## Check project
 
 init:
 	@go install golang.org/x/lint/golint@latest
+	@go install honnef.co/go/tools/cmd/staticcheck@latest
 
 lint: ## Lint the files
+	@staticcheck ${PKG_LIST}
 	@golint -set_exit_status ${PKG_LIST}
 
 vet: ## Vet the files
@@ -29,6 +31,6 @@ benchmark: ## Run benchmarks
 help: ## Display this help screen
 	@grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
 
-goversion ?= "1.16"
-test_version: ## Run tests inside Docker with given version (defaults to 1.15 oldest supported). Example: make test_version goversion=1.16
+goversion ?= "1.17"
+test_version: ## Run tests inside Docker with given version (defaults to 1.17 oldest supported). Example: make test_version goversion=1.17
 	@docker run --rm -it -v $(shell pwd):/project golang:$(goversion) /bin/sh -c "cd /project && make init check"
diff --git a/README.md b/README.md
index 8b2321f..509b973 100644
--- a/README.md
+++ b/README.md
@@ -11,12 +11,11 @@
 
 ## Supported Go versions
 
+Latest version of Echo supports last four Go major [releases](https://go.dev/doc/devel/release) and might work with older versions.
+
 As of version 4.0.0, Echo is available as a [Go module](https://github.com/golang/go/wiki/Modules).
 Therefore a Go version capable of understanding /vN suffixed imports is required:
 
-- 1.9.7+
-- 1.10.3+
-- 1.14+
 
 Any of these versions will allow you to import Echo as `github.com/labstack/echo/v4` which is the recommended
 way of using Echo going forward.
@@ -93,15 +92,16 @@ func hello(c echo.Context) error {
 
 # Third-party middlewares
 
-| Repository | Description                                                                                                                                                                                                                                                                                                                                                                                                    |
-|------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| [github.com/labstack/echo-contrib](https://github.com/labstack/echo-contrib) | (by Echo team) [casbin](https://github.com/casbin/casbin), [gorilla/sessions](https://github.com/gorilla/sessions), [jaegertracing](github.com/uber/jaeger-client-go), [prometheus](https://github.com/prometheus/client_golang/), [pprof](https://pkg.go.dev/net/http/pprof), [zipkin](https://github.com/openzipkin/zipkin-go) middlewares | 
-| [deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen) | Automatically generate RESTful API documentation with [OpenAPI](https://swagger.io/specification/) Client and Server Code Generator                                                                                                                                                                                                                       |
-| [github.com/swaggo/echo-swagger](https://github.com/swaggo/echo-swagger) | Automatically generate RESTful API documentation with [Swagger](https://swagger.io/) 2.0.                                                                                                                                                                                                                                                        |
-| [github.com/ziflex/lecho](https://github.com/ziflex/lecho) | [Zerolog](https://github.com/rs/zerolog) logging library wrapper for Echo logger interface.                                                                                                                                                                                                                                                                    |
-| [github.com/brpaz/echozap](https://github.com/brpaz/echozap) | Uber´s [Zap](https://github.com/uber-go/zap) logging library wrapper for Echo logger interface.                                                                                                                                                                                                                                                              |
-| [github.com/darkweak/souin/plugins/echo](https://github.com/darkweak/souin/tree/master/plugins/echo) | HTTP cache system based on [Souin](https://github.com/darkweak/souin) to automatically get your endpoints cached. It supports some distributed and non-distributed storage systems depending your needs.                                                                                                             |
-| [github.com/mikestefanello/pagoda](https://github.com/mikestefanello/pagoda) | Rapid, easy full-stack web development starter kit built with Echo.
+| Repository                                                                                           | Description                                                                                                                                                                                                                                                                                                                                  |
+|------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| [github.com/labstack/echo-contrib](https://github.com/labstack/echo-contrib)                         | (by Echo team) [casbin](https://github.com/casbin/casbin), [gorilla/sessions](https://github.com/gorilla/sessions), [jaegertracing](github.com/uber/jaeger-client-go), [prometheus](https://github.com/prometheus/client_golang/), [pprof](https://pkg.go.dev/net/http/pprof), [zipkin](https://github.com/openzipkin/zipkin-go) middlewares | 
+| [deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen)                                      | Automatically generate RESTful API documentation with [OpenAPI](https://swagger.io/specification/) Client and Server Code Generator                                                                                                                                                                                                          |
+| [github.com/swaggo/echo-swagger](https://github.com/swaggo/echo-swagger)                             | Automatically generate RESTful API documentation with [Swagger](https://swagger.io/) 2.0.                                                                                                                                                                                                                                                    |
+| [github.com/ziflex/lecho](https://github.com/ziflex/lecho)                                           | [Zerolog](https://github.com/rs/zerolog) logging library wrapper for Echo logger interface.                                                                                                                                                                                                                                                  |
+| [github.com/brpaz/echozap](https://github.com/brpaz/echozap)                                         | Uber´s [Zap](https://github.com/uber-go/zap) logging library wrapper for Echo logger interface.                                                                                                                                                                                                                                              |
+| [github.com/darkweak/souin/plugins/echo](https://github.com/darkweak/souin/tree/master/plugins/echo) | HTTP cache system based on [Souin](https://github.com/darkweak/souin) to automatically get your endpoints cached. It supports some distributed and non-distributed storage systems depending your needs.                                                                                                                                     |
+| [github.com/mikestefanello/pagoda](https://github.com/mikestefanello/pagoda)                         | Rapid, easy full-stack web development starter kit built with Echo.                                                                                                                                                                                                                                                                          |
+| [github.com/go-woo/protoc-gen-echo](https://github.com/go-woo/protoc-gen-echo)                       | ProtoBuf generate Echo server side code                                                                                                                                                                                                                                                                                                      |
 
 Please send a PR to add your own library here.
 
diff --git a/context.go b/context.go
index a4ecfad..df2228a 100644
--- a/context.go
+++ b/context.go
@@ -181,7 +181,7 @@ type (
 		// Logger returns the `Logger` instance.
 		Logger() Logger
 
-		// Set the logger
+		// SetLogger Set the logger
 		SetLogger(l Logger)
 
 		// Echo returns the `Echo` instance.
@@ -282,11 +282,16 @@ func (c *context) RealIP() string {
 	if ip := c.request.Header.Get(HeaderXForwardedFor); ip != "" {
 		i := strings.IndexAny(ip, ",")
 		if i > 0 {
-			return strings.TrimSpace(ip[:i])
+			xffip := strings.TrimSpace(ip[:i])
+			xffip = strings.TrimPrefix(xffip, "[")
+			xffip = strings.TrimSuffix(xffip, "]")
+			return xffip
 		}
 		return ip
 	}
 	if ip := c.request.Header.Get(HeaderXRealIP); ip != "" {
+		ip = strings.TrimPrefix(ip, "[")
+		ip = strings.TrimSuffix(ip, "]")
 		return ip
 	}
 	ra, _, _ := net.SplitHostPort(c.request.RemoteAddr)
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 8829619..2b632c9 100644
--- a/echo.go
+++ b/echo.go
@@ -3,34 +3,34 @@ Package echo implements high performance, minimalist Go web framework.
 
 Example:
 
-  package main
+	package main
 
-  import (
-    "net/http"
+	import (
+	  "net/http"
 
-    "github.com/labstack/echo/v4"
-    "github.com/labstack/echo/v4/middleware"
-  )
+	  "github.com/labstack/echo/v4"
+	  "github.com/labstack/echo/v4/middleware"
+	)
 
-  // Handler
-  func hello(c echo.Context) error {
-    return c.String(http.StatusOK, "Hello, World!")
-  }
+	// Handler
+	func hello(c echo.Context) error {
+	  return c.String(http.StatusOK, "Hello, World!")
+	}
 
-  func main() {
-    // Echo instance
-    e := echo.New()
+	func main() {
+	  // Echo instance
+	  e := echo.New()
 
-    // Middleware
-    e.Use(middleware.Logger())
-    e.Use(middleware.Recover())
+	  // Middleware
+	  e.Use(middleware.Logger())
+	  e.Use(middleware.Recover())
 
-    // Routes
-    e.GET("/", hello)
+	  // Routes
+	  e.GET("/", hello)
 
-    // Start server
-    e.Logger.Fatal(e.Start(":1323"))
-  }
+	  // Start server
+	  e.Logger.Fatal(e.Start(":1323"))
+	}
 
 Learn more at https://echo.labstack.com
 */
@@ -43,10 +43,10 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"io/ioutil"
 	stdLog "log"
 	"net"
 	"net/http"
+	"os"
 	"reflect"
 	"runtime"
 	"sync"
@@ -183,6 +183,8 @@ const (
 	PROPFIND = "PROPFIND"
 	// REPORT Method can be used to get information about a resource, see rfc 3253
 	REPORT = "REPORT"
+	// RouteNotFound is special method type for routes handling "route not found" (404) cases
+	RouteNotFound = "echo_route_not_found"
 )
 
 // Headers
@@ -246,7 +248,7 @@ const (
 
 const (
 	// Version of Echo
-	Version = "4.7.2"
+	Version = "4.9.0"
 	website = "https://echo.labstack.com"
 	// http://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Echo
 	banner = `
@@ -480,8 +482,21 @@ func (e *Echo) TRACE(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
 	return e.Add(http.MethodTrace, path, h, m...)
 }
 
-// Any registers a new route for all HTTP methods and path with matching handler
+// RouteNotFound registers a special-case route which is executed when no other route is found (i.e. HTTP 404 cases)
+// for current request URL.
+// Path supports static and named/any parameters just like other http method is defined. Generally path is ended with
+// wildcard/match-any character (`/*`, `/download/*` etc).
+//
+// Example: `e.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })`
+func (e *Echo) RouteNotFound(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
+	return e.Add(RouteNotFound, path, h, m...)
+}
+
+// Any registers a new route for all HTTP methods (supported by Echo) and path with matching handler
 // in the router with optional route-level middleware.
+//
+// Note: this method only adds specific set of supported HTTP methods as handler and is not true
+// "catch-any-arbitrary-method" way of matching requests.
 func (e *Echo) Any(path string, handler HandlerFunc, middleware ...MiddlewareFunc) []*Route {
 	routes := make([]*Route, len(methods))
 	for i, m := range methods {
@@ -515,6 +530,7 @@ func (e *Echo) File(path, file string, m ...MiddlewareFunc) *Route {
 func (e *Echo) add(host, method, path string, handler HandlerFunc, middleware ...MiddlewareFunc) *Route {
 	name := handlerName(handler)
 	router := e.findRouter(host)
+	// FIXME: when handler+middleware are both nil ... make it behave like handler removal
 	router.Add(method, path, func(c Context) error {
 		h := applyMiddleware(handler, middleware...)
 		return h(c)
@@ -549,7 +565,7 @@ func (e *Echo) Group(prefix string, m ...MiddlewareFunc) (g *Group) {
 	return
 }
 
-// URI generates a URI from handler.
+// URI generates an URI from handler.
 func (e *Echo) URI(handler HandlerFunc, params ...interface{}) string {
 	name := handlerName(handler)
 	return e.Reverse(name, params...)
@@ -610,7 +626,7 @@ func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	// Acquire context
 	c := e.pool.Get().(*context)
 	c.Reset(r, w)
-	var h func(Context) error
+	var h HandlerFunc
 
 	if e.premiddleware == nil {
 		e.findRouter(r.Host).Find(r.Method, GetPath(r), c)
@@ -684,7 +700,7 @@ func (e *Echo) StartTLS(address string, certFile, keyFile interface{}) (err erro
 func filepathOrContent(fileOrContent interface{}) (content []byte, err error) {
 	switch v := fileOrContent.(type) {
 	case string:
-		return ioutil.ReadFile(v)
+		return os.ReadFile(v)
 	case []byte:
 		return v, nil
 	default:
@@ -868,6 +884,15 @@ func (he *HTTPError) SetInternal(err error) *HTTPError {
 	return he
 }
 
+// WithInternal returns clone of HTTPError with err set to HTTPError.Internal field
+func (he *HTTPError) WithInternal(err error) *HTTPError {
+	return &HTTPError{
+		Code:     he.Code,
+		Message:  he.Message,
+		Internal: err,
+	}
+}
+
 // Unwrap satisfies the Go 1.13 error wrapper interface.
 func (he *HTTPError) Unwrap() error {
 	return he.Internal
@@ -897,8 +922,8 @@ func WrapMiddleware(m func(http.Handler) http.Handler) MiddlewareFunc {
 
 // GetPath returns RawPath, if it's empty returns Path from URL
 // Difference between RawPath and Path is:
-//  * Path is where request path is stored. Value is stored in decoded form: /%47%6f%2f becomes /Go/.
-//  * RawPath is an optional field which only gets set if the default encoding is different from Path.
+//   - Path is where request path is stored. Value is stored in decoded form: /%47%6f%2f becomes /Go/.
+//   - RawPath is an optional field which only gets set if the default encoding is different from Path.
 func GetPath(r *http.Request) string {
 	path := r.URL.RawPath
 	if path == "" {
diff --git a/echo_fs.go b/echo_fs.go
index c379054..9f83a03 100644
--- a/echo_fs.go
+++ b/echo_fs.go
@@ -1,62 +1,159 @@
-//go:build !go1.16
-// +build !go1.16
-
 package echo
 
 import (
+	"fmt"
+	"io/fs"
 	"net/http"
 	"net/url"
 	"os"
 	"path/filepath"
+	"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 !filepath.IsAbs(root) {
+			root = filepath.Join(dFS.prefix, root)
 		}
-		get(prefix, h)
+		return &defaultFS{
+			prefix: root,
+			fs:     os.DirFS(root),
+		}, nil
+	}
+	return fs.Sub(currentFs, root)
+}
+
+// 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/go.mod b/go.mod
index 4de2bdd..73fd6d9 100644
--- a/go.mod
+++ b/go.mod
@@ -4,21 +4,21 @@ go 1.17
 
 require (
 	github.com/golang-jwt/jwt v3.2.2+incompatible
-	github.com/labstack/gommon v0.3.1
-	github.com/stretchr/testify v1.7.0
-	github.com/valyala/fasttemplate v1.2.1
-	golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
-	golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f
-	golang.org/x/time v0.0.0-20201208040808-7e3f01d25324
+	github.com/labstack/gommon v0.4.0
+	github.com/stretchr/testify v1.8.1
+	github.com/valyala/fasttemplate v1.2.2
+	golang.org/x/crypto v0.2.0
+	golang.org/x/net v0.2.0
+	golang.org/x/time v0.2.0
 )
 
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/mattn/go-colorable v0.1.11 // indirect
-	github.com/mattn/go-isatty v0.0.14 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.16 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
-	golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
-	golang.org/x/text v0.3.7 // indirect
-	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
+	golang.org/x/sys v0.2.0 // indirect
+	golang.org/x/text v0.4.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index f667342..b052ff9 100644
--- a/go.sum
+++ b/go.sum
@@ -3,43 +3,70 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
-github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
-github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
-github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
+github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
+github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
 github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
-github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
 github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
-golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
-golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.2.0 h1:BRXPfhNivWL5Yq0BGQ39a2sW6t44aODpfxkWjYdzewE=
+golang.org/x/crypto v0.2.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY=
-golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
 golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
-golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/time v0.2.0 h1:52I/1L54xyEQAYdtcSuxtiT84KGYTBGXwayxmIpNJhE=
+golang.org/x/time v0.2.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/group.go b/group.go
index bba470c..28ce0dd 100644
--- a/group.go
+++ b/group.go
@@ -107,6 +107,13 @@ func (g *Group) File(path, file string) {
 	g.file(path, file, g.GET)
 }
 
+// RouteNotFound implements `Echo#RouteNotFound()` for sub-routes within the Group.
+//
+// Example: `g.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })`
+func (g *Group) RouteNotFound(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
+	return g.Add(RouteNotFound, path, h, m...)
+}
+
 // Add implements `Echo#Add()` for sub-routes within the Group.
 func (g *Group) Add(method, path string, handler HandlerFunc, middleware ...MiddlewareFunc) *Route {
 	// Combine into a new slice to avoid accidentally passing the same slice for
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/ip.go b/ip.go
index 46d464c..1bcd756 100644
--- a/ip.go
+++ b/ip.go
@@ -227,6 +227,8 @@ func ExtractIPFromRealIPHeader(options ...TrustOption) IPExtractor {
 	return func(req *http.Request) string {
 		realIP := req.Header.Get(HeaderXRealIP)
 		if realIP != "" {
+			realIP = strings.TrimPrefix(realIP, "[")
+			realIP = strings.TrimSuffix(realIP, "]")
 			if ip := net.ParseIP(realIP); ip != nil && checker.trust(ip) {
 				return realIP
 			}
@@ -248,7 +250,10 @@ func ExtractIPFromXFFHeader(options ...TrustOption) IPExtractor {
 		}
 		ips := append(strings.Split(strings.Join(xffs, ","), ","), directIP)
 		for i := len(ips) - 1; i >= 0; i-- {
-			ip := net.ParseIP(strings.TrimSpace(ips[i]))
+			ips[i] = strings.TrimSpace(ips[i])
+			ips[i] = strings.TrimPrefix(ips[i], "[")
+			ips[i] = strings.TrimSuffix(ips[i], "]")
+			ip := net.ParseIP(ips[i])
 			if ip == nil {
 				// Unable to parse IP; cannot trust entire records
 				return directIP
diff --git a/middleware/basic_auth.go b/middleware/basic_auth.go
index 8cf1ed9..52ef104 100644
--- a/middleware/basic_auth.go
+++ b/middleware/basic_auth.go
@@ -4,6 +4,7 @@ import (
 	"encoding/base64"
 	"strconv"
 	"strings"
+	"net/http"
 
 	"github.com/labstack/echo/v4"
 )
@@ -74,10 +75,13 @@ func BasicAuthWithConfig(config BasicAuthConfig) echo.MiddlewareFunc {
 			l := len(basic)
 
 			if len(auth) > l+1 && strings.EqualFold(auth[:l], basic) {
+				// Invalid base64 shouldn't be treated as error
+				// instead should be treated as invalid client input
 				b, err := base64.StdEncoding.DecodeString(auth[l+1:])
 				if err != nil {
-					return err
+					return echo.NewHTTPError(http.StatusBadRequest).SetInternal(err)
 				}
+
 				cred := string(b)
 				for i := 0; i < len(cred); i++ {
 					if cred[i] == ':' {
diff --git a/middleware/body_dump.go b/middleware/body_dump.go
index ebd0d0a..fa7891b 100644
--- a/middleware/body_dump.go
+++ b/middleware/body_dump.go
@@ -4,7 +4,6 @@ import (
 	"bufio"
 	"bytes"
 	"io"
-	"io/ioutil"
 	"net"
 	"net/http"
 
@@ -68,9 +67,9 @@ func BodyDumpWithConfig(config BodyDumpConfig) echo.MiddlewareFunc {
 			// Request
 			reqBody := []byte{}
 			if c.Request().Body != nil { // Read
-				reqBody, _ = ioutil.ReadAll(c.Request().Body)
+				reqBody, _ = io.ReadAll(c.Request().Body)
 			}
-			c.Request().Body = ioutil.NopCloser(bytes.NewBuffer(reqBody)) // Reset
+			c.Request().Body = io.NopCloser(bytes.NewBuffer(reqBody)) // Reset
 
 			// Response
 			resBody := new(bytes.Buffer)
diff --git a/middleware/compress.go b/middleware/compress.go
index ac6672e..9e5f610 100644
--- a/middleware/compress.go
+++ b/middleware/compress.go
@@ -4,7 +4,6 @@ import (
 	"bufio"
 	"compress/gzip"
 	"io"
-	"io/ioutil"
 	"net"
 	"net/http"
 	"strings"
@@ -89,7 +88,7 @@ func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc {
 						// nothing is written to body or error is returned.
 						// See issue #424, #407.
 						res.Writer = rw
-						w.Reset(ioutil.Discard)
+						w.Reset(io.Discard)
 					}
 					w.Close()
 					pool.Put(w)
@@ -135,7 +134,7 @@ func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
 func gzipCompressPool(config GzipConfig) sync.Pool {
 	return sync.Pool{
 		New: func() interface{} {
-			w, err := gzip.NewWriterLevel(ioutil.Discard, config.Level)
+			w, err := gzip.NewWriterLevel(io.Discard, config.Level)
 			if err != nil {
 				return err
 			}
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..8661c9f 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
@@ -113,7 +119,7 @@ func CSRFWithConfig(config CSRFConfig) echo.MiddlewareFunc {
 		config.CookieSecure = true
 	}
 
-	extractors, err := createExtractors(config.TokenLookup, "")
+	extractors, err := CreateExtractors(config.TokenLookup)
 	if err != nil {
 		panic(err)
 	}
@@ -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
 				}
 			}
 
diff --git a/middleware/extractor.go b/middleware/extractor.go
index afdfd81..5d9cee6 100644
--- a/middleware/extractor.go
+++ b/middleware/extractor.go
@@ -24,6 +24,26 @@ var errFormExtractorValueMissing = errors.New("missing value in the form")
 // ValuesExtractor defines a function for extracting values (keys/tokens) from the given context.
 type ValuesExtractor func(c echo.Context) ([]string, error)
 
+// CreateExtractors creates ValuesExtractors from given lookups.
+// Lookups is a string in the form of "<source>:<name>" or "<source>:<name>,<source>:<name>" that is used
+// to extract key from the request.
+// Possible values:
+//   - "header:<name>" or "header:<name>:<cut-prefix>"
+//     `<cut-prefix>` is argument value to cut/trim prefix of the extracted value. This is useful if header
+//     value has static prefix like `Authorization: <auth-scheme> <authorisation-parameters>` where part that we
+//     want to cut is `<auth-scheme> ` note the space at the end.
+//     In case of basic authentication `Authorization: Basic <credentials>` prefix we want to remove is `Basic `.
+//   - "query:<name>"
+//   - "param:<name>"
+//   - "form:<name>"
+//   - "cookie:<name>"
+//
+// Multiple sources example:
+// - "header:Authorization,header:X-Api-Key"
+func CreateExtractors(lookups string) ([]ValuesExtractor, error) {
+	return createExtractors(lookups, "")
+}
+
 func createExtractors(lookups string, authScheme string) ([]ValuesExtractor, error) {
 	if lookups == "" {
 		return nil, nil
diff --git a/middleware/jwt.go b/middleware/jwt.go
index bec5167..ef6ad4e 100644
--- a/middleware/jwt.go
+++ b/middleware/jwt.go
@@ -262,7 +262,7 @@ func JWTWithConfig(config JWTConfig) echo.MiddlewareFunc {
 }
 
 func (config *JWTConfig) defaultParseToken(auth string, c echo.Context) (interface{}, error) {
-	token := new(jwt.Token)
+	var token *jwt.Token
 	var err error
 	// Issue #647, #656
 	if _, ok := config.Claims.(jwt.MapClaims); ok {
diff --git a/middleware/logger.go b/middleware/logger.go
index 9baac47..a21df8f 100644
--- a/middleware/logger.go
+++ b/middleware/logger.go
@@ -23,6 +23,8 @@ type (
 		// Tags to construct the logger format.
 		//
 		// - time_unix
+		// - time_unix_milli
+		// - time_unix_micro
 		// - time_unix_nano
 		// - time_rfc3339
 		// - time_rfc3339_nano
@@ -126,6 +128,12 @@ func LoggerWithConfig(config LoggerConfig) echo.MiddlewareFunc {
 				switch tag {
 				case "time_unix":
 					return buf.WriteString(strconv.FormatInt(time.Now().Unix(), 10))
+				case "time_unix_milli":
+					// go 1.17 or later, it supports time#UnixMilli()
+					return buf.WriteString(strconv.FormatInt(time.Now().UnixNano()/1000000, 10))
+				case "time_unix_micro":
+					// go 1.17 or later, it supports time#UnixMicro()
+					return buf.WriteString(strconv.FormatInt(time.Now().UnixNano()/1000, 10))
 				case "time_unix_nano":
 					return buf.WriteString(strconv.FormatInt(time.Now().UnixNano(), 10))
 				case "time_rfc3339":
diff --git a/middleware/proxy.go b/middleware/proxy.go
index 6cfd673..d2cd2aa 100644
--- a/middleware/proxy.go
+++ b/middleware/proxy.go
@@ -72,6 +72,11 @@ type (
 		Next(echo.Context) *ProxyTarget
 	}
 
+	// TargetProvider defines an interface that gives the opportunity for balancer to return custom errors when selecting target.
+	TargetProvider interface {
+		NextTarget(echo.Context) (*ProxyTarget, error)
+	}
+
 	commonBalancer struct {
 		targets []*ProxyTarget
 		mutex   sync.RWMutex
@@ -223,6 +228,7 @@ func ProxyWithConfig(config ProxyConfig) echo.MiddlewareFunc {
 		}
 	}
 
+	provider, isTargetProvider := config.Balancer.(TargetProvider)
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
 		return func(c echo.Context) (err error) {
 			if config.Skipper(c) {
@@ -231,7 +237,16 @@ func ProxyWithConfig(config ProxyConfig) echo.MiddlewareFunc {
 
 			req := c.Request()
 			res := c.Response()
-			tgt := config.Balancer.Next(c)
+
+			var tgt *ProxyTarget
+			if isTargetProvider {
+				tgt, err = provider.NextTarget(c)
+				if err != nil {
+					return err
+				}
+			} else {
+				tgt = config.Balancer.Next(c)
+			}
 			c.Set(config.ContextKey, tgt)
 
 			if err := rewriteURL(config.RegexRewrite, req); err != nil {
diff --git a/middleware/request_logger.go b/middleware/request_logger.go
index 1b3e3ea..7a4d982 100644
--- a/middleware/request_logger.go
+++ b/middleware/request_logger.go
@@ -2,9 +2,10 @@ package middleware
 
 import (
 	"errors"
-	"github.com/labstack/echo/v4"
 	"net/http"
 	"time"
+
+	"github.com/labstack/echo/v4"
 )
 
 // Example for `fmt.Printf`
@@ -264,7 +265,8 @@ func (config RequestLoggerConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
 			if config.LogStatus {
 				v.Status = res.Status
 				if err != nil {
-					if httpErr, ok := err.(*echo.HTTPError); ok {
+					var httpErr *echo.HTTPError
+					if errors.As(err, &httpErr) {
 						v.Status = httpErr.Code
 					}
 				}
diff --git a/middleware/slash.go b/middleware/slash.go
index 4188675..a3bf807 100644
--- a/middleware/slash.go
+++ b/middleware/slash.go
@@ -33,7 +33,7 @@ func AddTrailingSlash() echo.MiddlewareFunc {
 	return AddTrailingSlashWithConfig(DefaultTrailingSlashConfig)
 }
 
-// AddTrailingSlashWithConfig returns a AddTrailingSlash middleware with config.
+// AddTrailingSlashWithConfig returns an AddTrailingSlash middleware with config.
 // See `AddTrailingSlash()`.
 func AddTrailingSlashWithConfig(config TrailingSlashConfig) echo.MiddlewareFunc {
 	// Defaults
diff --git a/middleware/static.go b/middleware/static.go
index 0106f7c..27ccf41 100644
--- a/middleware/static.go
+++ b/middleware/static.go
@@ -1,6 +1,7 @@
 package middleware
 
 import (
+	"errors"
 	"fmt"
 	"html/template"
 	"net/http"
@@ -196,8 +197,8 @@ func StaticWithConfig(config StaticConfig) echo.MiddlewareFunc {
 					return err
 				}
 
-				he, ok := err.(*echo.HTTPError)
-				if !(ok && config.HTML5 && he.Code == http.StatusNotFound) {
+				var he *echo.HTTPError
+				if !(errors.As(err, &he) && config.HTML5 && he.Code == http.StatusNotFound) {
 					return err
 				}
 
diff --git a/router.go b/router.go
index a1de2d6..23c5bd3 100644
--- a/router.go
+++ b/router.go
@@ -19,30 +19,39 @@ type (
 		prefix         string
 		parent         *node
 		staticChildren children
-		ppath          string
-		pnames         []string
-		methodHandler  *methodHandler
+		originalPath   string
+		methods        *routeMethods
 		paramChild     *node
 		anyChild       *node
+		paramsCount    int
 		// isLeaf indicates that node does not have child routes
 		isLeaf bool
 		// isHandler indicates that node has at least one handler registered to it
 		isHandler bool
-	}
-	kind          uint8
-	children      []*node
-	methodHandler struct {
-		connect     HandlerFunc
-		delete      HandlerFunc
-		get         HandlerFunc
-		head        HandlerFunc
-		options     HandlerFunc
-		patch       HandlerFunc
-		post        HandlerFunc
-		propfind    HandlerFunc
-		put         HandlerFunc
-		trace       HandlerFunc
-		report      HandlerFunc
+
+		// notFoundHandler is handler registered with RouteNotFound method and is executed for 404 cases
+		notFoundHandler *routeMethod
+	}
+	kind        uint8
+	children    []*node
+	routeMethod struct {
+		ppath   string
+		pnames  []string
+		handler HandlerFunc
+	}
+	routeMethods struct {
+		connect     *routeMethod
+		delete      *routeMethod
+		get         *routeMethod
+		head        *routeMethod
+		options     *routeMethod
+		patch       *routeMethod
+		post        *routeMethod
+		propfind    *routeMethod
+		put         *routeMethod
+		trace       *routeMethod
+		report      *routeMethod
+		anyOther    map[string]*routeMethod
 		allowHeader string
 	}
 )
@@ -56,7 +65,7 @@ const (
 	anyLabel   = byte('*')
 )
 
-func (m *methodHandler) isHandler() bool {
+func (m *routeMethods) isHandler() bool {
 	return m.connect != nil ||
 		m.delete != nil ||
 		m.get != nil ||
@@ -67,10 +76,12 @@ func (m *methodHandler) isHandler() bool {
 		m.propfind != nil ||
 		m.put != nil ||
 		m.trace != nil ||
-		m.report != nil
+		m.report != nil ||
+		len(m.anyOther) != 0
+	// RouteNotFound/404 is not considered as a handler
 }
 
-func (m *methodHandler) updateAllowHeader() {
+func (m *routeMethods) updateAllowHeader() {
 	buf := new(bytes.Buffer)
 	buf.WriteString(http.MethodOptions)
 
@@ -112,6 +123,10 @@ func (m *methodHandler) updateAllowHeader() {
 	if m.report != nil {
 		buf.WriteString(", REPORT")
 	}
+	for method := range m.anyOther { // for simplicity, we use map and therefore order is not deterministic here
+		buf.WriteString(", ")
+		buf.WriteString(method)
+	}
 	m.allowHeader = buf.String()
 }
 
@@ -119,7 +134,7 @@ func (m *methodHandler) updateAllowHeader() {
 func NewRouter(e *Echo) *Router {
 	return &Router{
 		tree: &node{
-			methodHandler: new(methodHandler),
+			methods: new(routeMethods),
 		},
 		routes: map[string]*Route{},
 		echo:   e,
@@ -153,7 +168,7 @@ func (r *Router) Add(method, path string, h HandlerFunc) {
 			}
 			j := i + 1
 
-			r.insert(method, path[:i], nil, staticKind, "", nil)
+			r.insert(method, path[:i], staticKind, routeMethod{})
 			for ; i < lcpIndex && path[i] != '/'; i++ {
 			}
 
@@ -163,23 +178,23 @@ func (r *Router) Add(method, path string, h HandlerFunc) {
 
 			if i == lcpIndex {
 				// path node is last fragment of route path. ie. `/users/:id`
-				r.insert(method, path[:i], h, paramKind, ppath, pnames)
+				r.insert(method, path[:i], paramKind, routeMethod{ppath, pnames, h})
 			} else {
-				r.insert(method, path[:i], nil, paramKind, "", nil)
+				r.insert(method, path[:i], paramKind, routeMethod{})
 			}
 		} else if path[i] == '*' {
-			r.insert(method, path[:i], nil, staticKind, "", nil)
+			r.insert(method, path[:i], staticKind, routeMethod{})
 			pnames = append(pnames, "*")
-			r.insert(method, path[:i+1], h, anyKind, ppath, pnames)
+			r.insert(method, path[:i+1], anyKind, routeMethod{ppath, pnames, h})
 		}
 	}
 
-	r.insert(method, path, h, staticKind, ppath, pnames)
+	r.insert(method, path, staticKind, routeMethod{ppath, pnames, h})
 }
 
-func (r *Router) insert(method, path string, h HandlerFunc, t kind, ppath string, pnames []string) {
+func (r *Router) insert(method, path string, t kind, rm routeMethod) {
 	// Adjust max param
-	paramLen := len(pnames)
+	paramLen := len(rm.pnames)
 	if *r.echo.maxParam < paramLen {
 		*r.echo.maxParam = paramLen
 	}
@@ -207,25 +222,31 @@ func (r *Router) insert(method, path string, h HandlerFunc, t kind, ppath string
 			// At root node
 			currentNode.label = search[0]
 			currentNode.prefix = search
-			if h != nil {
+			if rm.handler != nil {
 				currentNode.kind = t
-				currentNode.addHandler(method, h)
-				currentNode.ppath = ppath
-				currentNode.pnames = pnames
+				currentNode.addMethod(method, &rm)
+				currentNode.paramsCount = len(rm.pnames)
+				currentNode.originalPath = rm.ppath
 			}
 			currentNode.isLeaf = currentNode.staticChildren == nil && currentNode.paramChild == nil && currentNode.anyChild == nil
 		} else if lcpLen < prefixLen {
-			// Split node
+			// Split node into two before we insert new node.
+			// This happens when we are inserting path that is submatch of any existing inserted paths.
+			// For example, we have node `/test` and now are about to insert `/te/*`. In that case
+			// 1. overlapping part is `/te` that is used as parent node
+			// 2. `st` is part from existing node that is not matching - it gets its own node (child to `/te`)
+			// 3. `/*` is the new part we are about to insert (child to `/te`)
 			n := newNode(
 				currentNode.kind,
 				currentNode.prefix[lcpLen:],
 				currentNode,
 				currentNode.staticChildren,
-				currentNode.methodHandler,
-				currentNode.ppath,
-				currentNode.pnames,
+				currentNode.originalPath,
+				currentNode.methods,
+				currentNode.paramsCount,
 				currentNode.paramChild,
 				currentNode.anyChild,
+				currentNode.notFoundHandler,
 			)
 			// Update parent path for all children to new node
 			for _, child := range currentNode.staticChildren {
@@ -243,13 +264,14 @@ func (r *Router) insert(method, path string, h HandlerFunc, t kind, ppath string
 			currentNode.label = currentNode.prefix[0]
 			currentNode.prefix = currentNode.prefix[:lcpLen]
 			currentNode.staticChildren = nil
-			currentNode.methodHandler = new(methodHandler)
-			currentNode.ppath = ""
-			currentNode.pnames = nil
+			currentNode.originalPath = ""
+			currentNode.methods = new(routeMethods)
+			currentNode.paramsCount = 0
 			currentNode.paramChild = nil
 			currentNode.anyChild = nil
 			currentNode.isLeaf = false
 			currentNode.isHandler = false
+			currentNode.notFoundHandler = nil
 
 			// Only Static children could reach here
 			currentNode.addStaticChild(n)
@@ -257,13 +279,19 @@ func (r *Router) insert(method, path string, h HandlerFunc, t kind, ppath string
 			if lcpLen == searchLen {
 				// At parent node
 				currentNode.kind = t
-				currentNode.addHandler(method, h)
-				currentNode.ppath = ppath
-				currentNode.pnames = pnames
+				if rm.handler != nil {
+					currentNode.addMethod(method, &rm)
+					currentNode.paramsCount = len(rm.pnames)
+					currentNode.originalPath = rm.ppath
+				}
 			} else {
 				// Create child node
-				n = newNode(t, search[lcpLen:], currentNode, nil, new(methodHandler), ppath, pnames, nil, nil)
-				n.addHandler(method, h)
+				n = newNode(t, search[lcpLen:], currentNode, nil, "", new(routeMethods), 0, nil, nil, nil)
+				if rm.handler != nil {
+					n.addMethod(method, &rm)
+					n.paramsCount = len(rm.pnames)
+					n.originalPath = rm.ppath
+				}
 				// Only Static children could reach here
 				currentNode.addStaticChild(n)
 			}
@@ -277,8 +305,12 @@ func (r *Router) insert(method, path string, h HandlerFunc, t kind, ppath string
 				continue
 			}
 			// Create child node
-			n := newNode(t, search, currentNode, nil, new(methodHandler), ppath, pnames, nil, nil)
-			n.addHandler(method, h)
+			n := newNode(t, search, currentNode, nil, rm.ppath, new(routeMethods), 0, nil, nil, nil)
+			if rm.handler != nil {
+				n.addMethod(method, &rm)
+				n.paramsCount = len(rm.pnames)
+			}
+
 			switch t {
 			case staticKind:
 				currentNode.addStaticChild(n)
@@ -290,32 +322,42 @@ func (r *Router) insert(method, path string, h HandlerFunc, t kind, ppath string
 			currentNode.isLeaf = currentNode.staticChildren == nil && currentNode.paramChild == nil && currentNode.anyChild == nil
 		} else {
 			// Node already exists
-			if h != nil {
-				currentNode.addHandler(method, h)
-				currentNode.ppath = ppath
-				if len(currentNode.pnames) == 0 { // Issue #729
-					currentNode.pnames = pnames
-				}
+			if rm.handler != nil {
+				currentNode.addMethod(method, &rm)
+				currentNode.paramsCount = len(rm.pnames)
+				currentNode.originalPath = rm.ppath
 			}
 		}
 		return
 	}
 }
 
-func newNode(t kind, pre string, p *node, sc children, mh *methodHandler, ppath string, pnames []string, paramChildren, anyChildren *node) *node {
+func newNode(
+	t kind,
+	pre string,
+	p *node,
+	sc children,
+	originalPath string,
+	methods *routeMethods,
+	paramsCount int,
+	paramChildren,
+	anyChildren *node,
+	notFoundHandler *routeMethod,
+) *node {
 	return &node{
-		kind:           t,
-		label:          pre[0],
-		prefix:         pre,
-		parent:         p,
-		staticChildren: sc,
-		ppath:          ppath,
-		pnames:         pnames,
-		methodHandler:  mh,
-		paramChild:     paramChildren,
-		anyChild:       anyChildren,
-		isLeaf:         sc == nil && paramChildren == nil && anyChildren == nil,
-		isHandler:      mh.isHandler(),
+		kind:            t,
+		label:           pre[0],
+		prefix:          pre,
+		parent:          p,
+		staticChildren:  sc,
+		originalPath:    originalPath,
+		methods:         methods,
+		paramsCount:     paramsCount,
+		paramChild:      paramChildren,
+		anyChild:        anyChildren,
+		isLeaf:          sc == nil && paramChildren == nil && anyChildren == nil,
+		isHandler:       methods.isHandler(),
+		notFoundHandler: notFoundHandler,
 	}
 }
 
@@ -333,10 +375,8 @@ func (n *node) findStaticChild(l byte) *node {
 }
 
 func (n *node) findChildWithLabel(l byte) *node {
-	for _, c := range n.staticChildren {
-		if c.label == l {
-			return c
-		}
+	if c := n.findStaticChild(l); c != nil {
+		return c
 	}
 	if l == paramLabel {
 		return n.paramChild
@@ -347,66 +387,74 @@ func (n *node) findChildWithLabel(l byte) *node {
 	return nil
 }
 
-func (n *node) addHandler(method string, h HandlerFunc) {
+func (n *node) addMethod(method string, h *routeMethod) {
 	switch method {
 	case http.MethodConnect:
-		n.methodHandler.connect = h
+		n.methods.connect = h
 	case http.MethodDelete:
-		n.methodHandler.delete = h
+		n.methods.delete = h
 	case http.MethodGet:
-		n.methodHandler.get = h
+		n.methods.get = h
 	case http.MethodHead:
-		n.methodHandler.head = h
+		n.methods.head = h
 	case http.MethodOptions:
-		n.methodHandler.options = h
+		n.methods.options = h
 	case http.MethodPatch:
-		n.methodHandler.patch = h
+		n.methods.patch = h
 	case http.MethodPost:
-		n.methodHandler.post = h
+		n.methods.post = h
 	case PROPFIND:
-		n.methodHandler.propfind = h
+		n.methods.propfind = h
 	case http.MethodPut:
-		n.methodHandler.put = h
+		n.methods.put = h
 	case http.MethodTrace:
-		n.methodHandler.trace = h
+		n.methods.trace = h
 	case REPORT:
-		n.methodHandler.report = h
+		n.methods.report = h
+	case RouteNotFound:
+		n.notFoundHandler = h
+		return // RouteNotFound/404 is not considered as a handler so no further logic needs to be executed
+	default:
+		if n.methods.anyOther == nil {
+			n.methods.anyOther = make(map[string]*routeMethod)
+		}
+		if h.handler == nil {
+			delete(n.methods.anyOther, method)
+		} else {
+			n.methods.anyOther[method] = h
+		}
 	}
 
-	n.methodHandler.updateAllowHeader()
-	if h != nil {
-		n.isHandler = true
-	} else {
-		n.isHandler = n.methodHandler.isHandler()
-	}
+	n.methods.updateAllowHeader()
+	n.isHandler = true
 }
 
-func (n *node) findHandler(method string) HandlerFunc {
+func (n *node) findMethod(method string) *routeMethod {
 	switch method {
 	case http.MethodConnect:
-		return n.methodHandler.connect
+		return n.methods.connect
 	case http.MethodDelete:
-		return n.methodHandler.delete
+		return n.methods.delete
 	case http.MethodGet:
-		return n.methodHandler.get
+		return n.methods.get
 	case http.MethodHead:
-		return n.methodHandler.head
+		return n.methods.head
 	case http.MethodOptions:
-		return n.methodHandler.options
+		return n.methods.options
 	case http.MethodPatch:
-		return n.methodHandler.patch
+		return n.methods.patch
 	case http.MethodPost:
-		return n.methodHandler.post
+		return n.methods.post
 	case PROPFIND:
-		return n.methodHandler.propfind
+		return n.methods.propfind
 	case http.MethodPut:
-		return n.methodHandler.put
+		return n.methods.put
 	case http.MethodTrace:
-		return n.methodHandler.trace
+		return n.methods.trace
 	case REPORT:
-		return n.methodHandler.report
-	default:
-		return nil
+		return n.methods.report
+	default: // RouteNotFound/404 is not considered as a handler
+		return n.methods.anyOther[method]
 	}
 }
 
@@ -435,7 +483,7 @@ func (r *Router) Find(method, path string, c Context) {
 
 	var (
 		previousBestMatchNode *node
-		matchedHandler        HandlerFunc
+		matchedRouteMethod    *routeMethod
 		// search stores the remaining path to check for match. By each iteration we move from start of path to end of the path
 		// and search value gets shorter and shorter.
 		search      = path
@@ -508,7 +556,7 @@ func (r *Router) Find(method, path string, c Context) {
 			// No matching prefix, let's backtrack to the first possible alternative node of the decision path
 			nk, ok := backtrackToNextNodeKind(staticKind)
 			if !ok {
-				return // No other possibilities on the decision path
+				return // No other possibilities on the decision path, handler will be whatever context is reset to.
 			} else if nk == paramKind {
 				goto Param
 				// NOTE: this case (backtracking from static node to previous any node) can not happen by current any matching logic. Any node is end of search currently
@@ -524,15 +572,21 @@ func (r *Router) Find(method, path string, c Context) {
 		search = search[lcpLen:]
 		searchIndex = searchIndex + lcpLen
 
-		// Finish routing if no remaining search and we are on a node with handler and matching method type
-		if search == "" && currentNode.isHandler {
-			// check if current node has handler registered for http method we are looking for. we store currentNode as
-			// best matching in case we do no find no more routes matching this path+method
-			if previousBestMatchNode == nil {
-				previousBestMatchNode = currentNode
-			}
-			if h := currentNode.findHandler(method); h != nil {
-				matchedHandler = h
+		// Finish routing if is no request path remaining to search
+		if search == "" {
+			// in case of node that is handler we have exact method type match or something for 405 to use
+			if currentNode.isHandler {
+				// check if current node has handler registered for http method we are looking for. we store currentNode as
+				// best matching in case we do no find no more routes matching this path+method
+				if previousBestMatchNode == nil {
+					previousBestMatchNode = currentNode
+				}
+				if h := currentNode.findMethod(method); h != nil {
+					matchedRouteMethod = h
+					break
+				}
+			} else if currentNode.notFoundHandler != nil {
+				matchedRouteMethod = currentNode.notFoundHandler
 				break
 			}
 		}
@@ -552,7 +606,8 @@ func (r *Router) Find(method, path string, c Context) {
 			i := 0
 			l := len(search)
 			if currentNode.isLeaf {
-				// when param node does not have any children then param node should act similarly to any node - consider all remaining search as match
+				// when param node does not have any children (path param is last piece of route path) then param node should
+				// act similarly to any node - consider all remaining search as match
 				i = l
 			} else {
 				for ; i < l && search[i] != '/'; i++ {
@@ -571,19 +626,23 @@ func (r *Router) Find(method, path string, c Context) {
 		if child := currentNode.anyChild; child != nil {
 			// If any node is found, use remaining path for paramValues
 			currentNode = child
-			paramValues[len(currentNode.pnames)-1] = search
+			paramValues[currentNode.paramsCount-1] = search
+
 			// update indexes/search in case we need to backtrack when no handler match is found
 			paramIndex++
 			searchIndex += +len(search)
 			search = ""
 
-			// check if current node has handler registered for http method we are looking for. we store currentNode as
-			// best matching in case we do no find no more routes matching this path+method
+			if h := currentNode.findMethod(method); h != nil {
+				matchedRouteMethod = h
+				break
+			}
+			// we store currentNode as best matching in case we do not find more routes matching this path+method. Needed for 405
 			if previousBestMatchNode == nil {
 				previousBestMatchNode = currentNode
 			}
-			if h := currentNode.findHandler(method); h != nil {
-				matchedHandler = h
+			if currentNode.notFoundHandler != nil {
+				matchedRouteMethod = currentNode.notFoundHandler
 				break
 			}
 		}
@@ -606,22 +665,34 @@ func (r *Router) Find(method, path string, c Context) {
 		return // nothing matched at all
 	}
 
-	if matchedHandler != nil {
-		ctx.handler = matchedHandler
+	// matchedHandler could be method+path handler that we matched or notFoundHandler from node with matching path
+	// user provided not found (404) handler has priority over generic method not found (405) handler or global 404 handler
+	var rPath string
+	var rPNames []string
+	if matchedRouteMethod != nil {
+		rPath = matchedRouteMethod.ppath
+		rPNames = matchedRouteMethod.pnames
+		ctx.handler = matchedRouteMethod.handler
 	} else {
 		// use previous match as basis. although we have no matching handler we have path match.
 		// so we can send http.StatusMethodNotAllowed (405) instead of http.StatusNotFound (404)
 		currentNode = previousBestMatchNode
 
+		rPath = currentNode.originalPath
+		rPNames = nil // no params here
 		ctx.handler = NotFoundHandler
-		if currentNode.isHandler {
-			ctx.Set(ContextKeyHeaderAllow, currentNode.methodHandler.allowHeader)
+		if currentNode.notFoundHandler != nil {
+			rPath = currentNode.notFoundHandler.ppath
+			rPNames = currentNode.notFoundHandler.pnames
+			ctx.handler = currentNode.notFoundHandler.handler
+		} else if currentNode.isHandler {
+			ctx.Set(ContextKeyHeaderAllow, currentNode.methods.allowHeader)
 			ctx.handler = MethodNotAllowedHandler
 			if method == http.MethodOptions {
-				ctx.handler = optionsMethodHandler(currentNode.methodHandler.allowHeader)
+				ctx.handler = optionsMethodHandler(currentNode.methods.allowHeader)
 			}
 		}
 	}
-	ctx.path = currentNode.ppath
-	ctx.pnames = currentNode.pnames
+	ctx.path = rPath
+	ctx.pnames = rPNames
 }


Test patch:
diff --git a/bind_test.go b/bind_test.go
index 4ed8dbb..c35283d 100644
--- a/bind_test.go
+++ b/bind_test.go
@@ -190,44 +190,40 @@ func TestToMultipleFields(t *testing.T) {
 }
 
 func TestBindJSON(t *testing.T) {
-	assert := assert.New(t)
-	testBindOkay(assert, strings.NewReader(userJSON), nil, MIMEApplicationJSON)
-	testBindOkay(assert, strings.NewReader(userJSON), dummyQuery, MIMEApplicationJSON)
-	testBindArrayOkay(assert, strings.NewReader(usersJSON), nil, MIMEApplicationJSON)
-	testBindArrayOkay(assert, strings.NewReader(usersJSON), dummyQuery, MIMEApplicationJSON)
-	testBindError(assert, strings.NewReader(invalidContent), MIMEApplicationJSON, &json.SyntaxError{})
-	testBindError(assert, strings.NewReader(userJSONInvalidType), MIMEApplicationJSON, &json.UnmarshalTypeError{})
+	testBindOkay(t, strings.NewReader(userJSON), nil, MIMEApplicationJSON)
+	testBindOkay(t, strings.NewReader(userJSON), dummyQuery, MIMEApplicationJSON)
+	testBindArrayOkay(t, strings.NewReader(usersJSON), nil, MIMEApplicationJSON)
+	testBindArrayOkay(t, strings.NewReader(usersJSON), dummyQuery, MIMEApplicationJSON)
+	testBindError(t, strings.NewReader(invalidContent), MIMEApplicationJSON, &json.SyntaxError{})
+	testBindError(t, strings.NewReader(userJSONInvalidType), MIMEApplicationJSON, &json.UnmarshalTypeError{})
 }
 
 func TestBindXML(t *testing.T) {
-	assert := assert.New(t)
-
-	testBindOkay(assert, strings.NewReader(userXML), nil, MIMEApplicationXML)
-	testBindOkay(assert, strings.NewReader(userXML), dummyQuery, MIMEApplicationXML)
-	testBindArrayOkay(assert, strings.NewReader(userXML), nil, MIMEApplicationXML)
-	testBindArrayOkay(assert, strings.NewReader(userXML), dummyQuery, MIMEApplicationXML)
-	testBindError(assert, strings.NewReader(invalidContent), MIMEApplicationXML, errors.New(""))
-	testBindError(assert, strings.NewReader(userXMLConvertNumberError), MIMEApplicationXML, &strconv.NumError{})
-	testBindError(assert, strings.NewReader(userXMLUnsupportedTypeError), MIMEApplicationXML, &xml.SyntaxError{})
-	testBindOkay(assert, strings.NewReader(userXML), nil, MIMETextXML)
-	testBindOkay(assert, strings.NewReader(userXML), dummyQuery, MIMETextXML)
-	testBindError(assert, strings.NewReader(invalidContent), MIMETextXML, errors.New(""))
-	testBindError(assert, strings.NewReader(userXMLConvertNumberError), MIMETextXML, &strconv.NumError{})
-	testBindError(assert, strings.NewReader(userXMLUnsupportedTypeError), MIMETextXML, &xml.SyntaxError{})
+	testBindOkay(t, strings.NewReader(userXML), nil, MIMEApplicationXML)
+	testBindOkay(t, strings.NewReader(userXML), dummyQuery, MIMEApplicationXML)
+	testBindArrayOkay(t, strings.NewReader(userXML), nil, MIMEApplicationXML)
+	testBindArrayOkay(t, strings.NewReader(userXML), dummyQuery, MIMEApplicationXML)
+	testBindError(t, strings.NewReader(invalidContent), MIMEApplicationXML, errors.New(""))
+	testBindError(t, strings.NewReader(userXMLConvertNumberError), MIMEApplicationXML, &strconv.NumError{})
+	testBindError(t, strings.NewReader(userXMLUnsupportedTypeError), MIMEApplicationXML, &xml.SyntaxError{})
+	testBindOkay(t, strings.NewReader(userXML), nil, MIMETextXML)
+	testBindOkay(t, strings.NewReader(userXML), dummyQuery, MIMETextXML)
+	testBindError(t, strings.NewReader(invalidContent), MIMETextXML, errors.New(""))
+	testBindError(t, strings.NewReader(userXMLConvertNumberError), MIMETextXML, &strconv.NumError{})
+	testBindError(t, strings.NewReader(userXMLUnsupportedTypeError), MIMETextXML, &xml.SyntaxError{})
 }
 
 func TestBindForm(t *testing.T) {
-	assert := assert.New(t)
 
-	testBindOkay(assert, strings.NewReader(userForm), nil, MIMEApplicationForm)
-	testBindOkay(assert, strings.NewReader(userForm), dummyQuery, MIMEApplicationForm)
+	testBindOkay(t, strings.NewReader(userForm), nil, MIMEApplicationForm)
+	testBindOkay(t, strings.NewReader(userForm), dummyQuery, MIMEApplicationForm)
 	e := New()
 	req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(userForm))
 	rec := httptest.NewRecorder()
 	c := e.NewContext(req, rec)
 	req.Header.Set(HeaderContentType, MIMEApplicationForm)
 	err := c.Bind(&[]struct{ Field string }{})
-	assert.Error(err)
+	assert.Error(t, err)
 }
 
 func TestBindQueryParams(t *testing.T) {
@@ -317,20 +313,19 @@ func TestBindUnmarshalParam(t *testing.T) {
 	err := c.Bind(&result)
 	ts := Timestamp(time.Date(2016, 12, 6, 19, 9, 5, 0, time.UTC))
 
-	assert := assert.New(t)
-	if assert.NoError(err) {
+	if assert.NoError(t, err) {
 		//		assert.Equal( Timestamp(reflect.TypeOf(&Timestamp{}), time.Date(2016, 12, 6, 19, 9, 5, 0, time.UTC)), result.T)
-		assert.Equal(ts, result.T)
-		assert.Equal(StringArray([]string{"one", "two", "three"}), result.SA)
-		assert.Equal([]Timestamp{ts, ts}, result.TA)
-		assert.Equal(Struct{""}, result.ST)       // child struct does not have a field with matching tag
-		assert.Equal("baz", result.StWithTag.Foo) // child struct has field with matching tag
+		assert.Equal(t, ts, result.T)
+		assert.Equal(t, StringArray([]string{"one", "two", "three"}), result.SA)
+		assert.Equal(t, []Timestamp{ts, ts}, result.TA)
+		assert.Equal(t, Struct{""}, result.ST)       // child struct does not have a field with matching tag
+		assert.Equal(t, "baz", result.StWithTag.Foo) // child struct has field with matching tag
 	}
 }
 
 func TestBindUnmarshalText(t *testing.T) {
 	e := New()
-	req := httptest.NewRequest(GET, "/?ts=2016-12-06T19:09:05Z&sa=one,two,three&ta=2016-12-06T19:09:05Z&ta=2016-12-06T19:09:05Z&ST=baz", nil)
+	req := httptest.NewRequest(http.MethodGet, "/?ts=2016-12-06T19:09:05Z&sa=one,two,three&ta=2016-12-06T19:09:05Z&ta=2016-12-06T19:09:05Z&ST=baz", nil)
 	rec := httptest.NewRecorder()
 	c := e.NewContext(req, rec)
 	result := struct {
@@ -406,7 +401,7 @@ func TestBindUnmarshalParamAnonymousFieldPtrCustomTag(t *testing.T) {
 
 func TestBindUnmarshalTextPtr(t *testing.T) {
 	e := New()
-	req := httptest.NewRequest(GET, "/?ts=2016-12-06T19:09:05Z", nil)
+	req := httptest.NewRequest(http.MethodGet, "/?ts=2016-12-06T19:09:05Z", nil)
 	rec := httptest.NewRecorder()
 	c := e.NewContext(req, rec)
 	result := struct {
@@ -426,43 +421,40 @@ func TestBindMultipartForm(t *testing.T) {
 	mw.Close()
 	body := bodyBuffer.Bytes()
 
-	assert := assert.New(t)
-	testBindOkay(assert, bytes.NewReader(body), nil, mw.FormDataContentType())
-	testBindOkay(assert, bytes.NewReader(body), dummyQuery, mw.FormDataContentType())
+	testBindOkay(t, bytes.NewReader(body), nil, mw.FormDataContentType())
+	testBindOkay(t, bytes.NewReader(body), dummyQuery, mw.FormDataContentType())
 }
 
 func TestBindUnsupportedMediaType(t *testing.T) {
-	assert := assert.New(t)
-	testBindError(assert, strings.NewReader(invalidContent), MIMEApplicationJSON, &json.SyntaxError{})
+	testBindError(t, strings.NewReader(invalidContent), MIMEApplicationJSON, &json.SyntaxError{})
 }
 
 func TestBindbindData(t *testing.T) {
-	a := assert.New(t)
 	ts := new(bindTestStruct)
 	b := new(DefaultBinder)
 	err := b.bindData(ts, values, "form")
-	a.NoError(err)
-
-	a.Equal(0, ts.I)
-	a.Equal(int8(0), ts.I8)
-	a.Equal(int16(0), ts.I16)
-	a.Equal(int32(0), ts.I32)
-	a.Equal(int64(0), ts.I64)
-	a.Equal(uint(0), ts.UI)
-	a.Equal(uint8(0), ts.UI8)
-	a.Equal(uint16(0), ts.UI16)
-	a.Equal(uint32(0), ts.UI32)
-	a.Equal(uint64(0), ts.UI64)
-	a.Equal(false, ts.B)
-	a.Equal(float32(0), ts.F32)
-	a.Equal(float64(0), ts.F64)
-	a.Equal("", ts.S)
-	a.Equal("", ts.cantSet)
+	assert.NoError(t, err)
+
+	assert.Equal(t, 0, ts.I)
+	assert.Equal(t, int8(0), ts.I8)
+	assert.Equal(t, int16(0), ts.I16)
+	assert.Equal(t, int32(0), ts.I32)
+	assert.Equal(t, int64(0), ts.I64)
+	assert.Equal(t, uint(0), ts.UI)
+	assert.Equal(t, uint8(0), ts.UI8)
+	assert.Equal(t, uint16(0), ts.UI16)
+	assert.Equal(t, uint32(0), ts.UI32)
+	assert.Equal(t, uint64(0), ts.UI64)
+	assert.Equal(t, false, ts.B)
+	assert.Equal(t, float32(0), ts.F32)
+	assert.Equal(t, float64(0), ts.F64)
+	assert.Equal(t, "", ts.S)
+	assert.Equal(t, "", ts.cantSet)
 }
 
 func TestBindParam(t *testing.T) {
 	e := New()
-	req := httptest.NewRequest(GET, "/", nil)
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
 	rec := httptest.NewRecorder()
 	c := e.NewContext(req, rec)
 	c.SetPath("/users/:id/:name")
@@ -492,7 +484,7 @@ func TestBindParam(t *testing.T) {
 	// Bind something with param and post data payload
 	body := bytes.NewBufferString(`{ "name": "Jon Snow" }`)
 	e2 := New()
-	req2 := httptest.NewRequest(POST, "/", body)
+	req2 := httptest.NewRequest(http.MethodPost, "/", body)
 	req2.Header.Set(HeaderContentType, MIMEApplicationJSON)
 
 	rec2 := httptest.NewRecorder()
@@ -528,7 +520,6 @@ func TestBindUnmarshalTypeError(t *testing.T) {
 }
 
 func TestBindSetWithProperType(t *testing.T) {
-	assert := assert.New(t)
 	ts := new(bindTestStruct)
 	typ := reflect.TypeOf(ts).Elem()
 	val := reflect.ValueOf(ts).Elem()
@@ -543,9 +534,9 @@ func TestBindSetWithProperType(t *testing.T) {
 		}
 		val := values[typeField.Name][0]
 		err := setWithProperType(typeField.Type.Kind(), val, structField)
-		assert.NoError(err)
+		assert.NoError(t, err)
 	}
-	assertBindTestStruct(assert, ts)
+	assertBindTestStruct(t, ts)
 
 	type foo struct {
 		Bar bytes.Buffer
@@ -553,56 +544,54 @@ func TestBindSetWithProperType(t *testing.T) {
 	v := &foo{}
 	typ = reflect.TypeOf(v).Elem()
 	val = reflect.ValueOf(v).Elem()
-	assert.Error(setWithProperType(typ.Field(0).Type.Kind(), "5", val.Field(0)))
+	assert.Error(t, setWithProperType(typ.Field(0).Type.Kind(), "5", val.Field(0)))
 }
 
 func TestBindSetFields(t *testing.T) {
-	assert := assert.New(t)
 
 	ts := new(bindTestStruct)
 	val := reflect.ValueOf(ts).Elem()
 	// Int
-	if assert.NoError(setIntField("5", 0, val.FieldByName("I"))) {
-		assert.Equal(5, ts.I)
+	if assert.NoError(t, setIntField("5", 0, val.FieldByName("I"))) {
+		assert.Equal(t, 5, ts.I)
 	}
-	if assert.NoError(setIntField("", 0, val.FieldByName("I"))) {
-		assert.Equal(0, ts.I)
+	if assert.NoError(t, setIntField("", 0, val.FieldByName("I"))) {
+		assert.Equal(t, 0, ts.I)
 	}
 
 	// Uint
-	if assert.NoError(setUintField("10", 0, val.FieldByName("UI"))) {
-		assert.Equal(uint(10), ts.UI)
+	if assert.NoError(t, setUintField("10", 0, val.FieldByName("UI"))) {
+		assert.Equal(t, uint(10), ts.UI)
 	}
-	if assert.NoError(setUintField("", 0, val.FieldByName("UI"))) {
-		assert.Equal(uint(0), ts.UI)
+	if assert.NoError(t, setUintField("", 0, val.FieldByName("UI"))) {
+		assert.Equal(t, uint(0), ts.UI)
 	}
 
 	// Float
-	if assert.NoError(setFloatField("15.5", 0, val.FieldByName("F32"))) {
-		assert.Equal(float32(15.5), ts.F32)
+	if assert.NoError(t, setFloatField("15.5", 0, val.FieldByName("F32"))) {
+		assert.Equal(t, float32(15.5), ts.F32)
 	}
-	if assert.NoError(setFloatField("", 0, val.FieldByName("F32"))) {
-		assert.Equal(float32(0.0), ts.F32)
+	if assert.NoError(t, setFloatField("", 0, val.FieldByName("F32"))) {
+		assert.Equal(t, float32(0.0), ts.F32)
 	}
 
 	// Bool
-	if assert.NoError(setBoolField("true", val.FieldByName("B"))) {
-		assert.Equal(true, ts.B)
+	if assert.NoError(t, setBoolField("true", val.FieldByName("B"))) {
+		assert.Equal(t, true, ts.B)
 	}
-	if assert.NoError(setBoolField("", val.FieldByName("B"))) {
-		assert.Equal(false, ts.B)
+	if assert.NoError(t, setBoolField("", val.FieldByName("B"))) {
+		assert.Equal(t, false, ts.B)
 	}
 
 	ok, err := unmarshalFieldNonPtr("2016-12-06T19:09:05Z", val.FieldByName("T"))
-	if assert.NoError(err) {
-		assert.Equal(ok, true)
-		assert.Equal(Timestamp(time.Date(2016, 12, 6, 19, 9, 5, 0, time.UTC)), ts.T)
+	if assert.NoError(t, err) {
+		assert.Equal(t, ok, true)
+		assert.Equal(t, Timestamp(time.Date(2016, 12, 6, 19, 9, 5, 0, time.UTC)), ts.T)
 	}
 }
 
 func BenchmarkBindbindDataWithTags(b *testing.B) {
 	b.ReportAllocs()
-	assert := assert.New(b)
 	ts := new(bindTestStructWithTags)
 	binder := new(DefaultBinder)
 	var err error
@@ -610,29 +599,29 @@ func BenchmarkBindbindDataWithTags(b *testing.B) {
 	for i := 0; i < b.N; i++ {
 		err = binder.bindData(ts, values, "form")
 	}
-	assert.NoError(err)
-	assertBindTestStruct(assert, (*bindTestStruct)(ts))
+	assert.NoError(b, err)
+	assertBindTestStruct(b, (*bindTestStruct)(ts))
 }
 
-func assertBindTestStruct(a *assert.Assertions, ts *bindTestStruct) {
-	a.Equal(0, ts.I)
-	a.Equal(int8(8), ts.I8)
-	a.Equal(int16(16), ts.I16)
-	a.Equal(int32(32), ts.I32)
-	a.Equal(int64(64), ts.I64)
-	a.Equal(uint(0), ts.UI)
-	a.Equal(uint8(8), ts.UI8)
-	a.Equal(uint16(16), ts.UI16)
-	a.Equal(uint32(32), ts.UI32)
-	a.Equal(uint64(64), ts.UI64)
-	a.Equal(true, ts.B)
-	a.Equal(float32(32.5), ts.F32)
-	a.Equal(float64(64.5), ts.F64)
-	a.Equal("test", ts.S)
-	a.Equal("", ts.GetCantSet())
+func assertBindTestStruct(tb testing.TB, ts *bindTestStruct) {
+	assert.Equal(tb, 0, ts.I)
+	assert.Equal(tb, int8(8), ts.I8)
+	assert.Equal(tb, int16(16), ts.I16)
+	assert.Equal(tb, int32(32), ts.I32)
+	assert.Equal(tb, int64(64), ts.I64)
+	assert.Equal(tb, uint(0), ts.UI)
+	assert.Equal(tb, uint8(8), ts.UI8)
+	assert.Equal(tb, uint16(16), ts.UI16)
+	assert.Equal(tb, uint32(32), ts.UI32)
+	assert.Equal(tb, uint64(64), ts.UI64)
+	assert.Equal(tb, true, ts.B)
+	assert.Equal(tb, float32(32.5), ts.F32)
+	assert.Equal(tb, float64(64.5), ts.F64)
+	assert.Equal(tb, "test", ts.S)
+	assert.Equal(tb, "", ts.GetCantSet())
 }
 
-func testBindOkay(assert *assert.Assertions, r io.Reader, query url.Values, ctype string) {
+func testBindOkay(t *testing.T, r io.Reader, query url.Values, ctype string) {
 	e := New()
 	path := "/"
 	if len(query) > 0 {
@@ -644,13 +633,13 @@ func testBindOkay(assert *assert.Assertions, r io.Reader, query url.Values, ctyp
 	req.Header.Set(HeaderContentType, ctype)
 	u := new(user)
 	err := c.Bind(u)
-	if assert.NoError(err) {
-		assert.Equal(1, u.ID)
-		assert.Equal("Jon Snow", u.Name)
+	if assert.Equal(t, nil, err) {
+		assert.Equal(t, 1, u.ID)
+		assert.Equal(t, "Jon Snow", u.Name)
 	}
 }
 
-func testBindArrayOkay(assert *assert.Assertions, r io.Reader, query url.Values, ctype string) {
+func testBindArrayOkay(t *testing.T, r io.Reader, query url.Values, ctype string) {
 	e := New()
 	path := "/"
 	if len(query) > 0 {
@@ -662,14 +651,14 @@ func testBindArrayOkay(assert *assert.Assertions, r io.Reader, query url.Values,
 	req.Header.Set(HeaderContentType, ctype)
 	u := []user{}
 	err := c.Bind(&u)
-	if assert.NoError(err) {
-		assert.Equal(1, len(u))
-		assert.Equal(1, u[0].ID)
-		assert.Equal("Jon Snow", u[0].Name)
+	if assert.NoError(t, err) {
+		assert.Equal(t, 1, len(u))
+		assert.Equal(t, 1, u[0].ID)
+		assert.Equal(t, "Jon Snow", u[0].Name)
 	}
 }
 
-func testBindError(assert *assert.Assertions, r io.Reader, ctype string, expectedInternal error) {
+func testBindError(t *testing.T, r io.Reader, ctype string, expectedInternal error) {
 	e := New()
 	req := httptest.NewRequest(http.MethodPost, "/", r)
 	rec := httptest.NewRecorder()
@@ -681,14 +670,14 @@ func testBindError(assert *assert.Assertions, r io.Reader, ctype string, expecte
 	switch {
 	case strings.HasPrefix(ctype, MIMEApplicationJSON), strings.HasPrefix(ctype, MIMEApplicationXML), strings.HasPrefix(ctype, MIMETextXML),
 		strings.HasPrefix(ctype, MIMEApplicationForm), strings.HasPrefix(ctype, MIMEMultipartForm):
-		if assert.IsType(new(HTTPError), err) {
-			assert.Equal(http.StatusBadRequest, err.(*HTTPError).Code)
-			assert.IsType(expectedInternal, err.(*HTTPError).Internal)
+		if assert.IsType(t, new(HTTPError), err) {
+			assert.Equal(t, http.StatusBadRequest, err.(*HTTPError).Code)
+			assert.IsType(t, expectedInternal, err.(*HTTPError).Internal)
 		}
 	default:
-		if assert.IsType(new(HTTPError), err) {
-			assert.Equal(ErrUnsupportedMediaType, err)
-			assert.IsType(expectedInternal, err.(*HTTPError).Internal)
+		if assert.IsType(t, new(HTTPError), err) {
+			assert.Equal(t, ErrUnsupportedMediaType, err)
+			assert.IsType(t, expectedInternal, err.(*HTTPError).Internal)
 		}
 	}
 }
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/context_test.go b/context_test.go
index a8b9a99..11a63cf 100644
--- a/context_test.go
+++ b/context_test.go
@@ -32,7 +32,7 @@ var testUser = user{1, "Jon Snow"}
 
 func BenchmarkAllocJSONP(b *testing.B) {
 	e := New()
-	req := httptest.NewRequest(POST, "/", strings.NewReader(userJSON))
+	req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(userJSON))
 	rec := httptest.NewRecorder()
 	c := e.NewContext(req, rec).(*context)
 
@@ -46,7 +46,7 @@ func BenchmarkAllocJSONP(b *testing.B) {
 
 func BenchmarkAllocJSON(b *testing.B) {
 	e := New()
-	req := httptest.NewRequest(POST, "/", strings.NewReader(userJSON))
+	req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(userJSON))
 	rec := httptest.NewRecorder()
 	c := e.NewContext(req, rec).(*context)
 
@@ -60,7 +60,7 @@ func BenchmarkAllocJSON(b *testing.B) {
 
 func BenchmarkAllocXML(b *testing.B) {
 	e := New()
-	req := httptest.NewRequest(POST, "/", strings.NewReader(userJSON))
+	req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(userJSON))
 	rec := httptest.NewRecorder()
 	c := e.NewContext(req, rec).(*context)
 
@@ -97,7 +97,6 @@ func (responseWriterErr) Write([]byte) (int, error) {
 }
 
 func (responseWriterErr) WriteHeader(statusCode int) {
-
 }
 
 func TestContext(t *testing.T) {
@@ -728,7 +727,7 @@ func TestContext_QueryString(t *testing.T) {
 
 	queryString := "query=string&var=val"
 
-	req := httptest.NewRequest(GET, "/?"+queryString, nil)
+	req := httptest.NewRequest(http.MethodGet, "/?"+queryString, nil)
 	c := e.NewContext(req, nil)
 
 	testify.Equal(t, queryString, c.QueryString())
@@ -739,7 +738,7 @@ func TestContext_Request(t *testing.T) {
 
 	testify.Nil(t, c.Request())
 
-	req := httptest.NewRequest(GET, "/path", nil)
+	req := httptest.NewRequest(http.MethodGet, "/path", nil)
 	c.SetRequest(req)
 
 	testify.Equal(t, req, c.Request())
@@ -849,7 +848,7 @@ func TestContext_IsWebSocket(t *testing.T) {
 
 func TestContext_Bind(t *testing.T) {
 	e := New()
-	req := httptest.NewRequest(POST, "/", strings.NewReader(userJSON))
+	req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(userJSON))
 	c := e.NewContext(req, nil)
 	u := new(user)
 
@@ -904,6 +903,30 @@ func TestContext_RealIP(t *testing.T) {
 			},
 			"127.0.0.1",
 		},
+		{
+			&context{
+				request: &http.Request{
+					Header: http.Header{HeaderXForwardedFor: []string{"[2001:db8:85a3:8d3:1319:8a2e:370:7348], 2001:db8::1, "}},
+				},
+			},
+			"2001:db8:85a3:8d3:1319:8a2e:370:7348",
+		},
+		{
+			&context{
+				request: &http.Request{
+					Header: http.Header{HeaderXForwardedFor: []string{"[2001:db8:85a3:8d3:1319:8a2e:370:7348],[2001:db8::1]"}},
+				},
+			},
+			"2001:db8:85a3:8d3:1319:8a2e:370:7348",
+		},
+		{
+			&context{
+				request: &http.Request{
+					Header: http.Header{HeaderXForwardedFor: []string{"2001:db8:85a3:8d3:1319:8a2e:370:7348"}},
+				},
+			},
+			"2001:db8:85a3:8d3:1319:8a2e:370:7348",
+		},
 		{
 			&context{
 				request: &http.Request{
@@ -914,6 +937,17 @@ func TestContext_RealIP(t *testing.T) {
 			},
 			"192.168.0.1",
 		},
+		{
+			&context{
+				request: &http.Request{
+					Header: http.Header{
+						"X-Real-Ip": []string{"[2001:db8::1]"},
+					},
+				},
+			},
+			"2001:db8::1",
+		},
+
 		{
 			&context{
 				request: &http.Request{
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 0e1e42b..b0d1ccd 100644
--- a/echo_test.go
+++ b/echo_test.go
@@ -6,7 +6,7 @@ import (
 	"crypto/tls"
 	"errors"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"net"
 	"net/http"
 	"net/http/httptest"
@@ -235,10 +235,15 @@ 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 {
+		if body, err := io.ReadAll(resp.Body); err == nil {
 			assert.Equal(t, true, strings.HasPrefix(string(body), "<!doctype html>"))
 		} else {
 			assert.Fail(t, err.Error())
@@ -380,7 +385,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 +490,14 @@ func TestEchoURL(t *testing.T) {
 	g := e.Group("/group")
 	g.GET("/users/:uid/files/:fid", getFile)
 
-	assert := 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"))
+	assert.Equal(t, "/static/file", e.URL(static))
+	assert.Equal(t, "/users/:id", e.URL(getUser))
+	assert.Equal(t, "/users/1", e.URL(getUser, "1"))
+	assert.Equal(t, "/users/1", e.URL(getUser, "1"))
+	assert.Equal(t, "/documents/foo.txt", e.URL(getAny, "foo.txt"))
+	assert.Equal(t, "/documents/*", e.URL(getAny))
+	assert.Equal(t, "/group/users/1/files/:fid", e.URL(getFile, "1"))
+	assert.Equal(t, "/group/users/1/files/1", e.URL(getFile, "1", "1"))
 }
 
 func TestEchoRoutes(t *testing.T) {
@@ -598,8 +604,6 @@ func TestEchoServeHTTPPathEncoding(t *testing.T) {
 }
 
 func TestEchoHost(t *testing.T) {
-	assert := 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)) }
 	acceptHandler := func(c Context) error { return c.String(http.StatusAccepted, http.StatusText(http.StatusAccepted)) }
@@ -694,8 +698,8 @@ func TestEchoHost(t *testing.T) {
 
 			e.ServeHTTP(rec, req)
 
-			assert.Equal(tc.expectStatus, rec.Code)
-			assert.Equal(tc.expectBody, rec.Body.String())
+			assert.Equal(t, tc.expectStatus, rec.Code)
+			assert.Equal(t, tc.expectBody, rec.Body.String())
 		})
 	}
 }
@@ -766,6 +770,70 @@ func TestEchoNotFound(t *testing.T) {
 	assert.Equal(t, http.StatusNotFound, rec.Code)
 }
 
+func TestEcho_RouteNotFound(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		whenURL     string
+		expectRoute interface{}
+		expectCode  int
+	}{
+		{
+			name:        "404, route to static not found handler /a/c/xx",
+			whenURL:     "/a/c/xx",
+			expectRoute: "GET /a/c/xx",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "404, route to path param not found handler /a/:file",
+			whenURL:     "/a/echo.exe",
+			expectRoute: "GET /a/:file",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "404, route to any not found handler /*",
+			whenURL:     "/b/echo.exe",
+			expectRoute: "GET /*",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "200, route /a/c/df to /a/c/df",
+			whenURL:     "/a/c/df",
+			expectRoute: "GET /a/c/df",
+			expectCode:  http.StatusOK,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+
+			okHandler := func(c Context) error {
+				return c.String(http.StatusOK, c.Request().Method+" "+c.Path())
+			}
+			notFoundHandler := func(c Context) error {
+				return c.String(http.StatusNotFound, c.Request().Method+" "+c.Path())
+			}
+
+			e.GET("/", okHandler)
+			e.GET("/a/c/df", okHandler)
+			e.GET("/a/b*", okHandler)
+			e.PUT("/*", okHandler)
+
+			e.RouteNotFound("/a/c/xx", notFoundHandler)  // static
+			e.RouteNotFound("/a/:file", notFoundHandler) // param
+			e.RouteNotFound("/*", notFoundHandler)       // any
+
+			req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
+			rec := httptest.NewRecorder()
+
+			e.ServeHTTP(rec, req)
+
+			assert.Equal(t, tc.expectCode, rec.Code)
+			assert.Equal(t, tc.expectRoute, rec.Body.String())
+		})
+	}
+}
+
 func TestEchoMethodNotAllowed(t *testing.T) {
 	e := New()
 
@@ -963,9 +1031,9 @@ func TestEchoStartTLSAndStart(t *testing.T) {
 }
 
 func TestEchoStartTLSByteString(t *testing.T) {
-	cert, err := ioutil.ReadFile("_fixture/certs/cert.pem")
+	cert, err := os.ReadFile("_fixture/certs/cert.pem")
 	require.NoError(t, err)
-	key, err := ioutil.ReadFile("_fixture/certs/key.pem")
+	key, err := os.ReadFile("_fixture/certs/key.pem")
 	require.NoError(t, err)
 
 	testCases := []struct {
@@ -1137,13 +1205,22 @@ func TestHTTPError(t *testing.T) {
 
 		assert.Equal(t, "code=400, message=map[code:12]", err.Error())
 	})
-	t.Run("internal", func(t *testing.T) {
+
+	t.Run("internal and SetInternal", func(t *testing.T) {
 		err := NewHTTPError(http.StatusBadRequest, map[string]interface{}{
 			"code": 12,
 		})
 		err.SetInternal(errors.New("internal error"))
 		assert.Equal(t, "code=400, message=map[code:12], internal=internal error", err.Error())
 	})
+
+	t.Run("internal and WithInternal", func(t *testing.T) {
+		err := NewHTTPError(http.StatusBadRequest, map[string]interface{}{
+			"code": 12,
+		})
+		err = err.WithInternal(errors.New("internal error"))
+		assert.Equal(t, "code=400, message=map[code:12], internal=internal error", err.Error())
+	})
 }
 
 func TestHTTPError_Unwrap(t *testing.T) {
@@ -1154,20 +1231,29 @@ func TestHTTPError_Unwrap(t *testing.T) {
 
 		assert.Nil(t, errors.Unwrap(err))
 	})
-	t.Run("internal", func(t *testing.T) {
+
+	t.Run("unwrap internal and SetInternal", func(t *testing.T) {
 		err := NewHTTPError(http.StatusBadRequest, map[string]interface{}{
 			"code": 12,
 		})
 		err.SetInternal(errors.New("internal error"))
 		assert.Equal(t, "internal error", errors.Unwrap(err).Error())
 	})
+
+	t.Run("unwrap internal and WithInternal", func(t *testing.T) {
+		err := NewHTTPError(http.StatusBadRequest, map[string]interface{}{
+			"code": 12,
+		})
+		err = err.WithInternal(errors.New("internal error"))
+		assert.Equal(t, "internal error", errors.Unwrap(err).Error())
+	})
 }
 
 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")
@@ -1180,7 +1266,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 {
@@ -1191,7 +1280,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)
@@ -1315,10 +1404,15 @@ 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 {
+				if body, err := io.ReadAll(resp.Body); err == nil {
 					assert.Equal(t, "OK", string(body))
 				} else {
 					assert.Fail(t, err.Error())
@@ -1348,8 +1442,6 @@ func TestEchoListenerNetworkInvalid(t *testing.T) {
 }
 
 func TestEchoReverse(t *testing.T) {
-	assert := assert.New(t)
-
 	e := New()
 	dummyHandler := func(Context) error { return nil }
 
@@ -1359,22 +1451,20 @@ func TestEchoReverse(t *testing.T) {
 	e.GET("/params/:foo/bar/:qux", dummyHandler).Name = "/params/:foo/bar/:qux"
 	e.GET("/params/:foo/bar/:qux/*", dummyHandler).Name = "/params/:foo/bar/:qux/*"
 
-	assert.Equal("/static", e.Reverse("/static"))
-	assert.Equal("/static", e.Reverse("/static", "missing param"))
-	assert.Equal("/static/*", e.Reverse("/static/*"))
-	assert.Equal("/static/foo.txt", e.Reverse("/static/*", "foo.txt"))
-
-	assert.Equal("/params/:foo", e.Reverse("/params/:foo"))
-	assert.Equal("/params/one", e.Reverse("/params/:foo", "one"))
-	assert.Equal("/params/:foo/bar/:qux", e.Reverse("/params/:foo/bar/:qux"))
-	assert.Equal("/params/one/bar/:qux", e.Reverse("/params/:foo/bar/:qux", "one"))
-	assert.Equal("/params/one/bar/two", e.Reverse("/params/:foo/bar/:qux", "one", "two"))
-	assert.Equal("/params/one/bar/two/three", e.Reverse("/params/:foo/bar/:qux/*", "one", "two", "three"))
+	assert.Equal(t, "/static", e.Reverse("/static"))
+	assert.Equal(t, "/static", e.Reverse("/static", "missing param"))
+	assert.Equal(t, "/static/*", e.Reverse("/static/*"))
+	assert.Equal(t, "/static/foo.txt", e.Reverse("/static/*", "foo.txt"))
+
+	assert.Equal(t, "/params/:foo", e.Reverse("/params/:foo"))
+	assert.Equal(t, "/params/one", e.Reverse("/params/:foo", "one"))
+	assert.Equal(t, "/params/:foo/bar/:qux", e.Reverse("/params/:foo/bar/:qux"))
+	assert.Equal(t, "/params/one/bar/:qux", e.Reverse("/params/:foo/bar/:qux", "one"))
+	assert.Equal(t, "/params/one/bar/two", e.Reverse("/params/:foo/bar/:qux", "one", "two"))
+	assert.Equal(t, "/params/one/bar/two/three", e.Reverse("/params/:foo/bar/:qux/*", "one", "two", "three"))
 }
 
 func TestEchoReverseHandleHostProperly(t *testing.T) {
-	assert := assert.New(t)
-
 	dummyHandler := func(Context) error { return nil }
 
 	e := New()
@@ -1382,10 +1472,10 @@ func TestEchoReverseHandleHostProperly(t *testing.T) {
 	h.GET("/static", dummyHandler).Name = "/static"
 	h.GET("/static/*", dummyHandler).Name = "/static/*"
 
-	assert.Equal("/static", e.Reverse("/static"))
-	assert.Equal("/static", e.Reverse("/static", "missing param"))
-	assert.Equal("/static/*", e.Reverse("/static/*"))
-	assert.Equal("/static/foo.txt", e.Reverse("/static/*", "foo.txt"))
+	assert.Equal(t, "/static", e.Reverse("/static"))
+	assert.Equal(t, "/static", e.Reverse("/static", "missing param"))
+	assert.Equal(t, "/static/*", e.Reverse("/static/*"))
+	assert.Equal(t, "/static/foo.txt", e.Reverse("/static/*", "foo.txt"))
 }
 
 func TestEcho_ListenerAddr(t *testing.T) {
@@ -1404,9 +1494,9 @@ func TestEcho_ListenerAddr(t *testing.T) {
 }
 
 func TestEcho_TLSListenerAddr(t *testing.T) {
-	cert, err := ioutil.ReadFile("_fixture/certs/cert.pem")
+	cert, err := os.ReadFile("_fixture/certs/cert.pem")
 	require.NoError(t, err)
-	key, err := ioutil.ReadFile("_fixture/certs/key.pem")
+	key, err := os.ReadFile("_fixture/certs/key.pem")
 	require.NoError(t, err)
 
 	e := New()
@@ -1424,9 +1514,9 @@ func TestEcho_TLSListenerAddr(t *testing.T) {
 }
 
 func TestEcho_StartServer(t *testing.T) {
-	cert, err := ioutil.ReadFile("_fixture/certs/cert.pem")
+	cert, err := os.ReadFile("_fixture/certs/cert.pem")
 	require.NoError(t, err)
-	key, err := ioutil.ReadFile("_fixture/certs/key.pem")
+	key, err := os.ReadFile("_fixture/certs/key.pem")
 	require.NoError(t, err)
 	certs, err := tls.X509KeyPair(cert, key)
 	require.NoError(t, err)
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/group_test.go b/group_test.go
index c51fd91..01c304d 100644
--- a/group_test.go
+++ b/group_test.go
@@ -1,9 +1,9 @@
 package echo
 
 import (
-	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
+	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -32,7 +32,7 @@ func TestGroupFile(t *testing.T) {
 	e := New()
 	g := e.Group("/group")
 	g.File("/walle", "_fixture/images/walle.png")
-	expectedData, err := ioutil.ReadFile("_fixture/images/walle.png")
+	expectedData, err := os.ReadFile("_fixture/images/walle.png")
 	assert.Nil(t, err)
 	req := httptest.NewRequest(http.MethodGet, "/group/walle", nil)
 	rec := httptest.NewRecorder()
@@ -119,3 +119,68 @@ func TestGroupRouteMiddlewareWithMatchAny(t *testing.T) {
 	assert.Equal(t, "/*", m)
 
 }
+
+func TestGroup_RouteNotFound(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		whenURL     string
+		expectRoute interface{}
+		expectCode  int
+	}{
+		{
+			name:        "404, route to static not found handler /group/a/c/xx",
+			whenURL:     "/group/a/c/xx",
+			expectRoute: "GET /group/a/c/xx",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "404, route to path param not found handler /group/a/:file",
+			whenURL:     "/group/a/echo.exe",
+			expectRoute: "GET /group/a/:file",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "404, route to any not found handler /group/*",
+			whenURL:     "/group/b/echo.exe",
+			expectRoute: "GET /group/*",
+			expectCode:  http.StatusNotFound,
+		},
+		{
+			name:        "200, route /group/a/c/df to /group/a/c/df",
+			whenURL:     "/group/a/c/df",
+			expectRoute: "GET /group/a/c/df",
+			expectCode:  http.StatusOK,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			g := e.Group("/group")
+
+			okHandler := func(c Context) error {
+				return c.String(http.StatusOK, c.Request().Method+" "+c.Path())
+			}
+			notFoundHandler := func(c Context) error {
+				return c.String(http.StatusNotFound, c.Request().Method+" "+c.Path())
+			}
+
+			g.GET("/", okHandler)
+			g.GET("/a/c/df", okHandler)
+			g.GET("/a/b*", okHandler)
+			g.PUT("/*", okHandler)
+
+			g.RouteNotFound("/a/c/xx", notFoundHandler)  // static
+			g.RouteNotFound("/a/:file", notFoundHandler) // param
+			g.RouteNotFound("/*", notFoundHandler)       // any
+
+			req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
+			rec := httptest.NewRecorder()
+
+			e.ServeHTTP(rec, req)
+
+			assert.Equal(t, tc.expectCode, rec.Code)
+			assert.Equal(t, tc.expectRoute, rec.Body.String())
+		})
+	}
+}
diff --git a/ip_test.go b/ip_test.go
index 755900d..38c4a1c 100644
--- a/ip_test.go
+++ b/ip_test.go
@@ -459,6 +459,7 @@ func TestExtractIPDirect(t *testing.T) {
 
 func TestExtractIPFromRealIPHeader(t *testing.T) {
 	_, ipForRemoteAddrExternalRange, _ := net.ParseCIDR("203.0.113.199/24")
+	_, ipv6ForRemoteAddrExternalRange, _ := net.ParseCIDR("2001:db8::/64")
 
 	var testCases = []struct {
 		name              string
@@ -493,6 +494,16 @@ func TestExtractIPFromRealIPHeader(t *testing.T) {
 			},
 			expectIP: "203.0.113.1",
 		},
+		{
+			name: "request is from external IP has valid + UNTRUSTED external X-Real-Ip header, extract IP from remote addr",
+			whenRequest: http.Request{
+				Header: http.Header{
+					HeaderXRealIP: []string{"[2001:db8::113:199]"}, // <-- this is untrusted
+				},
+				RemoteAddr: "[2001:db8::113:1]:8080",
+			},
+			expectIP: "2001:db8::113:1",
+		},
 		{
 			name: "request is from external IP has valid + TRUSTED X-Real-Ip header, extract IP from X-Real-Ip header",
 			givenTrustOptions: []TrustOption{ // case for "trust direct-facing proxy"
@@ -506,6 +517,19 @@ func TestExtractIPFromRealIPHeader(t *testing.T) {
 			},
 			expectIP: "203.0.113.199",
 		},
+		{
+			name: "request is from external IP has valid + TRUSTED X-Real-Ip header, extract IP from X-Real-Ip header",
+			givenTrustOptions: []TrustOption{ // case for "trust direct-facing proxy"
+				TrustIPRange(ipv6ForRemoteAddrExternalRange), // we trust external IP range "2001:db8::/64"
+			},
+			whenRequest: http.Request{
+				Header: http.Header{
+					HeaderXRealIP: []string{"[2001:db8::113:199]"},
+				},
+				RemoteAddr: "[2001:db8::113:1]:8080",
+			},
+			expectIP: "2001:db8::113:199",
+		},
 		{
 			name: "request is from external IP has XFF and valid + TRUSTED X-Real-Ip header, extract IP from X-Real-Ip header",
 			givenTrustOptions: []TrustOption{ // case for "trust direct-facing proxy"
@@ -520,6 +544,20 @@ func TestExtractIPFromRealIPHeader(t *testing.T) {
 			},
 			expectIP: "203.0.113.199",
 		},
+		{
+			name: "request is from external IP has XFF and valid + TRUSTED X-Real-Ip header, extract IP from X-Real-Ip header",
+			givenTrustOptions: []TrustOption{ // case for "trust direct-facing proxy"
+				TrustIPRange(ipv6ForRemoteAddrExternalRange), // we trust external IP range "2001:db8::/64"
+			},
+			whenRequest: http.Request{
+				Header: http.Header{
+					HeaderXRealIP:       []string{"[2001:db8::113:199]"},
+					HeaderXForwardedFor: []string{"[2001:db8::113:198], [2001:db8::113:197]"}, // <-- should not affect anything
+				},
+				RemoteAddr: "[2001:db8::113:1]:8080",
+			},
+			expectIP: "2001:db8::113:199",
+		},
 	}
 
 	for _, tc := range testCases {
@@ -532,6 +570,7 @@ func TestExtractIPFromRealIPHeader(t *testing.T) {
 
 func TestExtractIPFromXFFHeader(t *testing.T) {
 	_, ipForRemoteAddrExternalRange, _ := net.ParseCIDR("203.0.113.199/24")
+	_, ipv6ForRemoteAddrExternalRange, _ := net.ParseCIDR("2001:db8::/64")
 
 	var testCases = []struct {
 		name              string
@@ -566,6 +605,16 @@ func TestExtractIPFromXFFHeader(t *testing.T) {
 			},
 			expectIP: "127.0.0.3",
 		},
+		{
+			name: "request trusts all IPs in XFF header, extract IP from furthest in XFF chain",
+			whenRequest: http.Request{
+				Header: http.Header{
+					HeaderXForwardedFor: []string{"[fe80::3], [fe80::2], [fe80::1]"},
+				},
+				RemoteAddr: "[fe80::1]:8080",
+			},
+			expectIP: "fe80::3",
+		},
 		{
 			name: "request is from external IP has valid + UNTRUSTED external XFF header, extract IP from remote addr",
 			whenRequest: http.Request{
@@ -576,6 +625,16 @@ func TestExtractIPFromXFFHeader(t *testing.T) {
 			},
 			expectIP: "203.0.113.1",
 		},
+		{
+			name: "request is from external IP has valid + UNTRUSTED external XFF header, extract IP from remote addr",
+			whenRequest: http.Request{
+				Header: http.Header{
+					HeaderXForwardedFor: []string{"[2001:db8::1]"}, // <-- this is untrusted
+				},
+				RemoteAddr: "[2001:db8::2]:8080",
+			},
+			expectIP: "2001:db8::2",
+		},
 		{
 			name: "request is from external IP is valid and has some IPs TRUSTED XFF header, extract IP from XFF header",
 			givenTrustOptions: []TrustOption{
@@ -595,6 +654,25 @@ func TestExtractIPFromXFFHeader(t *testing.T) {
 			},
 			expectIP: "203.0.100.100", // this is first trusted IP in XFF chain
 		},
+		{
+			name: "request is from external IP is valid and has some IPs TRUSTED XFF header, extract IP from XFF header",
+			givenTrustOptions: []TrustOption{
+				TrustIPRange(ipv6ForRemoteAddrExternalRange), // we trust external IP range "2001:db8::/64"
+			},
+			// from request its seems that request has been proxied through 6 servers.
+			// 1) 2001:db8:1::1:100 (this is external IP set by 2001:db8:2::100:100 which we do not trust - could be spoofed)
+			// 2) 2001:db8:2::100:100  (this is outside of our network but set by 2001:db8::113:199 which we trust to set correct IPs)
+			// 3) 2001:db8::113:199 (we trust, for example maybe our proxy from some other office)
+			// 4) fd12:3456:789a:1::1 (internal IP, some internal upstream loadbalancer ala SSL offloading with F5 products)
+			// 5) fe80::1 (is proxy on localhost. maybe we have Nginx in front of our Echo instance doing some routing)
+			whenRequest: http.Request{
+				Header: http.Header{
+					HeaderXForwardedFor: []string{"[2001:db8:1::1:100], [2001:db8:2::100:100], [2001:db8::113:199], [fd12:3456:789a:1::1]"},
+				},
+				RemoteAddr: "[fe80::1]:8080", // IP of proxy upstream of our APP
+			},
+			expectIP: "2001:db8:2::100:100", // this is first trusted IP in XFF chain
+		},
 	}
 
 	for _, tc := range testCases {
diff --git a/middleware/basic_auth_test.go b/middleware/basic_auth_test.go
index 76039db..20e7692 100644
--- a/middleware/basic_auth_test.go
+++ b/middleware/basic_auth_test.go
@@ -26,12 +26,10 @@ func TestBasicAuth(t *testing.T) {
 		return c.String(http.StatusOK, "test")
 	})
 
-	assert := assert.New(t)
-
 	// Valid credentials
 	auth := basic + " " + base64.StdEncoding.EncodeToString([]byte("joe:secret"))
 	req.Header.Set(echo.HeaderAuthorization, auth)
-	assert.NoError(h(c))
+	assert.NoError(t, h(c))
 
 	h = BasicAuthWithConfig(BasicAuthConfig{
 		Skipper:   nil,
@@ -44,28 +42,34 @@ func TestBasicAuth(t *testing.T) {
 	// Valid credentials
 	auth = basic + " " + base64.StdEncoding.EncodeToString([]byte("joe:secret"))
 	req.Header.Set(echo.HeaderAuthorization, auth)
-	assert.NoError(h(c))
+	assert.NoError(t, h(c))
 
 	// Case-insensitive header scheme
 	auth = strings.ToUpper(basic) + " " + base64.StdEncoding.EncodeToString([]byte("joe:secret"))
 	req.Header.Set(echo.HeaderAuthorization, auth)
-	assert.NoError(h(c))
+	assert.NoError(t, h(c))
 
 	// Invalid credentials
 	auth = basic + " " + base64.StdEncoding.EncodeToString([]byte("joe:invalid-password"))
 	req.Header.Set(echo.HeaderAuthorization, auth)
 	he := h(c).(*echo.HTTPError)
-	assert.Equal(http.StatusUnauthorized, he.Code)
-	assert.Equal(basic+` realm="someRealm"`, res.Header().Get(echo.HeaderWWWAuthenticate))
+	assert.Equal(t, http.StatusUnauthorized, he.Code)
+	assert.Equal(t, basic+` realm="someRealm"`, res.Header().Get(echo.HeaderWWWAuthenticate))
+
+	// Invalid base64 string
+	auth = basic + " invalidString"
+	req.Header.Set(echo.HeaderAuthorization, auth)
+	he = h(c).(*echo.HTTPError)
+	assert.Equal(t, http.StatusBadRequest, he.Code)
 
 	// Missing Authorization header
 	req.Header.Del(echo.HeaderAuthorization)
 	he = h(c).(*echo.HTTPError)
-	assert.Equal(http.StatusUnauthorized, he.Code)
+	assert.Equal(t, http.StatusUnauthorized, he.Code)
 
 	// Invalid Authorization header
 	auth = base64.StdEncoding.EncodeToString([]byte("invalid"))
 	req.Header.Set(echo.HeaderAuthorization, auth)
 	he = h(c).(*echo.HTTPError)
-	assert.Equal(http.StatusUnauthorized, he.Code)
+	assert.Equal(t, http.StatusUnauthorized, he.Code)
 }
diff --git a/middleware/body_dump_test.go b/middleware/body_dump_test.go
index e6e00f7..de1de33 100644
--- a/middleware/body_dump_test.go
+++ b/middleware/body_dump_test.go
@@ -2,7 +2,7 @@ package middleware
 
 import (
 	"errors"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"strings"
@@ -19,7 +19,7 @@ func TestBodyDump(t *testing.T) {
 	rec := httptest.NewRecorder()
 	c := e.NewContext(req, rec)
 	h := func(c echo.Context) error {
-		body, err := ioutil.ReadAll(c.Request().Body)
+		body, err := io.ReadAll(c.Request().Body)
 		if err != nil {
 			return err
 		}
@@ -33,13 +33,11 @@ func TestBodyDump(t *testing.T) {
 		responseBody = string(resBody)
 	})
 
-	assert := assert.New(t)
-
-	if assert.NoError(mw(h)(c)) {
-		assert.Equal(requestBody, hw)
-		assert.Equal(responseBody, hw)
-		assert.Equal(http.StatusOK, rec.Code)
-		assert.Equal(hw, rec.Body.String())
+	if assert.NoError(t, mw(h)(c)) {
+		assert.Equal(t, requestBody, hw)
+		assert.Equal(t, responseBody, hw)
+		assert.Equal(t, http.StatusOK, rec.Code)
+		assert.Equal(t, hw, rec.Body.String())
 	}
 
 	// Must set default skipper
diff --git a/middleware/body_limit_test.go b/middleware/body_limit_test.go
index 8981534..8ffed55 100644
--- a/middleware/body_limit_test.go
+++ b/middleware/body_limit_test.go
@@ -2,7 +2,7 @@ package middleware
 
 import (
 	"bytes"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"testing"
@@ -18,33 +18,31 @@ func TestBodyLimit(t *testing.T) {
 	rec := httptest.NewRecorder()
 	c := e.NewContext(req, rec)
 	h := func(c echo.Context) error {
-		body, err := ioutil.ReadAll(c.Request().Body)
+		body, err := io.ReadAll(c.Request().Body)
 		if err != nil {
 			return err
 		}
 		return c.String(http.StatusOK, string(body))
 	}
 
-	assert := assert.New(t)
-
 	// Based on content length (within limit)
-	if assert.NoError(BodyLimit("2M")(h)(c)) {
-		assert.Equal(http.StatusOK, rec.Code)
-		assert.Equal(hw, rec.Body.Bytes())
+	if assert.NoError(t, BodyLimit("2M")(h)(c)) {
+		assert.Equal(t, http.StatusOK, rec.Code)
+		assert.Equal(t, hw, rec.Body.Bytes())
 	}
 
 	// Based on content length (overlimit)
 	he := BodyLimit("2B")(h)(c).(*echo.HTTPError)
-	assert.Equal(http.StatusRequestEntityTooLarge, he.Code)
+	assert.Equal(t, http.StatusRequestEntityTooLarge, he.Code)
 
 	// Based on content read (within limit)
 	req = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(hw))
 	req.ContentLength = -1
 	rec = httptest.NewRecorder()
 	c = e.NewContext(req, rec)
-	if assert.NoError(BodyLimit("2M")(h)(c)) {
-		assert.Equal(http.StatusOK, rec.Code)
-		assert.Equal("Hello, World!", rec.Body.String())
+	if assert.NoError(t, BodyLimit("2M")(h)(c)) {
+		assert.Equal(t, http.StatusOK, rec.Code)
+		assert.Equal(t, "Hello, World!", rec.Body.String())
 	}
 
 	// Based on content read (overlimit)
@@ -53,7 +51,7 @@ func TestBodyLimit(t *testing.T) {
 	rec = httptest.NewRecorder()
 	c = e.NewContext(req, rec)
 	he = BodyLimit("2B")(h)(c).(*echo.HTTPError)
-	assert.Equal(http.StatusRequestEntityTooLarge, he.Code)
+	assert.Equal(t, http.StatusRequestEntityTooLarge, he.Code)
 }
 
 func TestBodyLimitReader(t *testing.T) {
@@ -69,18 +67,18 @@ func TestBodyLimitReader(t *testing.T) {
 	}
 	reader := &limitedReader{
 		BodyLimitConfig: config,
-		reader:          ioutil.NopCloser(bytes.NewReader(hw)),
+		reader:          io.NopCloser(bytes.NewReader(hw)),
 		context:         e.NewContext(req, rec),
 	}
 
 	// read all should return ErrStatusRequestEntityTooLarge
-	_, err := ioutil.ReadAll(reader)
+	_, err := io.ReadAll(reader)
 	he := err.(*echo.HTTPError)
 	assert.Equal(t, http.StatusRequestEntityTooLarge, he.Code)
 
 	// reset reader and read two bytes must succeed
 	bt := make([]byte, 2)
-	reader.Reset(ioutil.NopCloser(bytes.NewReader(hw)), e.NewContext(req, rec))
+	reader.Reset(io.NopCloser(bytes.NewReader(hw)), e.NewContext(req, rec))
 	n, err := reader.Read(bt)
 	assert.Equal(t, 2, n)
 	assert.Equal(t, nil, err)
diff --git a/middleware/compress_test.go b/middleware/compress_test.go
index b62bffe..714548e 100644
--- a/middleware/compress_test.go
+++ b/middleware/compress_test.go
@@ -4,9 +4,9 @@ import (
 	"bytes"
 	"compress/gzip"
 	"io"
-	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
+	"os"
 	"testing"
 
 	"github.com/labstack/echo/v4"
@@ -26,9 +26,7 @@ func TestGzip(t *testing.T) {
 	})
 	h(c)
 
-	assert := assert.New(t)
-
-	assert.Equal("test", rec.Body.String())
+	assert.Equal(t, "test", rec.Body.String())
 
 	// Gzip
 	req = httptest.NewRequest(http.MethodGet, "/", nil)
@@ -36,14 +34,14 @@ func TestGzip(t *testing.T) {
 	rec = httptest.NewRecorder()
 	c = e.NewContext(req, rec)
 	h(c)
-	assert.Equal(gzipScheme, rec.Header().Get(echo.HeaderContentEncoding))
-	assert.Contains(rec.Header().Get(echo.HeaderContentType), echo.MIMETextPlain)
+	assert.Equal(t, gzipScheme, rec.Header().Get(echo.HeaderContentEncoding))
+	assert.Contains(t, rec.Header().Get(echo.HeaderContentType), echo.MIMETextPlain)
 	r, err := gzip.NewReader(rec.Body)
-	if assert.NoError(err) {
+	if assert.NoError(t, err) {
 		buf := new(bytes.Buffer)
 		defer r.Close()
 		buf.ReadFrom(r)
-		assert.Equal("test", buf.String())
+		assert.Equal(t, "test", buf.String())
 	}
 
 	chunkBuf := make([]byte, 5)
@@ -63,21 +61,21 @@ func TestGzip(t *testing.T) {
 		c.Response().Flush()
 
 		// Read the first part of the data
-		assert.True(rec.Flushed)
-		assert.Equal(gzipScheme, rec.Header().Get(echo.HeaderContentEncoding))
+		assert.True(t, rec.Flushed)
+		assert.Equal(t, gzipScheme, rec.Header().Get(echo.HeaderContentEncoding))
 		r.Reset(rec.Body)
 
 		_, err = io.ReadFull(r, chunkBuf)
-		assert.NoError(err)
-		assert.Equal("test\n", string(chunkBuf))
+		assert.NoError(t, err)
+		assert.Equal(t, "test\n", string(chunkBuf))
 
 		// Write and flush the second part of the data
 		c.Response().Write([]byte("test\n"))
 		c.Response().Flush()
 
 		_, err = io.ReadFull(r, chunkBuf)
-		assert.NoError(err)
-		assert.Equal("test\n", string(chunkBuf))
+		assert.NoError(t, err)
+		assert.Equal(t, "test\n", string(chunkBuf))
 
 		// Write the final part of the data and return
 		c.Response().Write([]byte("test"))
@@ -87,7 +85,7 @@ func TestGzip(t *testing.T) {
 	buf := new(bytes.Buffer)
 	defer r.Close()
 	buf.ReadFrom(r)
-	assert.Equal("test", buf.String())
+	assert.Equal(t, "test", buf.String())
 }
 
 func TestGzipNoContent(t *testing.T) {
@@ -175,7 +173,7 @@ func TestGzipWithStatic(t *testing.T) {
 	r, err := gzip.NewReader(rec.Body)
 	if assert.NoError(t, err) {
 		defer r.Close()
-		want, err := ioutil.ReadFile("../_fixture/images/walle.png")
+		want, err := os.ReadFile("../_fixture/images/walle.png")
 		if assert.NoError(t, err) {
 			buf := new(bytes.Buffer)
 			buf.ReadFrom(r)
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/decompress_test.go b/middleware/decompress_test.go
index 51fa6b0..2e73ba8 100644
--- a/middleware/decompress_test.go
+++ b/middleware/decompress_test.go
@@ -4,7 +4,7 @@ import (
 	"bytes"
 	"compress/gzip"
 	"errors"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"strings"
@@ -28,8 +28,7 @@ func TestDecompress(t *testing.T) {
 	})
 	h(c)
 
-	assert := assert.New(t)
-	assert.Equal("test", rec.Body.String())
+	assert.Equal(t, "test", rec.Body.String())
 
 	// Decompress
 	body := `{"name": "echo"}`
@@ -39,10 +38,10 @@ func TestDecompress(t *testing.T) {
 	rec = httptest.NewRecorder()
 	c = e.NewContext(req, rec)
 	h(c)
-	assert.Equal(GZIPEncoding, req.Header.Get(echo.HeaderContentEncoding))
-	b, err := ioutil.ReadAll(req.Body)
-	assert.NoError(err)
-	assert.Equal(body, string(b))
+	assert.Equal(t, GZIPEncoding, req.Header.Get(echo.HeaderContentEncoding))
+	b, err := io.ReadAll(req.Body)
+	assert.NoError(t, err)
+	assert.Equal(t, body, string(b))
 }
 
 func TestDecompressDefaultConfig(t *testing.T) {
@@ -57,8 +56,7 @@ func TestDecompressDefaultConfig(t *testing.T) {
 	})
 	h(c)
 
-	assert := assert.New(t)
-	assert.Equal("test", rec.Body.String())
+	assert.Equal(t, "test", rec.Body.String())
 
 	// Decompress
 	body := `{"name": "echo"}`
@@ -68,10 +66,10 @@ func TestDecompressDefaultConfig(t *testing.T) {
 	rec = httptest.NewRecorder()
 	c = e.NewContext(req, rec)
 	h(c)
-	assert.Equal(GZIPEncoding, req.Header.Get(echo.HeaderContentEncoding))
-	b, err := ioutil.ReadAll(req.Body)
-	assert.NoError(err)
-	assert.Equal(body, string(b))
+	assert.Equal(t, GZIPEncoding, req.Header.Get(echo.HeaderContentEncoding))
+	b, err := io.ReadAll(req.Body)
+	assert.NoError(t, err)
+	assert.Equal(t, body, string(b))
 }
 
 func TestCompressRequestWithoutDecompressMiddleware(t *testing.T) {
@@ -84,7 +82,7 @@ func TestCompressRequestWithoutDecompressMiddleware(t *testing.T) {
 	e.NewContext(req, rec)
 	e.ServeHTTP(rec, req)
 	assert.Equal(t, GZIPEncoding, req.Header.Get(echo.HeaderContentEncoding))
-	b, err := ioutil.ReadAll(req.Body)
+	b, err := io.ReadAll(req.Body)
 	assert.NoError(t, err)
 	assert.NotEqual(t, b, body)
 	assert.Equal(t, b, gz)
@@ -134,7 +132,7 @@ func TestDecompressSkipper(t *testing.T) {
 	c := e.NewContext(req, rec)
 	e.ServeHTTP(rec, req)
 	assert.Equal(t, rec.Header().Get(echo.HeaderContentType), echo.MIMEApplicationJSONCharsetUTF8)
-	reqBody, err := ioutil.ReadAll(c.Request().Body)
+	reqBody, err := io.ReadAll(c.Request().Body)
 	assert.NoError(t, err)
 	assert.Equal(t, body, string(reqBody))
 }
@@ -163,7 +161,7 @@ func TestDecompressPoolError(t *testing.T) {
 	c := e.NewContext(req, rec)
 	e.ServeHTTP(rec, req)
 	assert.Equal(t, GZIPEncoding, req.Header.Get(echo.HeaderContentEncoding))
-	reqBody, err := ioutil.ReadAll(c.Request().Body)
+	reqBody, err := io.ReadAll(c.Request().Body)
 	assert.NoError(t, err)
 	assert.Equal(t, body, string(reqBody))
 	assert.Equal(t, rec.Code, http.StatusInternalServerError)
diff --git a/middleware/extractor_test.go b/middleware/extractor_test.go
index 2e898f5..428c556 100644
--- a/middleware/extractor_test.go
+++ b/middleware/extractor_test.go
@@ -110,7 +110,7 @@ func TestCreateExtractors(t *testing.T) {
 				setPathParams(c, tc.givenPathParams)
 			}
 
-			extractors, err := createExtractors(tc.whenLoopups, "")
+			extractors, err := CreateExtractors(tc.whenLoopups)
 			if tc.expectCreateError != "" {
 				assert.EqualError(t, err, tc.expectCreateError)
 				return
diff --git a/middleware/jwt_test.go b/middleware/jwt_test.go
index eee9df9..90e8cad 100644
--- a/middleware/jwt_test.go
+++ b/middleware/jwt_test.go
@@ -348,8 +348,6 @@ func TestJWTConfig(t *testing.T) {
 }
 
 func TestJWTwithKID(t *testing.T) {
-	test := assert.New(t)
-
 	e := echo.New()
 	handler := func(c echo.Context) error {
 		return c.String(http.StatusOK, "test")
@@ -417,19 +415,19 @@ func TestJWTwithKID(t *testing.T) {
 		if tc.expErrCode != 0 {
 			h := JWTWithConfig(tc.config)(handler)
 			he := h(c).(*echo.HTTPError)
-			test.Equal(tc.expErrCode, he.Code, tc.info)
+			assert.Equal(t, tc.expErrCode, he.Code, tc.info)
 			continue
 		}
 
 		h := JWTWithConfig(tc.config)(handler)
-		if test.NoError(h(c), tc.info) {
+		if assert.NoError(t, h(c), tc.info) {
 			user := c.Get("user").(*jwt.Token)
 			switch claims := user.Claims.(type) {
 			case jwt.MapClaims:
-				test.Equal(claims["name"], "John Doe", tc.info)
+				assert.Equal(t, claims["name"], "John Doe", tc.info)
 			case *jwtCustomClaims:
-				test.Equal(claims.Name, "John Doe", tc.info)
-				test.Equal(claims.Admin, true, tc.info)
+				assert.Equal(t, claims.Name, "John Doe", tc.info)
+				assert.Equal(t, claims.Admin, true, tc.info)
 			default:
 				panic("unexpected type of claims")
 			}
diff --git a/middleware/logger_test.go b/middleware/logger_test.go
index 394f627..ab889bf 100644
--- a/middleware/logger_test.go
+++ b/middleware/logger_test.go
@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"strconv"
 	"strings"
 	"testing"
 	"time"
@@ -244,3 +245,49 @@ func BenchmarkLoggerWithConfig_withMapFields(b *testing.B) {
 		buf.Reset()
 	}
 }
+
+func TestLoggerTemplateWithTimeUnixMilli(t *testing.T) {
+	buf := new(bytes.Buffer)
+
+	e := echo.New()
+	e.Use(LoggerWithConfig(LoggerConfig{
+		Format: `${time_unix_milli}`,
+		Output: buf,
+	}))
+
+	e.GET("/", func(c echo.Context) error {
+		return c.String(http.StatusOK, "OK")
+	})
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+
+	rec := httptest.NewRecorder()
+	e.ServeHTTP(rec, req)
+
+	unixMillis, err := strconv.ParseInt(buf.String(), 10, 64)
+	assert.NoError(t, err)
+	assert.WithinDuration(t, time.Unix(unixMillis/1000, 0), time.Now(), 3*time.Second)
+}
+
+func TestLoggerTemplateWithTimeUnixMicro(t *testing.T) {
+	buf := new(bytes.Buffer)
+
+	e := echo.New()
+	e.Use(LoggerWithConfig(LoggerConfig{
+		Format: `${time_unix_micro}`,
+		Output: buf,
+	}))
+
+	e.GET("/", func(c echo.Context) error {
+		return c.String(http.StatusOK, "OK")
+	})
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+
+	rec := httptest.NewRecorder()
+	e.ServeHTTP(rec, req)
+
+	unixMicros, err := strconv.ParseInt(buf.String(), 10, 64)
+	assert.NoError(t, err)
+	assert.WithinDuration(t, time.Unix(unixMicros/1000000, 0), time.Now(), 3*time.Second)
+}
diff --git a/middleware/proxy_test.go b/middleware/proxy_test.go
index 7939fc5..a1b7f2c 100644
--- a/middleware/proxy_test.go
+++ b/middleware/proxy_test.go
@@ -4,7 +4,7 @@ import (
 	"bytes"
 	"context"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"net"
 	"net/http"
 	"net/http/httptest"
@@ -18,7 +18,7 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-//Assert expected with url.EscapedPath method to obtain the path.
+// Assert expected with url.EscapedPath method to obtain the path.
 func TestProxy(t *testing.T) {
 	// Setup
 	t1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -31,7 +31,6 @@ func TestProxy(t *testing.T) {
 	}))
 	defer t2.Close()
 	url2, _ := url.Parse(t2.URL)
-
 	targets := []*ProxyTarget{
 		{
 			Name: "target 1",
@@ -94,7 +93,7 @@ func TestProxy(t *testing.T) {
 	e.Use(ProxyWithConfig(ProxyConfig{
 		Balancer: rrb,
 		ModifyResponse: func(res *http.Response) error {
-			res.Body = ioutil.NopCloser(bytes.NewBuffer([]byte("modified")))
+			res.Body = io.NopCloser(bytes.NewBuffer([]byte("modified")))
 			res.Header.Set("X-Modified", "1")
 			return nil
 		},
@@ -122,6 +121,55 @@ func TestProxy(t *testing.T) {
 	e.ServeHTTP(rec, req)
 }
 
+type testProvider struct {
+	*commonBalancer
+	target *ProxyTarget
+	err    error
+}
+
+func (p *testProvider) Next(c echo.Context) *ProxyTarget {
+	return &ProxyTarget{}
+}
+
+func (p *testProvider) NextTarget(c echo.Context) (*ProxyTarget, error) {
+	return p.target, p.err
+}
+
+func TestTargetProvider(t *testing.T) {
+	t1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, "target 1")
+	}))
+	defer t1.Close()
+	url1, _ := url.Parse(t1.URL)
+
+	e := echo.New()
+	tp := &testProvider{commonBalancer: new(commonBalancer)}
+	tp.target = &ProxyTarget{Name: "target 1", URL: url1}
+	e.Use(Proxy(tp))
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	e.ServeHTTP(rec, req)
+	body := rec.Body.String()
+	assert.Equal(t, "target 1", body)
+}
+
+func TestFailNextTarget(t *testing.T) {
+	url1, err := url.Parse("http://dummy:8080")
+	assert.Nil(t, err)
+
+	e := echo.New()
+	tp := &testProvider{commonBalancer: new(commonBalancer)}
+	tp.target = &ProxyTarget{Name: "target 1", URL: url1}
+	tp.err = echo.NewHTTPError(http.StatusInternalServerError, "method could not select target")
+
+	e.Use(Proxy(tp))
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	e.ServeHTTP(rec, req)
+	body := rec.Body.String()
+	assert.Equal(t, "{\"message\":\"method could not select target\"}\n", body)
+}
+
 func TestProxyRealIPHeader(t *testing.T) {
 	// Setup
 	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
@@ -336,10 +384,9 @@ func TestProxyError(t *testing.T) {
 	e := echo.New()
 	e.Use(Proxy(rb))
 	req := httptest.NewRequest(http.MethodGet, "/", nil)
-	rec := httptest.NewRecorder()
 
 	// Remote unreachable
-	rec = httptest.NewRecorder()
+	rec := httptest.NewRecorder()
 	req.URL.Path = "/api/users"
 	e.ServeHTTP(rec, req)
 	assert.Equal(t, "/api/users", req.URL.Path)
diff --git a/middleware/rewrite_test.go b/middleware/rewrite_test.go
index 0ac04bb..47d707c 100644
--- a/middleware/rewrite_test.go
+++ b/middleware/rewrite_test.go
@@ -1,7 +1,7 @@
 package middleware
 
 import (
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
@@ -142,7 +142,7 @@ func TestRewriteWithConfigPreMiddleware_Issue1143(t *testing.T) {
 		assert.Equal(t, http.StatusOK, rec.Code)
 
 		defer rec.Result().Body.Close()
-		bodyBytes, _ := ioutil.ReadAll(rec.Result().Body)
+		bodyBytes, _ := io.ReadAll(rec.Result().Body)
 		assert.Equal(t, "hosts", string(bodyBytes))
 	}
 }
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)
+			}
+		})
+	}
+}
diff --git a/middleware/timeout_test.go b/middleware/timeout_test.go
index 7fb802a..6f60753 100644
--- a/middleware/timeout_test.go
+++ b/middleware/timeout_test.go
@@ -2,9 +2,10 @@ package middleware
 
 import (
 	"bytes"
+	"context"
 	"errors"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"log"
 	"net"
 	"net/http"
@@ -12,6 +13,7 @@ import (
 	"net/url"
 	"reflect"
 	"strings"
+	"sync"
 	"testing"
 	"time"
 
@@ -127,7 +129,7 @@ func TestTimeoutOnTimeoutRouteErrorHandler(t *testing.T) {
 	e := echo.New()
 	c := e.NewContext(req, rec)
 
-	stopChan := make(chan struct{}, 0)
+	stopChan := make(chan struct{})
 	err := m(func(c echo.Context) error {
 		<-stopChan
 		return errors.New("error in route after timeout")
@@ -243,7 +245,7 @@ func TestTimeoutWithErrorMessage(t *testing.T) {
 	e := echo.New()
 	c := e.NewContext(req, rec)
 
-	stopChan := make(chan struct{}, 0)
+	stopChan := make(chan struct{})
 	err := m(func(c echo.Context) error {
 		// NOTE: when difference between timeout duration and handler execution time is almost the same (in range of 100microseconds)
 		// the result of timeout does not seem to be reliable - could respond timeout, could respond handler output
@@ -273,7 +275,7 @@ func TestTimeoutWithDefaultErrorMessage(t *testing.T) {
 	e := echo.New()
 	c := e.NewContext(req, rec)
 
-	stopChan := make(chan struct{}, 0)
+	stopChan := make(chan struct{})
 	err := m(func(c echo.Context) error {
 		<-stopChan
 		return c.String(http.StatusOK, "Hello, World!")
@@ -327,12 +329,13 @@ func TestTimeoutCanHandleContextDeadlineOnNextHandler(t *testing.T) {
 func TestTimeoutWithFullEchoStack(t *testing.T) {
 	// test timeout with full http server stack running, do see what http.Server.ErrorLog contains
 	var testCases = []struct {
-		name                 string
-		whenPath             string
-		expectStatusCode     int
-		expectResponse       string
-		expectLogContains    []string
-		expectLogNotContains []string
+		name                    string
+		whenPath                string
+		whenForceHandlerTimeout bool
+		expectStatusCode        int
+		expectResponse          string
+		expectLogContains       []string
+		expectLogNotContains    []string
 	}{
 		{
 			name:                 "404 - write response in global error handler",
@@ -351,60 +354,63 @@ func TestTimeoutWithFullEchoStack(t *testing.T) {
 			expectLogContains:    []string{`"status":418,"error":"",`},
 		},
 		{
-			name:             "503 - handler timeouts, write response in timeout middleware",
-			whenPath:         "/?delay=50ms",
-			expectResponse:   "<html><head><title>Timeout</title></head><body><h1>Timeout</h1></body></html>",
-			expectStatusCode: http.StatusServiceUnavailable,
+			name:                    "503 - handler timeouts, write response in timeout middleware",
+			whenForceHandlerTimeout: true,
+			whenPath:                "/",
+			expectResponse:          "<html><head><title>Timeout</title></head><body><h1>Timeout</h1></body></html>",
+			expectStatusCode:        http.StatusServiceUnavailable,
 			expectLogNotContains: []string{
 				"echo:http: superfluous response.WriteHeader call from",
-				"{", // means that logger was not called.
 			},
+			expectLogContains: []string{"http: Handler timeout"},
 		},
 	}
 
-	e := echo.New()
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := echo.New()
 
-	buf := new(bytes.Buffer)
-	e.Logger.SetOutput(buf)
+			buf := new(coroutineSafeBuffer)
+			e.Logger.SetOutput(buf)
 
-	// NOTE: timeout middleware is first as it changes Response.Writer and causes data race for logger middleware if it is not first
-	// FIXME: I have no idea how to fix this without adding mutexes.
-	e.Use(TimeoutWithConfig(TimeoutConfig{
-		Timeout: 15 * time.Millisecond,
-	}))
-	e.Use(Logger())
-	e.Use(Recover())
+			// NOTE: timeout middleware is first as it changes Response.Writer and causes data race for logger middleware if it is not first
+			e.Use(TimeoutWithConfig(TimeoutConfig{
+				Timeout: 15 * time.Millisecond,
+			}))
+			e.Use(Logger())
+			e.Use(Recover())
 
-	e.GET("/", func(c echo.Context) error {
-		var delay time.Duration
-		if err := echo.QueryParamsBinder(c).Duration("delay", &delay).BindError(); err != nil {
-			return err
-		}
-		if delay > 0 {
-			time.Sleep(delay)
-		}
-		return c.JSON(http.StatusTeapot, map[string]string{"message": "OK"})
-	})
-
-	server, addr, err := startServer(e)
-	if err != nil {
-		assert.NoError(t, err)
-		return
-	}
-	defer server.Close()
+			wg := sync.WaitGroup{}
+			if tc.whenForceHandlerTimeout {
+				wg.Add(1) // make `wg.Wait()` block until we release it with `wg.Done()`
+			}
+			e.GET("/", func(c echo.Context) error {
+				wg.Wait()
+				return c.JSON(http.StatusTeapot, map[string]string{"message": "OK"})
+			})
 
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			buf.Reset() // this is design this can not be run in parallel
+			server, addr, err := startServer(e)
+			if err != nil {
+				assert.NoError(t, err)
+				return
+			}
+			defer server.Close()
 
 			res, err := http.Get(fmt.Sprintf("http://%v%v", addr, tc.whenPath))
 			if err != nil {
 				assert.NoError(t, err)
 				return
 			}
+			if tc.whenForceHandlerTimeout {
+				wg.Done()
+				// shutdown waits for server to shutdown. this way we wait logger mw to be executed
+				ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
+				defer cancel()
+				server.Shutdown(ctx)
+			}
 
 			assert.Equal(t, tc.expectStatusCode, res.StatusCode)
-			if body, err := ioutil.ReadAll(res.Body); err == nil {
+			if body, err := io.ReadAll(res.Body); err == nil {
 				assert.Equal(t, tc.expectResponse, string(body))
 			} else {
 				assert.Fail(t, err.Error())
@@ -412,15 +418,45 @@ func TestTimeoutWithFullEchoStack(t *testing.T) {
 
 			logged := buf.String()
 			for _, subStr := range tc.expectLogContains {
-				assert.True(t, strings.Contains(logged, subStr))
+				assert.True(t, strings.Contains(logged, subStr), "expected logs to contain: %v, logged: '%v'", subStr, logged)
 			}
 			for _, subStr := range tc.expectLogNotContains {
-				assert.False(t, strings.Contains(logged, subStr))
+				assert.False(t, strings.Contains(logged, subStr), "expected logs not to contain: %v, logged: '%v'", subStr, logged)
 			}
 		})
 	}
 }
 
+// as we are spawning multiple coroutines - one for http server, one for request, one by timeout middleware, one by testcase
+// we are accessing logger (writing/reading) from multiple coroutines and causing dataraces (most often reported on macos)
+// we could be writing to logger in logger middleware and at the same time our tests is getting logger buffer contents
+// in testcase coroutine.
+type coroutineSafeBuffer struct {
+	bytes.Buffer
+	lock sync.RWMutex
+}
+
+func (b *coroutineSafeBuffer) Write(p []byte) (n int, err error) {
+	b.lock.Lock()
+	defer b.lock.Unlock()
+
+	return b.Buffer.Write(p)
+}
+
+func (b *coroutineSafeBuffer) Bytes() []byte {
+	b.lock.RLock()
+	defer b.lock.RUnlock()
+
+	return b.Buffer.Bytes()
+}
+
+func (b *coroutineSafeBuffer) String() string {
+	b.lock.RLock()
+	defer b.lock.RUnlock()
+
+	return b.Buffer.String()
+}
+
 func startServer(e *echo.Echo) (*http.Server, string, error) {
 	l, err := net.Listen("tcp", ":0")
 	if err != nil {
diff --git a/router_test.go b/router_test.go
index 457566b..a954210 100644
--- a/router_test.go
+++ b/router_test.go
@@ -716,6 +716,67 @@ func TestRouterParam(t *testing.T) {
 	}
 }
 
+func TestRouter_addAndMatchAllSupportedMethods(t *testing.T) {
+	var testCases = []struct {
+		name            string
+		givenNoAddRoute bool
+		whenMethod      string
+		expectPath      string
+		expectError     string
+	}{
+		{name: "ok, CONNECT", whenMethod: http.MethodConnect},
+		{name: "ok, DELETE", whenMethod: http.MethodDelete},
+		{name: "ok, GET", whenMethod: http.MethodGet},
+		{name: "ok, HEAD", whenMethod: http.MethodHead},
+		{name: "ok, OPTIONS", whenMethod: http.MethodOptions},
+		{name: "ok, PATCH", whenMethod: http.MethodPatch},
+		{name: "ok, POST", whenMethod: http.MethodPost},
+		{name: "ok, PROPFIND", whenMethod: PROPFIND},
+		{name: "ok, PUT", whenMethod: http.MethodPut},
+		{name: "ok, TRACE", whenMethod: http.MethodTrace},
+		{name: "ok, REPORT", whenMethod: REPORT},
+		{name: "ok, NON_TRADITIONAL_METHOD", whenMethod: "NON_TRADITIONAL_METHOD"},
+		{
+			name:            "ok, NOT_EXISTING_METHOD",
+			whenMethod:      "NOT_EXISTING_METHOD",
+			givenNoAddRoute: true,
+			expectPath:      "/*",
+			expectError:     "code=405, message=Method Not Allowed",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+
+			e.GET("/*", handlerFunc)
+
+			if !tc.givenNoAddRoute {
+				e.Add(tc.whenMethod, "/my/*", handlerFunc)
+			}
+
+			req := httptest.NewRequest(tc.whenMethod, "/my/some-url", nil)
+			rec := httptest.NewRecorder()
+			c := e.NewContext(req, rec).(*context)
+
+			e.router.Find(tc.whenMethod, "/my/some-url", c)
+			err := c.handler(c)
+
+			if tc.expectError != "" {
+				assert.EqualError(t, err, tc.expectError)
+			} else {
+				assert.NoError(t, err)
+			}
+
+			expectPath := "/my/*"
+			if tc.expectPath != "" {
+				expectPath = tc.expectPath
+			}
+			assert.Equal(t, expectPath, c.Path())
+		})
+	}
+}
+
 func TestMethodNotAllowedAndNotFound(t *testing.T) {
 	e := New()
 	r := e.router
@@ -1101,6 +1162,228 @@ func TestRouterBacktrackingFromMultipleParamKinds(t *testing.T) {
 	}
 }
 
+func TestNotFoundRouteAnyKind(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		whenURL     string
+		expectRoute interface{}
+		expectID    int
+		expectParam map[string]string
+	}{
+		{
+			name:        "route not existent /xx to not found handler /*",
+			whenURL:     "/xx",
+			expectRoute: "/*",
+			expectID:    4,
+			expectParam: map[string]string{"*": "xx"},
+		},
+		{
+			name:        "route not existent /a/xx to not found handler /a/*",
+			whenURL:     "/a/xx",
+			expectRoute: "/a/*",
+			expectID:    5,
+			expectParam: map[string]string{"*": "xx"},
+		},
+		{
+			name:        "route not existent /a/c/dxxx to not found handler /a/c/d*",
+			whenURL:     "/a/c/dxxx",
+			expectRoute: "/a/c/d*",
+			expectID:    6,
+			expectParam: map[string]string{"*": "xxx"},
+		},
+		{
+			name:        "route /a/c/df to /a/c/df",
+			whenURL:     "/a/c/df",
+			expectRoute: "/a/c/df",
+			expectID:    1,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			r := e.router
+
+			r.Add(http.MethodGet, "/", handlerHelper("ID", 0))
+			r.Add(http.MethodGet, "/a/c/df", handlerHelper("ID", 1))
+			r.Add(http.MethodGet, "/a/b*", handlerHelper("ID", 2))
+			r.Add(http.MethodPut, "/*", handlerHelper("ID", 3))
+
+			r.Add(RouteNotFound, "/a/c/d*", handlerHelper("ID", 6))
+			r.Add(RouteNotFound, "/a/*", handlerHelper("ID", 5))
+			r.Add(RouteNotFound, "/*", handlerHelper("ID", 4))
+
+			c := e.NewContext(nil, nil).(*context)
+			r.Find(http.MethodGet, tc.whenURL, c)
+
+			c.handler(c)
+
+			testValue, _ := c.Get("ID").(int)
+			assert.Equal(t, tc.expectID, testValue)
+			assert.Equal(t, tc.expectRoute, c.Path())
+			for param, expectedValue := range tc.expectParam {
+				assert.Equal(t, expectedValue, c.Param(param))
+			}
+			checkUnusedParamValues(t, c, tc.expectParam)
+		})
+	}
+}
+
+func TestNotFoundRouteParamKind(t *testing.T) {
+	var testCases = []struct {
+		name        string
+		whenURL     string
+		expectRoute interface{}
+		expectID    int
+		expectParam map[string]string
+	}{
+		{
+			name:        "route not existent /xx to not found handler /:file",
+			whenURL:     "/xx",
+			expectRoute: "/:file",
+			expectID:    4,
+			expectParam: map[string]string{"file": "xx"},
+		},
+		{
+			name:        "route not existent /a/xx to not found handler /a/:file",
+			whenURL:     "/a/xx",
+			expectRoute: "/a/:file",
+			expectID:    5,
+			expectParam: map[string]string{"file": "xx"},
+		},
+		{
+			name:        "route not existent /a/c/dxxx to not found handler /a/c/d:file",
+			whenURL:     "/a/c/dxxx",
+			expectRoute: "/a/c/d:file",
+			expectID:    6,
+			expectParam: map[string]string{"file": "xxx"},
+		},
+		{
+			name:        "route /a/c/df to /a/c/df",
+			whenURL:     "/a/c/df",
+			expectRoute: "/a/c/df",
+			expectID:    1,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			r := e.router
+
+			r.Add(http.MethodGet, "/", handlerHelper("ID", 0))
+			r.Add(http.MethodGet, "/a/c/df", handlerHelper("ID", 1))
+			r.Add(http.MethodGet, "/a/b*", handlerHelper("ID", 2))
+			r.Add(http.MethodPut, "/*", handlerHelper("ID", 3))
+
+			r.Add(RouteNotFound, "/a/c/d:file", handlerHelper("ID", 6))
+			r.Add(RouteNotFound, "/a/:file", handlerHelper("ID", 5))
+			r.Add(RouteNotFound, "/:file", handlerHelper("ID", 4))
+
+			c := e.NewContext(nil, nil).(*context)
+			r.Find(http.MethodGet, tc.whenURL, c)
+
+			c.handler(c)
+
+			testValue, _ := c.Get("ID").(int)
+			assert.Equal(t, tc.expectID, testValue)
+			assert.Equal(t, tc.expectRoute, c.Path())
+			for param, expectedValue := range tc.expectParam {
+				assert.Equal(t, expectedValue, c.Param(param))
+			}
+			checkUnusedParamValues(t, c, tc.expectParam)
+		})
+	}
+}
+
+func TestNotFoundRouteStaticKind(t *testing.T) {
+	// note: static not found handler is quite silly thing to have but we still support it
+	var testCases = []struct {
+		name        string
+		whenURL     string
+		expectRoute interface{}
+		expectID    int
+		expectParam map[string]string
+	}{
+		{
+			name:        "route not existent / to not found handler /",
+			whenURL:     "/",
+			expectRoute: "/",
+			expectID:    3,
+			expectParam: map[string]string{},
+		},
+		{
+			name:        "route /a to /a",
+			whenURL:     "/a",
+			expectRoute: "/a",
+			expectID:    1,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := New()
+			r := e.router
+
+			r.Add(http.MethodPut, "/", handlerHelper("ID", 0))
+			r.Add(http.MethodGet, "/a", handlerHelper("ID", 1))
+			r.Add(http.MethodPut, "/*", handlerHelper("ID", 2))
+
+			r.Add(RouteNotFound, "/", handlerHelper("ID", 3))
+
+			c := e.NewContext(nil, nil).(*context)
+			r.Find(http.MethodGet, tc.whenURL, c)
+
+			c.handler(c)
+
+			testValue, _ := c.Get("ID").(int)
+			assert.Equal(t, tc.expectID, testValue)
+			assert.Equal(t, tc.expectRoute, c.Path())
+			for param, expectedValue := range tc.expectParam {
+				assert.Equal(t, expectedValue, c.Param(param))
+			}
+			checkUnusedParamValues(t, c, tc.expectParam)
+		})
+	}
+}
+
+func TestRouter_notFoundRouteWithNodeSplitting(t *testing.T) {
+	e := New()
+	r := e.router
+
+	r.Add(http.MethodGet, "/test*", handlerHelper("ID", 0))
+	r.Add(RouteNotFound, "/*", handlerHelper("ID", 1))
+	r.Add(RouteNotFound, "/test", handlerHelper("ID", 2))
+
+	// Tree before:
+	// 1    `/`
+	// 1.1      `*` (any) ID=1
+	// 1.2      `test` (static) ID=2
+	// 1.2.1        `*` (any) ID=0
+
+	// node with path `test` has routeNotFound handler from previous Add call. Now when we insert `/te/st*` into router tree
+	// This means that node `test` is split into `te` and `st` nodes and new node `/st*` is inserted.
+	// On that split `/test` routeNotFound handler must not be lost.
+	r.Add(http.MethodGet, "/te/st*", handlerHelper("ID", 3))
+	// Tree after:
+	// 1    `/`
+	// 1.1      `*` (any) ID=1
+	// 1.2      `te` (static)
+	// 1.2.1        `st` (static) ID=2
+	// 1.2.1.1          `*` (any) ID=0
+	// 1.2.2        `/st` (static)
+	// 1.2.2.1          `*` (any) ID=3
+
+	c := e.NewContext(nil, nil).(*context)
+	r.Find(http.MethodPut, "/test", c)
+
+	c.handler(c)
+
+	testValue, _ := c.Get("ID").(int)
+	assert.Equal(t, 2, testValue)
+	assert.Equal(t, "/test", c.Path())
+}
+
 // Issue #1509
 func TestRouterParamStaticConflict(t *testing.T) {
 	e := New()
@@ -2318,6 +2601,33 @@ func TestRouterPanicWhenParamNoRootOnlyChildsFailsFind(t *testing.T) {
 	}
 }
 
+// Issue #1726
+func TestRouterDifferentParamsInPath(t *testing.T) {
+	e := New()
+	r := e.router
+	r.Add(http.MethodPut, "/*", func(Context) error {
+		return nil
+	})
+	r.Add(http.MethodPut, "/users/:vid/files/:gid", func(Context) error {
+		return nil
+	})
+	r.Add(http.MethodGet, "/users/:uid/files/:fid", func(Context) error {
+		return nil
+	})
+	c := e.NewContext(nil, nil).(*context)
+
+	r.Find(http.MethodGet, "/users/1/files/2", c)
+	assert.Equal(t, "1", c.Param("uid"))
+	assert.Equal(t, "2", c.Param("fid"))
+
+	r.Find(http.MethodGet, "/users/1/shouldBacktrackToFirstAnyRouteAnd405", c)
+	assert.Equal(t, "/*", c.Path())
+
+	r.Find(http.MethodPut, "/users/3/files/4", c)
+	assert.Equal(t, "3", c.Param("vid"))
+	assert.Equal(t, "4", c.Param("gid"))
+}
+
 func TestRouterHandleMethodOptions(t *testing.T) {
 	e := New()
 	r := e.router
@@ -2380,11 +2690,30 @@ func TestRouterHandleMethodOptions(t *testing.T) {
 				assert.NoError(t, err)
 				assert.Equal(t, tc.expectStatus, rec.Code)
 			}
-			assert.Equal(t, tc.expectAllowHeader, c.Response().Header().Get("Allow"))
+			assert.Equal(t, tc.expectAllowHeader, c.Response().Header().Get(HeaderAllow))
 		})
 	}
 }
 
+func TestRouterAllowHeaderForAnyOtherMethodType(t *testing.T) {
+	e := New()
+	r := e.router
+
+	r.Add(http.MethodGet, "/users", handlerFunc)
+	r.Add("COPY", "/users", handlerFunc)
+	r.Add("LOCK", "/users", handlerFunc)
+
+	req := httptest.NewRequest("TEST", "/users", nil)
+	rec := httptest.NewRecorder()
+	c := e.NewContext(req, rec).(*context)
+
+	r.Find("TEST", "/users", c)
+	err := c.handler(c)
+
+	assert.EqualError(t, err, "code=405, message=Method Not Allowed")
+	assert.ElementsMatch(t, []string{"COPY", "GET", "LOCK", "OPTIONS"}, strings.Split(c.Response().Header().Get(HeaderAllow), ", "))
+}
+
 func benchmarkRouterRoutes(b *testing.B, routes []*Route, routesToFind []*Route) {
 	e := New()
 	r := e.router