Repository: labstack/echo
Pull request #2039 (2021-12-04 21:12:03)

`Allow` header support in Router, MethodNotFoundHandler (405) and CORS middleware
This PR adds support for `Allow` header to 
* http OPTIONS method responses
* status 405 (method not found) responses
* [CORS](https://developer.mozilla.org/en-US/docs/Glossary/CORS) middleware

`Allow` header lists all method types registered for given routed url path.

Related RFCs:
* `Allow` RFC https://datatracker.ietf.org/doc/html/rfc7231#section-7.4.1 all 405 should have `Allow` header listing all methods that router has registered for given path.
* OPTIONS RFC https://httpwg.org/specs/rfc7231.html#OPTIONS `Allow` is optional but useful header to have

Implementation notes:
* Although in case of OPTIONS method router now add special options method handler instead of MethodNotFound handler as found/matched handler, we can not rely on that for CORS middleware. In CORS middleware we can not remove IF conditions for OPTIONS as when browser sends OPTIONS request it does not (by default) include cookie / authentication headers and therefore if we would blindly use `next(c)` and hope to meet our router optionshandler we probably not succeed because of different kinds of authentication middlewares (ala JWT or BasicAuth or KeyAuth) which check for stuff like that.
* The reason for adding `Allow` value to context `echo.ContextKeyHeaderAllow` is because default 405 handler needs to be able to access that value and possibly  CORS middleware is interested in that value.  As `echo.MethodNotAllowedHandler` is part of public API we can not change how that method is created due backwards compatibility. 
* `optionsMethodHandler` is kept private as it is router specific implementation detail. If you want your own then you can add them with `e.OPTIONS()` method for paths you choose.

Using context values in Echo core is so far unprecedented and potentially controversial decision. I did not want to introduce new field into `echo.context` struct as it is quite specific case and I know that Go standard library `http.Server` is using `context.Context` to add some info to each request context - so it is not so unheard of but probably should be avoided:

See: 
* [http.ServerContextKey](https://github.com/golang/go/blob/3396878af43752a7c25406dabd525754f80a1c40/src/net/http/server.go#L3037)
* [http.LocalAddrContextKey](https://github.com/golang/go/blob/3396878af43752a7c25406dabd525754f80a1c40/src/net/http/server.go#L1819)

Let's have a discussion

codecov[bot]: # [Codecov](https://codecov.io/gh/labstack/echo/pull/2039?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) Report
> Merging [#2039](https://codecov.io/gh/labstack/echo/pull/2039?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) (b79d52b) into [master](https://codecov.io/gh/labstack/echo/commit/b437ee3879f21e973be85ff827754343a34fbe35?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) (b437ee3) will **increase** coverage by `0.23%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/labstack/echo/pull/2039/graphs/tree.svg?width=650&height=150&src=pr&token=vqnT76l79i&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack)](https://codecov.io/gh/labstack/echo/pull/2039?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack)

```diff
@@            Coverage Diff             @@
##           master    #2039      +/-   ##
==========================================
+ Coverage   91.32%   91.56%   +0.23%     
==========================================
  Files          33       33              
  Lines        2871     2917      +46     
==========================================
+ Hits         2622     2671      +49     
+ Misses        159      157       -2     
+ Partials       90       89       -1     
```


| [Impacted Files](https://codecov.io/gh/labstack/echo/pull/2039?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) | Coverage Δ | |
|---|---|---|
| [context.go](https://codecov.io/gh/labstack/echo/pull/2039/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-Y29udGV4dC5nbw==) | `86.93% <ø> (ø)` | |
| [echo.go](https://codecov.io/gh/labstack/echo/pull/2039/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-ZWNoby5nbw==) | `94.20% <100.00%> (+0.05%)` | :arrow_up: |
| [middleware/cors.go](https://codecov.io/gh/labstack/echo/pull/2039/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-bWlkZGxld2FyZS9jb3JzLmdv) | `92.13% <100.00%> (+2.97%)` | :arrow_up: |
| [router.go](https://codecov.io/gh/labstack/echo/pull/2039/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-cm91dGVyLmdv) | `96.48% <100.00%> (+0.75%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/labstack/echo/pull/2039?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/labstack/echo/pull/2039?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack). Last update [b437ee3...b79d52b](https://codecov.io/gh/labstack/echo/pull/2039?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).

aldas: @lammel when you have time. please review. 
lammel: Working on it... so many lines to review ;-)

Martti T. ***@***.***> schrieb am So., 5. Dez. 2021, 18:35:

> @lammel <https://github.com/lammel> when you have time. please review.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/labstack/echo/pull/2039#issuecomment-986270334>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAAKVHUTUACCGXVWWVWZSTTUPOPFPANCNFSM5JL7GEXQ>
> .
> Triage notifications on the go with GitHub Mobile for iOS
> <https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
> or Android
> <https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub>.
>
>


Patch:
diff --git a/context.go b/context.go
index 91ab6e4..f2421d7 100644
--- a/context.go
+++ b/context.go
@@ -210,6 +210,13 @@ type (
 	}
 )
 
+const (
+	// ContextKeyHeaderAllow is set by Router for getting value for `Allow` header in later stages of handler call chain.
+	// Allow header is mandatory for status 405 (method not found) and useful for OPTIONS method requests.
+	// It is added to context only when Router does not find matching method handler for request.
+	ContextKeyHeaderAllow = "echo_header_allow"
+)
+
 const (
 	defaultMemory = 32 << 20 // 32 MB
 	indexPage     = "index.html"
diff --git a/echo.go b/echo.go
index df5d358..4278982 100644
--- a/echo.go
+++ b/echo.go
@@ -190,8 +190,12 @@ const (
 
 // Headers
 const (
-	HeaderAccept              = "Accept"
-	HeaderAcceptEncoding      = "Accept-Encoding"
+	HeaderAccept         = "Accept"
+	HeaderAcceptEncoding = "Accept-Encoding"
+	// HeaderAllow is the name of the "Allow" header field used to list the set of methods
+	// advertised as supported by the target resource. Returning an Allow header is mandatory
+	// for status 405 (method not found) and useful for the OPTIONS method in responses.
+	// See RFC 7231: https://datatracker.ietf.org/doc/html/rfc7231#section-7.4.1
 	HeaderAllow               = "Allow"
 	HeaderAuthorization       = "Authorization"
 	HeaderContentDisposition  = "Content-Disposition"
@@ -214,6 +218,7 @@ const (
 	HeaderXHTTPMethodOverride = "X-HTTP-Method-Override"
 	HeaderXRealIP             = "X-Real-IP"
 	HeaderXRequestID          = "X-Request-ID"
+	HeaderXCorrelationID      = "X-Correlation-ID"
 	HeaderXRequestedWith      = "X-Requested-With"
 	HeaderServer              = "Server"
 	HeaderOrigin              = "Origin"
@@ -301,6 +306,12 @@ var (
 	}
 
 	MethodNotAllowedHandler = func(c Context) error {
+		// See RFC 7231 section 7.4.1: An origin server MUST generate an Allow field in a 405 (Method Not Allowed)
+		// response and MAY do so in any other response. For disabled resources an empty Allow header may be returned
+		routerAllowMethods, ok := c.Get(ContextKeyHeaderAllow).(string)
+		if ok && routerAllowMethods != "" {
+			c.Response().Header().Set(HeaderAllow, routerAllowMethods)
+		}
 		return ErrMethodNotAllowed
 	}
 )
diff --git a/middleware/compress.go b/middleware/compress.go
index 6ae1974..ac6672e 100644
--- a/middleware/compress.go
+++ b/middleware/compress.go
@@ -27,6 +27,7 @@ type (
 	gzipResponseWriter struct {
 		io.Writer
 		http.ResponseWriter
+		wroteBody bool
 	}
 )
 
@@ -78,8 +79,9 @@ func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc {
 				}
 				rw := res.Writer
 				w.Reset(rw)
+				grw := &gzipResponseWriter{Writer: w, ResponseWriter: rw}
 				defer func() {
-					if res.Size == 0 {
+					if !grw.wroteBody {
 						if res.Header().Get(echo.HeaderContentEncoding) == gzipScheme {
 							res.Header().Del(echo.HeaderContentEncoding)
 						}
@@ -92,7 +94,6 @@ func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc {
 					w.Close()
 					pool.Put(w)
 				}()
-				grw := &gzipResponseWriter{Writer: w, ResponseWriter: rw}
 				res.Writer = grw
 			}
 			return next(c)
@@ -101,9 +102,6 @@ func GzipWithConfig(config GzipConfig) echo.MiddlewareFunc {
 }
 
 func (w *gzipResponseWriter) WriteHeader(code int) {
-	if code == http.StatusNoContent { // Issue #489
-		w.ResponseWriter.Header().Del(echo.HeaderContentEncoding)
-	}
 	w.Header().Del(echo.HeaderContentLength) // Issue #444
 	w.ResponseWriter.WriteHeader(code)
 }
@@ -112,6 +110,7 @@ func (w *gzipResponseWriter) Write(b []byte) (int, error) {
 	if w.Header().Get(echo.HeaderContentType) == "" {
 		w.Header().Set(echo.HeaderContentType, http.DetectContentType(b))
 	}
+	w.wroteBody = true
 	return w.Writer.Write(b)
 }
 
diff --git a/middleware/cors.go b/middleware/cors.go
index d6ef896..1625951 100644
--- a/middleware/cors.go
+++ b/middleware/cors.go
@@ -29,6 +29,8 @@ type (
 		// AllowMethods defines a list 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
+		// from `Allow` header that echo.Router set into context.
 		AllowMethods []string `yaml:"allow_methods"`
 
 		// AllowHeaders defines a list of request headers that can be used when
@@ -41,6 +43,8 @@ type (
 		// a response to a preflight request, this indicates whether or not the
 		// actual request can be made using credentials.
 		// Optional. Default value false.
+		// Security: avoid using `AllowCredentials = true` with `AllowOrigins = *`.
+		// See http://blog.portswigger.net/2016/10/exploiting-cors-misconfigurations-for.html
 		AllowCredentials bool `yaml:"allow_credentials"`
 
 		// ExposeHeaders defines a whitelist headers that clients are allowed to
@@ -80,7 +84,9 @@ func CORSWithConfig(config CORSConfig) echo.MiddlewareFunc {
 	if len(config.AllowOrigins) == 0 {
 		config.AllowOrigins = DefaultCORSConfig.AllowOrigins
 	}
+	hasCustomAllowMethods := true
 	if len(config.AllowMethods) == 0 {
+		hasCustomAllowMethods = false
 		config.AllowMethods = DefaultCORSConfig.AllowMethods
 	}
 
@@ -109,10 +115,28 @@ func CORSWithConfig(config CORSConfig) echo.MiddlewareFunc {
 			origin := req.Header.Get(echo.HeaderOrigin)
 			allowOrigin := ""
 
-			preflight := req.Method == http.MethodOptions
 			res.Header().Add(echo.HeaderVary, echo.HeaderOrigin)
 
-			// No Origin provided
+			// Preflight request is an OPTIONS request, using three HTTP request headers: Access-Control-Request-Method,
+			// Access-Control-Request-Headers, and the Origin header. See: https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request
+			// For simplicity we just consider method type and later `Origin` header.
+			preflight := req.Method == http.MethodOptions
+
+			// Although router adds special handler in case of OPTIONS method we avoid calling next for OPTIONS in this middleware
+			// as CORS requests do not have cookies / authentication headers by default, so we could get stuck in auth
+			// middlewares by calling next(c).
+			// But we still want to send `Allow` header as response in case of Non-CORS OPTIONS request as router default
+			// handler does.
+			routerAllowMethods := ""
+			if preflight {
+				tmpAllowMethods, ok := c.Get(echo.ContextKeyHeaderAllow).(string)
+				if ok && tmpAllowMethods != "" {
+					routerAllowMethods = tmpAllowMethods
+					c.Response().Header().Set(echo.HeaderAllow, routerAllowMethods)
+				}
+			}
+
+			// No Origin provided. This is (probably) not request from actual browser - proceed executing middleware chain
 			if origin == "" {
 				if !preflight {
 					return next(c)
@@ -145,19 +169,15 @@ func CORSWithConfig(config CORSConfig) echo.MiddlewareFunc {
 					}
 				}
 
-				// Check allowed origin patterns
-				for _, re := range allowOriginPatterns {
-					if allowOrigin == "" {
-						didx := strings.Index(origin, "://")
-						if didx == -1 {
-							continue
-						}
-						domAuth := origin[didx+3:]
-						// to avoid regex cost by invalid long domain
-						if len(domAuth) > 253 {
-							break
-						}
-
+				checkPatterns := false
+				if allowOrigin == "" {
+					// to avoid regex cost by invalid (long) domains (253 is domain name max limit)
+					if len(origin) <= (253+3+5) && strings.Contains(origin, "://") {
+						checkPatterns = true
+					}
+				}
+				if checkPatterns {
+					for _, re := range allowOriginPatterns {
 						if match, _ := regexp.MatchString(re, origin); match {
 							allowOrigin = origin
 							break
@@ -174,12 +194,13 @@ func CORSWithConfig(config CORSConfig) echo.MiddlewareFunc {
 				return c.NoContent(http.StatusNoContent)
 			}
 
+			res.Header().Set(echo.HeaderAccessControlAllowOrigin, allowOrigin)
+			if config.AllowCredentials {
+				res.Header().Set(echo.HeaderAccessControlAllowCredentials, "true")
+			}
+
 			// Simple request
 			if !preflight {
-				res.Header().Set(echo.HeaderAccessControlAllowOrigin, allowOrigin)
-				if config.AllowCredentials {
-					res.Header().Set(echo.HeaderAccessControlAllowCredentials, "true")
-				}
 				if exposeHeaders != "" {
 					res.Header().Set(echo.HeaderAccessControlExposeHeaders, exposeHeaders)
 				}
@@ -189,11 +210,13 @@ func CORSWithConfig(config CORSConfig) echo.MiddlewareFunc {
 			// Preflight request
 			res.Header().Add(echo.HeaderVary, echo.HeaderAccessControlRequestMethod)
 			res.Header().Add(echo.HeaderVary, echo.HeaderAccessControlRequestHeaders)
-			res.Header().Set(echo.HeaderAccessControlAllowOrigin, allowOrigin)
-			res.Header().Set(echo.HeaderAccessControlAllowMethods, allowMethods)
-			if config.AllowCredentials {
-				res.Header().Set(echo.HeaderAccessControlAllowCredentials, "true")
+
+			if !hasCustomAllowMethods && routerAllowMethods != "" {
+				res.Header().Set(echo.HeaderAccessControlAllowMethods, routerAllowMethods)
+			} else {
+				res.Header().Set(echo.HeaderAccessControlAllowMethods, allowMethods)
 			}
+
 			if allowHeaders != "" {
 				res.Header().Set(echo.HeaderAccessControlAllowHeaders, allowHeaders)
 			} else {
diff --git a/middleware/jwt.go b/middleware/jwt.go
index 21e33ab..43605e3 100644
--- a/middleware/jwt.go
+++ b/middleware/jwt.go
@@ -68,9 +68,14 @@ type (
 		// - "form:<name>"
 		// Multiply sources example:
 		// - "header: Authorization,cookie: myowncookie"
-
 		TokenLookup string
 
+		// TokenLookupFuncs defines a list of user-defined functions that extract JWT token from the given context.
+		// This is one of the two options to provide a token extractor.
+		// The order of precedence is user-defined TokenLookupFuncs, and TokenLookup.
+		// You can also provide both if you want.
+		TokenLookupFuncs []TokenLookupFunc
+
 		// AuthScheme to be used in the Authorization header.
 		// Optional. Default value "Bearer".
 		AuthScheme string
@@ -103,7 +108,8 @@ type (
 	// JWTErrorHandlerWithContext is almost identical to JWTErrorHandler, but it's passed the current context.
 	JWTErrorHandlerWithContext func(error, echo.Context) error
 
-	jwtExtractor func(echo.Context) (string, error)
+	// TokenLookupFunc defines a function for extracting JWT token from the given context.
+	TokenLookupFunc func(echo.Context) (string, error)
 )
 
 // Algorithms
@@ -120,13 +126,14 @@ var (
 var (
 	// DefaultJWTConfig is the default JWT auth middleware config.
 	DefaultJWTConfig = JWTConfig{
-		Skipper:       DefaultSkipper,
-		SigningMethod: AlgorithmHS256,
-		ContextKey:    "user",
-		TokenLookup:   "header:" + echo.HeaderAuthorization,
-		AuthScheme:    "Bearer",
-		Claims:        jwt.MapClaims{},
-		KeyFunc:       nil,
+		Skipper:          DefaultSkipper,
+		SigningMethod:    AlgorithmHS256,
+		ContextKey:       "user",
+		TokenLookup:      "header:" + echo.HeaderAuthorization,
+		TokenLookupFuncs: nil,
+		AuthScheme:       "Bearer",
+		Claims:           jwt.MapClaims{},
+		KeyFunc:          nil,
 	}
 )
 
@@ -163,7 +170,7 @@ func JWTWithConfig(config JWTConfig) echo.MiddlewareFunc {
 	if config.Claims == nil {
 		config.Claims = DefaultJWTConfig.Claims
 	}
-	if config.TokenLookup == "" {
+	if config.TokenLookup == "" && len(config.TokenLookupFuncs) == 0 {
 		config.TokenLookup = DefaultJWTConfig.TokenLookup
 	}
 	if config.AuthScheme == "" {
@@ -179,7 +186,7 @@ func JWTWithConfig(config JWTConfig) echo.MiddlewareFunc {
 	// Initialize
 	// Split sources
 	sources := strings.Split(config.TokenLookup, ",")
-	var extractors []jwtExtractor
+	var extractors = config.TokenLookupFuncs
 	for _, source := range sources {
 		parts := strings.Split(source, ":")
 
@@ -290,8 +297,8 @@ func (config *JWTConfig) defaultKeyFunc(t *jwt.Token) (interface{}, error) {
 	return config.SigningKey, nil
 }
 
-// jwtFromHeader returns a `jwtExtractor` that extracts token from the request header.
-func jwtFromHeader(header string, authScheme string) jwtExtractor {
+// jwtFromHeader returns a `TokenLookupFunc` that extracts token from the request header.
+func jwtFromHeader(header string, authScheme string) TokenLookupFunc {
 	return func(c echo.Context) (string, error) {
 		auth := c.Request().Header.Get(header)
 		l := len(authScheme)
@@ -302,8 +309,8 @@ func jwtFromHeader(header string, authScheme string) jwtExtractor {
 	}
 }
 
-// jwtFromQuery returns a `jwtExtractor` that extracts token from the query string.
-func jwtFromQuery(param string) jwtExtractor {
+// jwtFromQuery returns a `TokenLookupFunc` that extracts token from the query string.
+func jwtFromQuery(param string) TokenLookupFunc {
 	return func(c echo.Context) (string, error) {
 		token := c.QueryParam(param)
 		if token == "" {
@@ -313,8 +320,8 @@ func jwtFromQuery(param string) jwtExtractor {
 	}
 }
 
-// jwtFromParam returns a `jwtExtractor` that extracts token from the url param string.
-func jwtFromParam(param string) jwtExtractor {
+// jwtFromParam returns a `TokenLookupFunc` that extracts token from the url param string.
+func jwtFromParam(param string) TokenLookupFunc {
 	return func(c echo.Context) (string, error) {
 		token := c.Param(param)
 		if token == "" {
@@ -324,8 +331,8 @@ func jwtFromParam(param string) jwtExtractor {
 	}
 }
 
-// jwtFromCookie returns a `jwtExtractor` that extracts token from the named cookie.
-func jwtFromCookie(name string) jwtExtractor {
+// jwtFromCookie returns a `TokenLookupFunc` that extracts token from the named cookie.
+func jwtFromCookie(name string) TokenLookupFunc {
 	return func(c echo.Context) (string, error) {
 		cookie, err := c.Cookie(name)
 		if err != nil {
@@ -335,8 +342,8 @@ func jwtFromCookie(name string) jwtExtractor {
 	}
 }
 
-// jwtFromForm returns a `jwtExtractor` that extracts token from the form field.
-func jwtFromForm(name string) jwtExtractor {
+// jwtFromForm returns a `TokenLookupFunc` that extracts token from the form field.
+func jwtFromForm(name string) TokenLookupFunc {
 	return func(c echo.Context) (string, error) {
 		field := c.FormValue(name)
 		if field == "" {
diff --git a/middleware/request_id.go b/middleware/request_id.go
index b0baeeb..8c5ff66 100644
--- a/middleware/request_id.go
+++ b/middleware/request_id.go
@@ -17,14 +17,18 @@ type (
 
 		// RequestIDHandler defines a function which is executed for a request id.
 		RequestIDHandler func(echo.Context, string)
+
+		// TargetHeader defines what header to look for to populate the id
+		TargetHeader string
 	}
 )
 
 var (
 	// DefaultRequestIDConfig is the default RequestID middleware config.
 	DefaultRequestIDConfig = RequestIDConfig{
-		Skipper:   DefaultSkipper,
-		Generator: generator,
+		Skipper:      DefaultSkipper,
+		Generator:    generator,
+		TargetHeader: echo.HeaderXRequestID,
 	}
 )
 
@@ -42,6 +46,9 @@ func RequestIDWithConfig(config RequestIDConfig) echo.MiddlewareFunc {
 	if config.Generator == nil {
 		config.Generator = generator
 	}
+	if config.TargetHeader == "" {
+		config.TargetHeader = echo.HeaderXRequestID
+	}
 
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
 		return func(c echo.Context) error {
@@ -51,11 +58,11 @@ func RequestIDWithConfig(config RequestIDConfig) echo.MiddlewareFunc {
 
 			req := c.Request()
 			res := c.Response()
-			rid := req.Header.Get(echo.HeaderXRequestID)
+			rid := req.Header.Get(config.TargetHeader)
 			if rid == "" {
 				rid = config.Generator()
 			}
-			res.Header().Set(echo.HeaderXRequestID, rid)
+			res.Header().Set(config.TargetHeader, rid)
 			if config.RequestIDHandler != nil {
 				config.RequestIDHandler(c, rid)
 			}
diff --git a/router.go b/router.go
index a8277c8..1a2ce56 100644
--- a/router.go
+++ b/router.go
@@ -1,6 +1,7 @@
 package echo
 
 import (
+	"bytes"
 	"net/http"
 )
 
@@ -31,17 +32,18 @@ type (
 	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
+		connect     HandlerFunc
+		delete      HandlerFunc
+		get         HandlerFunc
+		head        HandlerFunc
+		options     HandlerFunc
+		patch       HandlerFunc
+		post        HandlerFunc
+		propfind    HandlerFunc
+		put         HandlerFunc
+		trace       HandlerFunc
+		report      HandlerFunc
+		allowHeader string
 	}
 )
 
@@ -68,6 +70,51 @@ func (m *methodHandler) isHandler() bool {
 		m.report != nil
 }
 
+func (m *methodHandler) updateAllowHeader() {
+	buf := new(bytes.Buffer)
+	buf.WriteString(http.MethodOptions)
+
+	if m.connect != nil {
+		buf.WriteString(", ")
+		buf.WriteString(http.MethodConnect)
+	}
+	if m.delete != nil {
+		buf.WriteString(", ")
+		buf.WriteString(http.MethodDelete)
+	}
+	if m.get != nil {
+		buf.WriteString(", ")
+		buf.WriteString(http.MethodGet)
+	}
+	if m.head != nil {
+		buf.WriteString(", ")
+		buf.WriteString(http.MethodHead)
+	}
+	if m.patch != nil {
+		buf.WriteString(", ")
+		buf.WriteString(http.MethodPatch)
+	}
+	if m.post != nil {
+		buf.WriteString(", ")
+		buf.WriteString(http.MethodPost)
+	}
+	if m.propfind != nil {
+		buf.WriteString(", PROPFIND")
+	}
+	if m.put != nil {
+		buf.WriteString(", ")
+		buf.WriteString(http.MethodPut)
+	}
+	if m.trace != nil {
+		buf.WriteString(", ")
+		buf.WriteString(http.MethodTrace)
+	}
+	if m.report != nil {
+		buf.WriteString(", REPORT")
+	}
+	m.allowHeader = buf.String()
+}
+
 // NewRouter returns a new Router instance.
 func NewRouter(e *Echo) *Router {
 	return &Router{
@@ -99,6 +146,9 @@ func (r *Router) Add(method, path string, h HandlerFunc) {
 	for i, lcpIndex := 0, len(path); i < lcpIndex; i++ {
 		if path[i] == ':' {
 			if i > 0 && path[i-1] == '\\' {
+				path = path[:i-1] + path[i:]
+				i--
+				lcpIndex--
 				continue
 			}
 			j := i + 1
@@ -323,6 +373,7 @@ func (n *node) addHandler(method string, h HandlerFunc) {
 		n.methodHandler.report = h
 	}
 
+	n.methodHandler.updateAllowHeader()
 	if h != nil {
 		n.isHandler = true
 	} else {
@@ -359,13 +410,14 @@ func (n *node) findHandler(method string) HandlerFunc {
 	}
 }
 
-func (n *node) checkMethodNotAllowed() HandlerFunc {
-	for _, m := range methods {
-		if h := n.findHandler(m); h != nil {
-			return MethodNotAllowedHandler
-		}
+func optionsMethodHandler(allowMethods string) func(c Context) error {
+	return func(c Context) error {
+		// Note: we are not handling most of the CORS headers here. CORS is handled by CORS middleware
+		// 'OPTIONS' method RFC: https://httpwg.org/specs/rfc7231.html#OPTIONS
+		// 'Allow' header RFC: https://datatracker.ietf.org/doc/html/rfc7231#section-7.4.1
+		c.Response().Header().Add(HeaderAllow, allowMethods)
+		return c.NoContent(http.StatusNoContent)
 	}
-	return NotFoundHandler
 }
 
 // Find lookup a handler registered for method and path. It also parses URL for path
@@ -560,7 +612,15 @@ func (r *Router) Find(method, path string, c Context) {
 		// 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
-		ctx.handler = currentNode.checkMethodNotAllowed()
+
+		ctx.handler = NotFoundHandler
+		if currentNode.isHandler {
+			ctx.Set(ContextKeyHeaderAllow, currentNode.methodHandler.allowHeader)
+			ctx.handler = MethodNotAllowedHandler
+			if method == http.MethodOptions {
+				ctx.handler = optionsMethodHandler(currentNode.methodHandler.allowHeader)
+			}
+		}
 	}
 	ctx.path = currentNode.ppath
 	ctx.pnames = currentNode.pnames


Test patch:
diff --git a/echo_test.go b/echo_test.go
index f289158..13a51b6 100644
--- a/echo_test.go
+++ b/echo_test.go
@@ -716,13 +716,16 @@ func TestEchoNotFound(t *testing.T) {
 
 func TestEchoMethodNotAllowed(t *testing.T) {
 	e := New()
+
 	e.GET("/", func(c Context) error {
 		return c.String(http.StatusOK, "Echo!")
 	})
 	req := httptest.NewRequest(http.MethodPost, "/", nil)
 	rec := httptest.NewRecorder()
 	e.ServeHTTP(rec, req)
+
 	assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
+	assert.Equal(t, "OPTIONS, GET", rec.Header().Get(HeaderAllow))
 }
 
 func TestEchoContext(t *testing.T) {
diff --git a/middleware/compress_test.go b/middleware/compress_test.go
index d16ffca..b62bffe 100644
--- a/middleware/compress_test.go
+++ b/middleware/compress_test.go
@@ -106,6 +106,27 @@ func TestGzipNoContent(t *testing.T) {
 	}
 }
 
+func TestGzipEmpty(t *testing.T) {
+	e := echo.New()
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	req.Header.Set(echo.HeaderAcceptEncoding, gzipScheme)
+	rec := httptest.NewRecorder()
+	c := e.NewContext(req, rec)
+	h := Gzip()(func(c echo.Context) error {
+		return c.String(http.StatusOK, "")
+	})
+	if assert.NoError(t, h(c)) {
+		assert.Equal(t, gzipScheme, rec.Header().Get(echo.HeaderContentEncoding))
+		assert.Equal(t, "text/plain; charset=UTF-8", rec.Header().Get(echo.HeaderContentType))
+		r, err := gzip.NewReader(rec.Body)
+		if assert.NoError(t, err) {
+			var buf bytes.Buffer
+			buf.ReadFrom(r)
+			assert.Equal(t, "", buf.String())
+		}
+	}
+}
+
 func TestGzipErrorReturned(t *testing.T) {
 	e := echo.New()
 	e.Use(Gzip())
diff --git a/middleware/cors_test.go b/middleware/cors_test.go
index 717abe4..daadbab 100644
--- a/middleware/cors_test.go
+++ b/middleware/cors_test.go
@@ -251,114 +251,238 @@ func Test_allowOriginSubdomain(t *testing.T) {
 	}
 }
 
+func TestCORSWithConfig_AllowMethods(t *testing.T) {
+	var testCases = []struct {
+		name            string
+		allowOrigins    []string
+		allowContextKey string
+
+		whenOrigin       string
+		whenAllowMethods []string
+
+		expectAllow                     string
+		expectAccessControlAllowMethods string
+	}{
+		{
+			name:             "custom AllowMethods, preflight, no origin, sets only allow header from context key",
+			allowContextKey:  "OPTIONS, GET",
+			whenAllowMethods: []string{http.MethodGet, http.MethodHead},
+			whenOrigin:       "",
+			expectAllow:      "OPTIONS, GET",
+		},
+		{
+			name:             "default AllowMethods, preflight, no origin, no allow header in context key and in response",
+			allowContextKey:  "",
+			whenAllowMethods: nil,
+			whenOrigin:       "",
+			expectAllow:      "",
+		},
+		{
+			name:                            "custom AllowMethods, preflight, existing origin, sets both headers different values",
+			allowContextKey:                 "OPTIONS, GET",
+			whenAllowMethods:                []string{http.MethodGet, http.MethodHead},
+			whenOrigin:                      "http://google.com",
+			expectAllow:                     "OPTIONS, GET",
+			expectAccessControlAllowMethods: "GET,HEAD",
+		},
+		{
+			name:                            "default AllowMethods, preflight, existing origin, sets both headers",
+			allowContextKey:                 "OPTIONS, GET",
+			whenAllowMethods:                nil,
+			whenOrigin:                      "http://google.com",
+			expectAllow:                     "OPTIONS, GET",
+			expectAccessControlAllowMethods: "OPTIONS, GET",
+		},
+		{
+			name:                            "default AllowMethods, preflight, existing origin, no allows, sets only CORS allow methods",
+			allowContextKey:                 "",
+			whenAllowMethods:                nil,
+			whenOrigin:                      "http://google.com",
+			expectAllow:                     "",
+			expectAccessControlAllowMethods: "GET,HEAD,PUT,PATCH,POST,DELETE",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := echo.New()
+			e.GET("/test", func(c echo.Context) error {
+				return c.String(http.StatusOK, "OK")
+			})
+
+			cors := CORSWithConfig(CORSConfig{
+				AllowOrigins: tc.allowOrigins,
+				AllowMethods: tc.whenAllowMethods,
+			})
+
+			req := httptest.NewRequest(http.MethodOptions, "/test", nil)
+			rec := httptest.NewRecorder()
+			c := e.NewContext(req, rec)
+
+			req.Header.Set(echo.HeaderOrigin, tc.whenOrigin)
+			if tc.allowContextKey != "" {
+				c.Set(echo.ContextKeyHeaderAllow, tc.allowContextKey)
+			}
+
+			h := cors(echo.NotFoundHandler)
+			h(c)
+
+			assert.Equal(t, tc.expectAllow, rec.Header().Get(echo.HeaderAllow))
+			assert.Equal(t, tc.expectAccessControlAllowMethods, rec.Header().Get(echo.HeaderAccessControlAllowMethods))
+		})
+	}
+}
+
 func TestCorsHeaders(t *testing.T) {
 	tests := []struct {
-		domain, allowedOrigin, method string
-		expected                      bool
+		name              string
+		originDomain      string
+		method            string
+		allowedOrigin     string
+		expected          bool
+		expectStatus      int
+		expectAllowHeader string
 	}{
 		{
-			domain:        "", // Request does not have Origin header
+			name:          "non-preflight request, allow any origin, missing origin header = no CORS logic done",
+			originDomain:  "",
 			allowedOrigin: "*",
 			method:        http.MethodGet,
 			expected:      false,
+			expectStatus:  http.StatusOK,
 		},
 		{
-			domain:        "http://example.com",
+			name:          "non-preflight request, allow any origin, specific origin domain",
+			originDomain:  "http://example.com",
 			allowedOrigin: "*",
 			method:        http.MethodGet,
 			expected:      true,
+			expectStatus:  http.StatusOK,
 		},
 		{
-			domain:        "", // Request does not have Origin header
+			name:          "non-preflight request, allow specific origin, missing origin header = no CORS logic done",
+			originDomain:  "", // Request does not have Origin header
 			allowedOrigin: "http://example.com",
 			method:        http.MethodGet,
 			expected:      false,
+			expectStatus:  http.StatusOK,
 		},
 		{
-			domain:        "http://bar.com",
+			name:          "non-preflight request, allow specific origin, different origin header = CORS logic failure",
+			originDomain:  "http://bar.com",
 			allowedOrigin: "http://example.com",
 			method:        http.MethodGet,
 			expected:      false,
+			expectStatus:  http.StatusOK,
 		},
 		{
-			domain:        "http://example.com",
+			name:          "non-preflight request, allow specific origin, matching origin header = CORS logic done",
+			originDomain:  "http://example.com",
 			allowedOrigin: "http://example.com",
 			method:        http.MethodGet,
 			expected:      true,
+			expectStatus:  http.StatusOK,
 		},
 		{
-			domain:        "", // Request does not have Origin header
-			allowedOrigin: "*",
-			method:        http.MethodOptions,
-			expected:      false,
+			name:              "preflight, allow any origin, missing origin header = no CORS logic done",
+			originDomain:      "", // Request does not have Origin header
+			allowedOrigin:     "*",
+			method:            http.MethodOptions,
+			expected:          false,
+			expectStatus:      http.StatusNoContent,
+			expectAllowHeader: "OPTIONS, GET, POST",
 		},
 		{
-			domain:        "http://example.com",
-			allowedOrigin: "*",
-			method:        http.MethodOptions,
-			expected:      true,
+			name:              "preflight, allow any origin, existing origin header = CORS logic done",
+			originDomain:      "http://example.com",
+			allowedOrigin:     "*",
+			method:            http.MethodOptions,
+			expected:          true,
+			expectStatus:      http.StatusNoContent,
+			expectAllowHeader: "OPTIONS, GET, POST",
 		},
 		{
-			domain:        "", // Request does not have Origin header
-			allowedOrigin: "http://example.com",
-			method:        http.MethodOptions,
-			expected:      false,
+			name:              "preflight, allow any origin, missing origin header = no CORS logic done",
+			originDomain:      "", // Request does not have Origin header
+			allowedOrigin:     "http://example.com",
+			method:            http.MethodOptions,
+			expected:          false,
+			expectStatus:      http.StatusNoContent,
+			expectAllowHeader: "OPTIONS, GET, POST",
 		},
 		{
-			domain:        "http://bar.com",
-			allowedOrigin: "http://example.com",
-			method:        http.MethodGet,
-			expected:      false,
+			name:              "preflight, allow specific origin, different origin header = no CORS logic done",
+			originDomain:      "http://bar.com",
+			allowedOrigin:     "http://example.com",
+			method:            http.MethodOptions,
+			expected:          false,
+			expectStatus:      http.StatusNoContent,
+			expectAllowHeader: "OPTIONS, GET, POST",
 		},
 		{
-			domain:        "http://example.com",
-			allowedOrigin: "http://example.com",
-			method:        http.MethodOptions,
-			expected:      true,
+			name:              "preflight, allow specific origin, matching origin header = CORS logic done",
+			originDomain:      "http://example.com",
+			allowedOrigin:     "http://example.com",
+			method:            http.MethodOptions,
+			expected:          true,
+			expectStatus:      http.StatusNoContent,
+			expectAllowHeader: "OPTIONS, GET, POST",
 		},
 	}
 
-	e := echo.New()
-	for _, tt := range tests {
-		req := httptest.NewRequest(tt.method, "/", nil)
-		rec := httptest.NewRecorder()
-		c := e.NewContext(req, rec)
-		if tt.domain != "" {
-			req.Header.Set(echo.HeaderOrigin, tt.domain)
-		}
-		cors := CORSWithConfig(CORSConfig{
-			AllowOrigins: []string{tt.allowedOrigin},
-			//AllowCredentials: true,
-			//MaxAge:           3600,
-		})
-		h := cors(echo.NotFoundHandler)
-		h(c)
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			e := echo.New()
 
-		assert.Equal(t, echo.HeaderOrigin, rec.Header().Get(echo.HeaderVary))
+			e.Use(CORSWithConfig(CORSConfig{
+				AllowOrigins: []string{tc.allowedOrigin},
+				//AllowCredentials: true,
+				//MaxAge:           3600,
+			}))
 
-		expectedAllowOrigin := ""
-		if tt.allowedOrigin == "*" {
-			expectedAllowOrigin = "*"
-		} else {
-			expectedAllowOrigin = tt.domain
-		}
+			e.GET("/", func(c echo.Context) error {
+				return c.String(http.StatusOK, "OK")
+			})
+			e.POST("/", func(c echo.Context) error {
+				return c.String(http.StatusCreated, "OK")
+			})
 
-		switch {
-		case tt.expected && tt.method == http.MethodOptions:
-			assert.Contains(t, rec.Header(), echo.HeaderAccessControlAllowMethods)
-			assert.Equal(t, expectedAllowOrigin, rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
-			assert.Equal(t, 3, len(rec.Header()[echo.HeaderVary]))
-		case tt.expected && tt.method == http.MethodGet:
-			assert.Equal(t, expectedAllowOrigin, rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
-			assert.Equal(t, 1, len(rec.Header()[echo.HeaderVary])) // Vary: Origin
-		default:
-			assert.NotContains(t, rec.Header(), echo.HeaderAccessControlAllowOrigin)
-			assert.Equal(t, 1, len(rec.Header()[echo.HeaderVary])) // Vary: Origin
-		}
+			req := httptest.NewRequest(tc.method, "/", nil)
+			rec := httptest.NewRecorder()
+
+			if tc.originDomain != "" {
+				req.Header.Set(echo.HeaderOrigin, tc.originDomain)
+			}
+
+			// we run through whole Echo handler chain to see how CORS works with Router OPTIONS handler
+			e.ServeHTTP(rec, req)
+
+			assert.Equal(t, echo.HeaderOrigin, rec.Header().Get(echo.HeaderVary))
+			assert.Equal(t, tc.expectAllowHeader, rec.Header().Get(echo.HeaderAllow))
+			assert.Equal(t, tc.expectStatus, rec.Code)
+
+			expectedAllowOrigin := ""
+			if tc.allowedOrigin == "*" {
+				expectedAllowOrigin = "*"
+			} else {
+				expectedAllowOrigin = tc.originDomain
+			}
+			switch {
+			case tc.expected && tc.method == http.MethodOptions:
+				assert.Contains(t, rec.Header(), echo.HeaderAccessControlAllowMethods)
+				assert.Equal(t, expectedAllowOrigin, rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
+
+				assert.Equal(t, 3, len(rec.Header()[echo.HeaderVary]))
+
+			case tc.expected && tc.method == http.MethodGet:
+				assert.Equal(t, expectedAllowOrigin, rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
+				assert.Equal(t, 1, len(rec.Header()[echo.HeaderVary])) // Vary: Origin
+			default:
+				assert.NotContains(t, rec.Header(), echo.HeaderAccessControlAllowOrigin)
+				assert.Equal(t, 1, len(rec.Header()[echo.HeaderVary])) // Vary: Origin
+			}
+		})
 
-		if tt.method == http.MethodOptions {
-			assert.Equal(t, http.StatusNoContent, rec.Code)
-		}
 	}
 }
 
diff --git a/middleware/jwt_test.go b/middleware/jwt_test.go
index 5f36ce0..18454d0 100644
--- a/middleware/jwt_test.go
+++ b/middleware/jwt_test.go
@@ -603,3 +603,27 @@ func TestJWTConfig_custom_ParseTokenFunc_Keyfunc(t *testing.T) {
 
 	assert.Equal(t, http.StatusTeapot, res.Code)
 }
+
+func TestJWTConfig_TokenLookupFuncs(t *testing.T) {
+	e := echo.New()
+
+	e.GET("/", func(c echo.Context) error {
+		return c.String(http.StatusOK, "test")
+	})
+
+	e.Use(JWTWithConfig(JWTConfig{
+		TokenLookupFuncs: []TokenLookupFunc{
+			func(c echo.Context) (string, error) {
+				return c.Request().Header.Get("X-API-Key"), nil
+			},
+		},
+		SigningKey: []byte("secret"),
+	}))
+
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	req.Header.Set("X-API-Key", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ")
+	res := httptest.NewRecorder()
+	e.ServeHTTP(res, req)
+
+	assert.Equal(t, http.StatusOK, res.Code)
+}
diff --git a/middleware/request_id_test.go b/middleware/request_id_test.go
index 944b3b4..21b7778 100644
--- a/middleware/request_id_test.go
+++ b/middleware/request_id_test.go
@@ -55,3 +55,34 @@ func TestRequestID_IDNotAltered(t *testing.T) {
 	_ = h(c)
 	assert.Equal(t, rec.Header().Get(echo.HeaderXRequestID), "<sample-request-id>")
 }
+
+func TestRequestIDConfigDifferentHeader(t *testing.T) {
+	e := echo.New()
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	rec := httptest.NewRecorder()
+	c := e.NewContext(req, rec)
+	handler := func(c echo.Context) error {
+		return c.String(http.StatusOK, "test")
+	}
+
+	rid := RequestIDWithConfig(RequestIDConfig{TargetHeader: echo.HeaderXCorrelationID})
+	h := rid(handler)
+	h(c)
+	assert.Len(t, rec.Header().Get(echo.HeaderXCorrelationID), 32)
+
+	// Custom generator and handler
+	customID := "customGenerator"
+	calledHandler := false
+	rid = RequestIDWithConfig(RequestIDConfig{
+		Generator:    func() string { return customID },
+		TargetHeader: echo.HeaderXCorrelationID,
+		RequestIDHandler: func(_ echo.Context, id string) {
+			calledHandler = true
+			assert.Equal(t, customID, id)
+		},
+	})
+	h = rid(handler)
+	h(c)
+	assert.Equal(t, rec.Header().Get(echo.HeaderXCorrelationID), "customGenerator")
+	assert.True(t, calledHandler)
+}
diff --git a/router_test.go b/router_test.go
index 1cb36b4..5cbb8d9 100644
--- a/router_test.go
+++ b/router_test.go
@@ -3,6 +3,7 @@ package echo
 import (
 	"fmt"
 	"net/http"
+	"net/http/httptest"
 	"strings"
 	"testing"
 
@@ -725,12 +726,13 @@ func TestMethodNotAllowedAndNotFound(t *testing.T) {
 	r.Add(http.MethodPost, "/users/:id", handlerFunc)
 
 	var testCases = []struct {
-		name        string
-		whenMethod  string
-		whenURL     string
-		expectRoute interface{}
-		expectParam map[string]string
-		expectError error
+		name              string
+		whenMethod        string
+		whenURL           string
+		expectRoute       interface{}
+		expectParam       map[string]string
+		expectError       error
+		expectAllowHeader string
 	}{
 		{
 			name:        "exact match for route+method",
@@ -740,11 +742,12 @@ func TestMethodNotAllowedAndNotFound(t *testing.T) {
 			expectParam: map[string]string{"id": "1"},
 		},
 		{
-			name:        "matches node but not method. sends 405 from best match node",
-			whenMethod:  http.MethodPut,
-			whenURL:     "/users/1",
-			expectRoute: nil,
-			expectError: ErrMethodNotAllowed,
+			name:              "matches node but not method. sends 405 from best match node",
+			whenMethod:        http.MethodPut,
+			whenURL:           "/users/1",
+			expectRoute:       nil,
+			expectError:       ErrMethodNotAllowed,
+			expectAllowHeader: "OPTIONS, POST",
 		},
 		{
 			name:        "best match is any route up in tree",
@@ -756,7 +759,9 @@ func TestMethodNotAllowedAndNotFound(t *testing.T) {
 	}
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			c := e.NewContext(nil, nil).(*context)
+			req := httptest.NewRequest(tc.whenMethod, tc.whenURL, nil)
+			rec := httptest.NewRecorder()
+			c := e.NewContext(req, rec).(*context)
 
 			method := http.MethodGet
 			if tc.whenMethod != "" {
@@ -775,10 +780,36 @@ func TestMethodNotAllowedAndNotFound(t *testing.T) {
 				assert.Equal(t, expectedValue, c.Param(param))
 			}
 			checkUnusedParamValues(t, c, tc.expectParam)
+
+			assert.Equal(t, tc.expectAllowHeader, c.Response().Header().Get(HeaderAllow))
 		})
 	}
 }
 
+func TestRouterOptionsMethodHandler(t *testing.T) {
+	e := New()
+
+	var keyInContext interface{}
+	e.Use(func(next HandlerFunc) HandlerFunc {
+		return func(c Context) error {
+			err := next(c)
+			keyInContext = c.Get(ContextKeyHeaderAllow)
+			return err
+		}
+	})
+	e.GET("/test", func(c Context) error {
+		return c.String(http.StatusOK, "Echo!")
+	})
+
+	req := httptest.NewRequest(http.MethodOptions, "/test", nil)
+	rec := httptest.NewRecorder()
+	e.ServeHTTP(rec, req)
+
+	assert.Equal(t, http.StatusNoContent, rec.Code)
+	assert.Equal(t, "OPTIONS, GET", rec.Header().Get(HeaderAllow))
+	assert.Equal(t, "OPTIONS, GET", keyInContext)
+}
+
 func TestRouterTwoParam(t *testing.T) {
 	e := New()
 	r := e.router
@@ -1124,6 +1155,8 @@ func TestRouterParam_escapeColon(t *testing.T) {
 	e := New()
 
 	e.POST("/files/a/long/file\\:undelete", handlerFunc)
+	e.POST("/multilevel\\:undelete/second\\:something", handlerFunc)
+	e.POST("/mixed/:id/second\\:something", handlerFunc)
 	e.POST("/v1/some/resource/name:customVerb", handlerFunc)
 
 	var testCases = []struct {
@@ -1133,12 +1166,22 @@ func TestRouterParam_escapeColon(t *testing.T) {
 		expectError string
 	}{
 		{
-			whenURL:     "/files/a/long/file\\:undelete",
+			whenURL:     "/files/a/long/file:undelete",
 			expectRoute: "/files/a/long/file\\:undelete",
 			expectParam: map[string]string{},
 		},
 		{
-			whenURL:     "/files/a/long/file\\:notMatching",
+			whenURL:     "/multilevel:undelete/second:something",
+			expectRoute: "/multilevel\\:undelete/second\\:something",
+			expectParam: map[string]string{},
+		},
+		{
+			whenURL:     "/mixed/123/second:something",
+			expectRoute: "/mixed/:id/second\\:something",
+			expectParam: map[string]string{"id": "123"},
+		},
+		{
+			whenURL:     "/files/a/long/file:notMatching",
 			expectRoute: nil,
 			expectError: "code=404, message=Not Found",
 			expectParam: nil,
@@ -2276,6 +2319,73 @@ func TestRouterPanicWhenParamNoRootOnlyChildsFailsFind(t *testing.T) {
 	}
 }
 
+func TestRouterHandleMethodOptions(t *testing.T) {
+	e := New()
+	r := e.router
+
+	r.Add(http.MethodGet, "/users", handlerFunc)
+	r.Add(http.MethodPost, "/users", handlerFunc)
+	r.Add(http.MethodPut, "/users/:id", handlerFunc)
+	r.Add(http.MethodGet, "/users/:id", handlerFunc)
+
+	var testCases = []struct {
+		name              string
+		whenMethod        string
+		whenURL           string
+		expectAllowHeader string
+		expectStatus      int
+	}{
+		{
+			name:              "allows GET and POST handlers",
+			whenMethod:        http.MethodOptions,
+			whenURL:           "/users",
+			expectAllowHeader: "OPTIONS, GET, POST",
+			expectStatus:      http.StatusNoContent,
+		},
+		{
+			name:              "allows GET and PUT handlers",
+			whenMethod:        http.MethodOptions,
+			whenURL:           "/users/1",
+			expectAllowHeader: "OPTIONS, GET, PUT",
+			expectStatus:      http.StatusNoContent,
+		},
+		{
+			name:              "GET does not have allows header",
+			whenMethod:        http.MethodGet,
+			whenURL:           "/users",
+			expectAllowHeader: "",
+			expectStatus:      http.StatusOK,
+		},
+		{
+			name:              "path with no handlers does not set Allows header",
+			whenMethod:        http.MethodOptions,
+			whenURL:           "/notFound",
+			expectAllowHeader: "",
+			expectStatus:      http.StatusNotFound,
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			req := httptest.NewRequest(tc.whenMethod, tc.whenURL, nil)
+			rec := httptest.NewRecorder()
+			c := e.NewContext(req, rec).(*context)
+
+			r.Find(tc.whenMethod, tc.whenURL, c)
+			err := c.handler(c)
+
+			if tc.expectStatus >= 400 {
+				assert.Error(t, err)
+				he := err.(*HTTPError)
+				assert.Equal(t, tc.expectStatus, he.Code)
+			} else {
+				assert.NoError(t, err)
+				assert.Equal(t, tc.expectStatus, rec.Code)
+			}
+			assert.Equal(t, tc.expectAllowHeader, c.Response().Header().Get("Allow"))
+		})
+	}
+}
+
 func benchmarkRouterRoutes(b *testing.B, routes []*Route, routesToFind []*Route) {
 	e := New()
 	r := e.router