Repository: labstack/echo
Pull request #2405 (2023-02-21 10:26:40)

CORS wildcard origin and allow credentials
Add middleware.CORSConfig.UnsafeWildcardOriginWithAllowCredentials to make UNSAFE usages of wildcard origin + allow cretentials less likely.

See: #2400

codecov[bot]: # [Codecov](https://codecov.io/gh/labstack/echo/pull/2405?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) Report
Base: **92.80**% // Head: **92.80**% // No change to project coverage :thumbsup:
> Coverage data is based on head [(`581ebc3`)](https://codecov.io/gh/labstack/echo/pull/2405?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) compared to base [(`b888a30`)](https://codecov.io/gh/labstack/echo/commit/b888a30fe394deeeb14e18226be51b5928115dd3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).
> Patch coverage: 60.00% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #2405   +/-   ##
=======================================
  Coverage   92.80%   92.80%           
=======================================
  Files          38       38           
  Lines        4506     4506           
=======================================
  Hits         4182     4182           
  Misses        236      236           
  Partials       88       88           
```


| [Impacted Files](https://codecov.io/gh/labstack/echo/pull/2405?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) | Coverage Δ | |
|---|---|---|
| [middleware/csrf.go](https://codecov.io/gh/labstack/echo/pull/2405?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-bWlkZGxld2FyZS9jc3JmLmdv) | `89.42% <33.33%> (ø)` | |
| [middleware/jwt.go](https://codecov.io/gh/labstack/echo/pull/2405?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-bWlkZGxld2FyZS9qd3QuZ28=) | `92.98% <33.33%> (ø)` | |
| [middleware/cors.go](https://codecov.io/gh/labstack/echo/pull/2405?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-bWlkZGxld2FyZS9jb3JzLmdv) | `92.85% <100.00%> (ø)` | |
| [middleware/key\_auth.go](https://codecov.io/gh/labstack/echo/pull/2405?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-bWlkZGxld2FyZS9rZXlfYXV0aC5nbw==) | `93.42% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/labstack/echo/pull/2405?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).

aldas: @lammel ping

Patch:
diff --git a/echo.go b/echo.go
index 7199c45..ef99c22 100644
--- a/echo.go
+++ b/echo.go
@@ -291,24 +291,53 @@ var (
 
 // Errors
 var (
-	ErrUnsupportedMediaType        = NewHTTPError(http.StatusUnsupportedMediaType)
-	ErrNotFound                    = NewHTTPError(http.StatusNotFound)
-	ErrUnauthorized                = NewHTTPError(http.StatusUnauthorized)
-	ErrForbidden                   = NewHTTPError(http.StatusForbidden)
-	ErrMethodNotAllowed            = NewHTTPError(http.StatusMethodNotAllowed)
-	ErrStatusRequestEntityTooLarge = NewHTTPError(http.StatusRequestEntityTooLarge)
-	ErrTooManyRequests             = NewHTTPError(http.StatusTooManyRequests)
-	ErrBadRequest                  = NewHTTPError(http.StatusBadRequest)
-	ErrBadGateway                  = NewHTTPError(http.StatusBadGateway)
-	ErrInternalServerError         = NewHTTPError(http.StatusInternalServerError)
-	ErrRequestTimeout              = NewHTTPError(http.StatusRequestTimeout)
-	ErrServiceUnavailable          = NewHTTPError(http.StatusServiceUnavailable)
-	ErrValidatorNotRegistered      = errors.New("validator not registered")
-	ErrRendererNotRegistered       = errors.New("renderer not registered")
-	ErrInvalidRedirectCode         = errors.New("invalid redirect status code")
-	ErrCookieNotFound              = errors.New("cookie not found")
-	ErrInvalidCertOrKeyType        = errors.New("invalid cert or key type, must be string or []byte")
-	ErrInvalidListenerNetwork      = errors.New("invalid listener network")
+	ErrBadRequest                    = NewHTTPError(http.StatusBadRequest)                    // HTTP 400 Bad Request
+	ErrUnauthorized                  = NewHTTPError(http.StatusUnauthorized)                  // HTTP 401 Unauthorized
+	ErrPaymentRequired               = NewHTTPError(http.StatusPaymentRequired)               // HTTP 402 Payment Required
+	ErrForbidden                     = NewHTTPError(http.StatusForbidden)                     // HTTP 403 Forbidden
+	ErrNotFound                      = NewHTTPError(http.StatusNotFound)                      // HTTP 404 Not Found
+	ErrMethodNotAllowed              = NewHTTPError(http.StatusMethodNotAllowed)              // HTTP 405 Method Not Allowed
+	ErrNotAcceptable                 = NewHTTPError(http.StatusNotAcceptable)                 // HTTP 406 Not Acceptable
+	ErrProxyAuthRequired             = NewHTTPError(http.StatusProxyAuthRequired)             // HTTP 407 Proxy AuthRequired
+	ErrRequestTimeout                = NewHTTPError(http.StatusRequestTimeout)                // HTTP 408 Request Timeout
+	ErrConflict                      = NewHTTPError(http.StatusConflict)                      // HTTP 409 Conflict
+	ErrGone                          = NewHTTPError(http.StatusGone)                          // HTTP 410 Gone
+	ErrLengthRequired                = NewHTTPError(http.StatusLengthRequired)                // HTTP 411 Length Required
+	ErrPreconditionFailed            = NewHTTPError(http.StatusPreconditionFailed)            // HTTP 412 Precondition Failed
+	ErrStatusRequestEntityTooLarge   = NewHTTPError(http.StatusRequestEntityTooLarge)         // HTTP 413 Payload Too Large
+	ErrRequestURITooLong             = NewHTTPError(http.StatusRequestURITooLong)             // HTTP 414 URI Too Long
+	ErrUnsupportedMediaType          = NewHTTPError(http.StatusUnsupportedMediaType)          // HTTP 415 Unsupported Media Type
+	ErrRequestedRangeNotSatisfiable  = NewHTTPError(http.StatusRequestedRangeNotSatisfiable)  // HTTP 416 Range Not Satisfiable
+	ErrExpectationFailed             = NewHTTPError(http.StatusExpectationFailed)             // HTTP 417 Expectation Failed
+	ErrTeapot                        = NewHTTPError(http.StatusTeapot)                        // HTTP 418 I'm a teapot
+	ErrMisdirectedRequest            = NewHTTPError(http.StatusMisdirectedRequest)            // HTTP 421 Misdirected Request
+	ErrUnprocessableEntity           = NewHTTPError(http.StatusUnprocessableEntity)           // HTTP 422 Unprocessable Entity
+	ErrLocked                        = NewHTTPError(http.StatusLocked)                        // HTTP 423 Locked
+	ErrFailedDependency              = NewHTTPError(http.StatusFailedDependency)              // HTTP 424 Failed Dependency
+	ErrTooEarly                      = NewHTTPError(http.StatusTooEarly)                      // HTTP 425 Too Early
+	ErrUpgradeRequired               = NewHTTPError(http.StatusUpgradeRequired)               // HTTP 426 Upgrade Required
+	ErrPreconditionRequired          = NewHTTPError(http.StatusPreconditionRequired)          // HTTP 428 Precondition Required
+	ErrTooManyRequests               = NewHTTPError(http.StatusTooManyRequests)               // HTTP 429 Too Many Requests
+	ErrRequestHeaderFieldsTooLarge   = NewHTTPError(http.StatusRequestHeaderFieldsTooLarge)   // HTTP 431 Request Header Fields Too Large
+	ErrUnavailableForLegalReasons    = NewHTTPError(http.StatusUnavailableForLegalReasons)    // HTTP 451 Unavailable For Legal Reasons
+	ErrInternalServerError           = NewHTTPError(http.StatusInternalServerError)           // HTTP 500 Internal Server Error
+	ErrNotImplemented                = NewHTTPError(http.StatusNotImplemented)                // HTTP 501 Not Implemented
+	ErrBadGateway                    = NewHTTPError(http.StatusBadGateway)                    // HTTP 502 Bad Gateway
+	ErrServiceUnavailable            = NewHTTPError(http.StatusServiceUnavailable)            // HTTP 503 Service Unavailable
+	ErrGatewayTimeout                = NewHTTPError(http.StatusGatewayTimeout)                // HTTP 504 Gateway Timeout
+	ErrHTTPVersionNotSupported       = NewHTTPError(http.StatusHTTPVersionNotSupported)       // HTTP 505 HTTP Version Not Supported
+	ErrVariantAlsoNegotiates         = NewHTTPError(http.StatusVariantAlsoNegotiates)         // HTTP 506 Variant Also Negotiates
+	ErrInsufficientStorage           = NewHTTPError(http.StatusInsufficientStorage)           // HTTP 507 Insufficient Storage
+	ErrLoopDetected                  = NewHTTPError(http.StatusLoopDetected)                  // HTTP 508 Loop Detected
+	ErrNotExtended                   = NewHTTPError(http.StatusNotExtended)                   // HTTP 510 Not Extended
+	ErrNetworkAuthenticationRequired = NewHTTPError(http.StatusNetworkAuthenticationRequired) // HTTP 511 Network Authentication Required
+
+	ErrValidatorNotRegistered = errors.New("validator not registered")
+	ErrRendererNotRegistered  = errors.New("renderer not registered")
+	ErrInvalidRedirectCode    = errors.New("invalid redirect status code")
+	ErrCookieNotFound         = errors.New("cookie not found")
+	ErrInvalidCertOrKeyType   = errors.New("invalid cert or key type, must be string or []byte")
+	ErrInvalidListenerNetwork = errors.New("invalid listener network")
 )
 
 // Error handlers
diff --git a/middleware/cors.go b/middleware/cors.go
index 25cf983..149de34 100644
--- a/middleware/cors.go
+++ b/middleware/cors.go
@@ -79,6 +79,15 @@ type (
 		// See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
 		AllowCredentials bool `yaml:"allow_credentials"`
 
+		// UnsafeWildcardOriginWithAllowCredentials UNSAFE/INSECURE: allows wildcard '*' origin to be used with AllowCredentials
+		// flag. In that case we consider any origin allowed and send it back to the client with `Access-Control-Allow-Origin` header.
+		//
+		// This is INSECURE and potentially leads to [cross-origin](https://portswigger.net/research/exploiting-cors-misconfigurations-for-bitcoins-and-bounties)
+		// attacks. See: https://github.com/labstack/echo/issues/2400 for discussion on the subject.
+		//
+		// Optional. Default value is false.
+		UnsafeWildcardOriginWithAllowCredentials bool `yaml:"unsafe_wildcard_origin_with_allow_credentials"`
+
 		// ExposeHeaders determines the value of Access-Control-Expose-Headers, which
 		// defines a list of headers that clients are allowed to access.
 		//
@@ -203,7 +212,7 @@ func CORSWithConfig(config CORSConfig) echo.MiddlewareFunc {
 			} else {
 				// Check allowed origins
 				for _, o := range config.AllowOrigins {
-					if o == "*" && config.AllowCredentials {
+					if o == "*" && config.AllowCredentials && config.UnsafeWildcardOriginWithAllowCredentials {
 						allowOrigin = origin
 						break
 					}
diff --git a/middleware/csrf.go b/middleware/csrf.go
index 8661c9f..6899700 100644
--- a/middleware/csrf.go
+++ b/middleware/csrf.go
@@ -119,9 +119,9 @@ func CSRFWithConfig(config CSRFConfig) echo.MiddlewareFunc {
 		config.CookieSecure = true
 	}
 
-	extractors, err := CreateExtractors(config.TokenLookup)
-	if err != nil {
-		panic(err)
+	extractors, cErr := CreateExtractors(config.TokenLookup)
+	if cErr != nil {
+		panic(cErr)
 	}
 
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
diff --git a/middleware/jwt.go b/middleware/jwt.go
index bd62826..bc318c9 100644
--- a/middleware/jwt.go
+++ b/middleware/jwt.go
@@ -196,9 +196,9 @@ func JWTWithConfig(config JWTConfig) echo.MiddlewareFunc {
 		config.ParseTokenFunc = config.defaultParseToken
 	}
 
-	extractors, err := createExtractors(config.TokenLookup, config.AuthScheme)
-	if err != nil {
-		panic(err)
+	extractors, cErr := createExtractors(config.TokenLookup, config.AuthScheme)
+	if cErr != nil {
+		panic(cErr)
 	}
 	if len(config.TokenLookupFuncs) > 0 {
 		extractors = append(config.TokenLookupFuncs, extractors...)
diff --git a/middleware/key_auth.go b/middleware/key_auth.go
index e8a6b08..f6fcc5d 100644
--- a/middleware/key_auth.go
+++ b/middleware/key_auth.go
@@ -108,9 +108,9 @@ func KeyAuthWithConfig(config KeyAuthConfig) echo.MiddlewareFunc {
 		panic("echo: key-auth middleware requires a validator function")
 	}
 
-	extractors, err := createExtractors(config.KeyLookup, config.AuthScheme)
-	if err != nil {
-		panic(err)
+	extractors, cErr := createExtractors(config.KeyLookup, config.AuthScheme)
+	if cErr != nil {
+		panic(cErr)
 	}
 
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
diff --git a/middleware/static.go b/middleware/static.go
index 27ccf41..24a5f59 100644
--- a/middleware/static.go
+++ b/middleware/static.go
@@ -8,7 +8,6 @@ import (
 	"net/url"
 	"os"
 	"path"
-	"path/filepath"
 	"strings"
 
 	"github.com/labstack/echo/v4"
@@ -157,9 +156,9 @@ func StaticWithConfig(config StaticConfig) echo.MiddlewareFunc {
 	}
 
 	// Index template
-	t, err := template.New("index").Parse(html)
-	if err != nil {
-		panic(fmt.Sprintf("echo: %v", err))
+	t, tErr := template.New("index").Parse(html)
+	if tErr != nil {
+		panic(fmt.Errorf("echo: %w", tErr))
 	}
 
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
@@ -176,7 +175,7 @@ func StaticWithConfig(config StaticConfig) echo.MiddlewareFunc {
 			if err != nil {
 				return
 			}
-			name := filepath.Join(config.Root, filepath.Clean("/"+p)) // "/"+ for security
+			name := path.Join(config.Root, path.Clean("/"+p)) // "/"+ for security
 
 			if config.IgnoreBase {
 				routePath := path.Base(strings.TrimRight(c.Path(), "/*"))
@@ -187,12 +186,14 @@ func StaticWithConfig(config StaticConfig) echo.MiddlewareFunc {
 				}
 			}
 
-			file, err := openFile(config.Filesystem, name)
+			file, err := config.Filesystem.Open(name)
 			if err != nil {
-				if !os.IsNotExist(err) {
+				if !isIgnorableOpenFileError(err) {
 					return err
 				}
 
+				// file with that path did not exist, so we continue down in middleware/handler chain, hoping that we end up in
+				// handler that is meant to handle this request
 				if err = next(c); err == nil {
 					return err
 				}
@@ -202,7 +203,7 @@ func StaticWithConfig(config StaticConfig) echo.MiddlewareFunc {
 					return err
 				}
 
-				file, err = openFile(config.Filesystem, filepath.Join(config.Root, config.Index))
+				file, err = config.Filesystem.Open(path.Join(config.Root, config.Index))
 				if err != nil {
 					return err
 				}
@@ -216,15 +217,13 @@ func StaticWithConfig(config StaticConfig) echo.MiddlewareFunc {
 			}
 
 			if info.IsDir() {
-				index, err := openFile(config.Filesystem, filepath.Join(name, config.Index))
+				index, err := config.Filesystem.Open(path.Join(name, config.Index))
 				if err != nil {
 					if config.Browse {
 						return listDir(t, name, file, c.Response())
 					}
 
-					if os.IsNotExist(err) {
-						return next(c)
-					}
+					return next(c)
 				}
 
 				defer index.Close()
@@ -242,11 +241,6 @@ func StaticWithConfig(config StaticConfig) echo.MiddlewareFunc {
 	}
 }
 
-func openFile(fs http.FileSystem, name string) (http.File, error) {
-	pathWithSlashes := filepath.ToSlash(name)
-	return fs.Open(pathWithSlashes)
-}
-
 func serveFile(c echo.Context, file http.File, info os.FileInfo) error {
 	http.ServeContent(c.Response(), c.Request(), info.Name(), info.ModTime(), file)
 	return nil
diff --git a/middleware/static_other.go b/middleware/static_other.go
new file mode 100644
index 0000000..0337b22
--- /dev/null
+++ b/middleware/static_other.go
@@ -0,0 +1,12 @@
+//go:build !windows
+
+package middleware
+
+import (
+	"os"
+)
+
+// We ignore these errors as there could be handler that matches request path.
+func isIgnorableOpenFileError(err error) bool {
+	return os.IsNotExist(err)
+}
diff --git a/middleware/static_windows.go b/middleware/static_windows.go
new file mode 100644
index 0000000..0ab1198
--- /dev/null
+++ b/middleware/static_windows.go
@@ -0,0 +1,23 @@
+package middleware
+
+import (
+	"os"
+)
+
+// We ignore these errors as there could be handler that matches request path.
+//
+// As of Go 1.20 filepath.Clean has different behaviour on OS related filesystems so we need to use path.Clean
+// on Windows which has some caveats. The Open methods might return different errors than earlier versions and
+// as of 1.20 path checks are more strict on the provided path and considers [UNC](https://en.wikipedia.org/wiki/Path_(computing)#UNC)
+// paths with missing host etc parts as invalid. Previously it would result you `fs.ErrNotExist`.
+//
+// For 1.20@Windows we need to treat those errors the same as `fs.ErrNotExists` so we can continue handling
+// errors in the middleware/handler chain. Otherwise we might end up with status 500 instead of finding a route
+// or return 404 not found.
+func isIgnorableOpenFileError(err error) bool {
+	if os.IsNotExist(err) {
+		return true
+	}
+	errTxt := err.Error()
+	return errTxt == "http: invalid or unsafe file path" || errTxt == "invalid path"
+}


Test patch:
diff --git a/middleware/context_timeout_test.go b/middleware/context_timeout_test.go
index 605ca8e..24c6203 100644
--- a/middleware/context_timeout_test.go
+++ b/middleware/context_timeout_test.go
@@ -148,7 +148,7 @@ func TestContextTimeoutWithDefaultErrorMessage(t *testing.T) {
 	c := e.NewContext(req, rec)
 
 	err := m(func(c echo.Context) error {
-		if err := sleepWithContext(c.Request().Context(), time.Duration(20*time.Millisecond)); err != nil {
+		if err := sleepWithContext(c.Request().Context(), time.Duration(80*time.Millisecond)); err != nil {
 			return err
 		}
 		return c.String(http.StatusOK, "Hello, World!")
@@ -176,7 +176,7 @@ func TestContextTimeoutCanHandleContextDeadlineOnNextHandler(t *testing.T) {
 		return nil
 	}
 
-	timeout := 10 * time.Millisecond
+	timeout := 50 * time.Millisecond
 	m := ContextTimeoutWithConfig(ContextTimeoutConfig{
 		Timeout:      timeout,
 		ErrorHandler: timeoutErrorHandler,
@@ -189,11 +189,10 @@ func TestContextTimeoutCanHandleContextDeadlineOnNextHandler(t *testing.T) {
 	c := e.NewContext(req, rec)
 
 	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
-		// difference over 500microseconds (0.5millisecond) response seems to be reliable
+		// NOTE: Very short periods are not reliable for tests due to Go routine scheduling and the unpredictable order
+		// for 1) request and 2) time goroutine. For most OS this works as expected, but MacOS seems most flaky.
 
-		if err := sleepWithContext(c.Request().Context(), time.Duration(20*time.Millisecond)); err != nil {
+		if err := sleepWithContext(c.Request().Context(), 100*time.Millisecond); err != nil {
 			return err
 		}
 
diff --git a/middleware/cors_test.go b/middleware/cors_test.go
index daadbab..c1bb91e 100644
--- a/middleware/cors_test.go
+++ b/middleware/cors_test.go
@@ -11,106 +11,190 @@ import (
 )
 
 func TestCORS(t *testing.T) {
-	e := echo.New()
+	var testCases = []struct {
+		name             string
+		givenMW          echo.MiddlewareFunc
+		whenMethod       string
+		whenHeaders      map[string]string
+		expectHeaders    map[string]string
+		notExpectHeaders map[string]string
+	}{
+		{
+			name:          "ok, wildcard origin",
+			whenHeaders:   map[string]string{echo.HeaderOrigin: "localhost"},
+			expectHeaders: map[string]string{echo.HeaderAccessControlAllowOrigin: "*"},
+		},
+		{
+			name:             "ok, wildcard AllowedOrigin with no Origin header in request",
+			notExpectHeaders: map[string]string{echo.HeaderAccessControlAllowOrigin: ""},
+		},
+		{
+			name: "ok, specific AllowOrigins and AllowCredentials",
+			givenMW: CORSWithConfig(CORSConfig{
+				AllowOrigins:     []string{"localhost"},
+				AllowCredentials: true,
+				MaxAge:           3600,
+			}),
+			whenHeaders: map[string]string{echo.HeaderOrigin: "localhost"},
+			expectHeaders: map[string]string{
+				echo.HeaderAccessControlAllowOrigin:      "localhost",
+				echo.HeaderAccessControlAllowCredentials: "true",
+			},
+		},
+		{
+			name: "ok, preflight request with matching origin for `AllowOrigins`",
+			givenMW: CORSWithConfig(CORSConfig{
+				AllowOrigins:     []string{"localhost"},
+				AllowCredentials: true,
+				MaxAge:           3600,
+			}),
+			whenMethod: http.MethodOptions,
+			whenHeaders: map[string]string{
+				echo.HeaderOrigin:      "localhost",
+				echo.HeaderContentType: echo.MIMEApplicationJSON,
+			},
+			expectHeaders: map[string]string{
+				echo.HeaderAccessControlAllowOrigin:      "localhost",
+				echo.HeaderAccessControlAllowMethods:     "GET,HEAD,PUT,PATCH,POST,DELETE",
+				echo.HeaderAccessControlAllowCredentials: "true",
+				echo.HeaderAccessControlMaxAge:           "3600",
+			},
+		},
+		{
+			name: "ok, preflight request with wildcard `AllowOrigins` and `AllowCredentials` true",
+			givenMW: CORSWithConfig(CORSConfig{
+				AllowOrigins:     []string{"*"},
+				AllowCredentials: true,
+				MaxAge:           3600,
+			}),
+			whenMethod: http.MethodOptions,
+			whenHeaders: map[string]string{
+				echo.HeaderOrigin:      "localhost",
+				echo.HeaderContentType: echo.MIMEApplicationJSON,
+			},
+			expectHeaders: map[string]string{
+				echo.HeaderAccessControlAllowOrigin:      "*", // Note: browsers will ignore and complain about responses having `*`
+				echo.HeaderAccessControlAllowMethods:     "GET,HEAD,PUT,PATCH,POST,DELETE",
+				echo.HeaderAccessControlAllowCredentials: "true",
+				echo.HeaderAccessControlMaxAge:           "3600",
+			},
+		},
+		{
+			name: "ok, preflight request with wildcard `AllowOrigins` and `AllowCredentials` false",
+			givenMW: CORSWithConfig(CORSConfig{
+				AllowOrigins:     []string{"*"},
+				AllowCredentials: false, // important for this testcase
+				MaxAge:           3600,
+			}),
+			whenMethod: http.MethodOptions,
+			whenHeaders: map[string]string{
+				echo.HeaderOrigin:      "localhost",
+				echo.HeaderContentType: echo.MIMEApplicationJSON,
+			},
+			expectHeaders: map[string]string{
+				echo.HeaderAccessControlAllowOrigin:  "*",
+				echo.HeaderAccessControlAllowMethods: "GET,HEAD,PUT,PATCH,POST,DELETE",
+				echo.HeaderAccessControlMaxAge:       "3600",
+			},
+			notExpectHeaders: map[string]string{
+				echo.HeaderAccessControlAllowCredentials: "",
+			},
+		},
+		{
+			name: "ok, INSECURE preflight request with wildcard `AllowOrigins` and `AllowCredentials` true",
+			givenMW: CORSWithConfig(CORSConfig{
+				AllowOrigins:                             []string{"*"},
+				AllowCredentials:                         true,
+				UnsafeWildcardOriginWithAllowCredentials: true, // important for this testcase
+				MaxAge:                                   3600,
+			}),
+			whenMethod: http.MethodOptions,
+			whenHeaders: map[string]string{
+				echo.HeaderOrigin:      "localhost",
+				echo.HeaderContentType: echo.MIMEApplicationJSON,
+			},
+			expectHeaders: map[string]string{
+				echo.HeaderAccessControlAllowOrigin:      "localhost", // This could end up as cross-origin attack
+				echo.HeaderAccessControlAllowMethods:     "GET,HEAD,PUT,PATCH,POST,DELETE",
+				echo.HeaderAccessControlAllowCredentials: "true",
+				echo.HeaderAccessControlMaxAge:           "3600",
+			},
+		},
+		{
+			name: "ok, preflight request with Access-Control-Request-Headers",
+			givenMW: CORSWithConfig(CORSConfig{
+				AllowOrigins: []string{"*"},
+			}),
+			whenMethod: http.MethodOptions,
+			whenHeaders: map[string]string{
+				echo.HeaderOrigin:                      "localhost",
+				echo.HeaderContentType:                 echo.MIMEApplicationJSON,
+				echo.HeaderAccessControlRequestHeaders: "Special-Request-Header",
+			},
+			expectHeaders: map[string]string{
+				echo.HeaderAccessControlAllowOrigin:  "*",
+				echo.HeaderAccessControlAllowHeaders: "Special-Request-Header",
+				echo.HeaderAccessControlAllowMethods: "GET,HEAD,PUT,PATCH,POST,DELETE",
+			},
+		},
+		{
+			name: "ok, preflight request with `AllowOrigins` which allow all subdomains aaa with *",
+			givenMW: CORSWithConfig(CORSConfig{
+				AllowOrigins: []string{"http://*.example.com"},
+			}),
+			whenMethod:    http.MethodOptions,
+			whenHeaders:   map[string]string{echo.HeaderOrigin: "http://aaa.example.com"},
+			expectHeaders: map[string]string{echo.HeaderAccessControlAllowOrigin: "http://aaa.example.com"},
+		},
+		{
+			name: "ok, preflight request with `AllowOrigins` which allow all subdomains bbb with *",
+			givenMW: CORSWithConfig(CORSConfig{
+				AllowOrigins: []string{"http://*.example.com"},
+			}),
+			whenMethod:    http.MethodOptions,
+			whenHeaders:   map[string]string{echo.HeaderOrigin: "http://bbb.example.com"},
+			expectHeaders: map[string]string{echo.HeaderAccessControlAllowOrigin: "http://bbb.example.com"},
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := echo.New()
+
+			mw := CORS()
+			if tc.givenMW != nil {
+				mw = tc.givenMW
+			}
+			h := mw(func(c echo.Context) error {
+				return nil
+			})
 
-	// Wildcard origin
-	req := httptest.NewRequest(http.MethodGet, "/", nil)
-	rec := httptest.NewRecorder()
-	c := e.NewContext(req, rec)
-	h := CORS()(echo.NotFoundHandler)
-	req.Header.Set(echo.HeaderOrigin, "localhost")
-	h(c)
-	assert.Equal(t, "*", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
-
-	// Wildcard AllowedOrigin with no Origin header in request
-	req = httptest.NewRequest(http.MethodGet, "/", nil)
-	rec = httptest.NewRecorder()
-	c = e.NewContext(req, rec)
-	h = CORS()(echo.NotFoundHandler)
-	h(c)
-	assert.NotContains(t, rec.Header(), echo.HeaderAccessControlAllowOrigin)
-
-	// Allow origins
-	req = httptest.NewRequest(http.MethodGet, "/", nil)
-	rec = httptest.NewRecorder()
-	c = e.NewContext(req, rec)
-	h = CORSWithConfig(CORSConfig{
-		AllowOrigins:     []string{"localhost"},
-		AllowCredentials: true,
-		MaxAge:           3600,
-	})(echo.NotFoundHandler)
-	req.Header.Set(echo.HeaderOrigin, "localhost")
-	h(c)
-	assert.Equal(t, "localhost", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
-	assert.Equal(t, "true", rec.Header().Get(echo.HeaderAccessControlAllowCredentials))
-
-	// Preflight request
-	req = httptest.NewRequest(http.MethodOptions, "/", nil)
-	rec = httptest.NewRecorder()
-	c = e.NewContext(req, rec)
-	req.Header.Set(echo.HeaderOrigin, "localhost")
-	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
-	cors := CORSWithConfig(CORSConfig{
-		AllowOrigins:     []string{"localhost"},
-		AllowCredentials: true,
-		MaxAge:           3600,
-	})
-	h = cors(echo.NotFoundHandler)
-	h(c)
-	assert.Equal(t, "localhost", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
-	assert.NotEmpty(t, rec.Header().Get(echo.HeaderAccessControlAllowMethods))
-	assert.Equal(t, "true", rec.Header().Get(echo.HeaderAccessControlAllowCredentials))
-	assert.Equal(t, "3600", rec.Header().Get(echo.HeaderAccessControlMaxAge))
-
-	// Preflight request with `AllowOrigins` *
-	req = httptest.NewRequest(http.MethodOptions, "/", nil)
-	rec = httptest.NewRecorder()
-	c = e.NewContext(req, rec)
-	req.Header.Set(echo.HeaderOrigin, "localhost")
-	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
-	cors = CORSWithConfig(CORSConfig{
-		AllowOrigins:     []string{"*"},
-		AllowCredentials: true,
-		MaxAge:           3600,
-	})
-	h = cors(echo.NotFoundHandler)
-	h(c)
-	assert.Equal(t, "localhost", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
-	assert.NotEmpty(t, rec.Header().Get(echo.HeaderAccessControlAllowMethods))
-	assert.Equal(t, "true", rec.Header().Get(echo.HeaderAccessControlAllowCredentials))
-	assert.Equal(t, "3600", rec.Header().Get(echo.HeaderAccessControlMaxAge))
-
-	// Preflight request with Access-Control-Request-Headers
-	req = httptest.NewRequest(http.MethodOptions, "/", nil)
-	rec = httptest.NewRecorder()
-	c = e.NewContext(req, rec)
-	req.Header.Set(echo.HeaderOrigin, "localhost")
-	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
-	req.Header.Set(echo.HeaderAccessControlRequestHeaders, "Special-Request-Header")
-	cors = CORSWithConfig(CORSConfig{
-		AllowOrigins: []string{"*"},
-	})
-	h = cors(echo.NotFoundHandler)
-	h(c)
-	assert.Equal(t, "*", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
-	assert.Equal(t, "Special-Request-Header", rec.Header().Get(echo.HeaderAccessControlAllowHeaders))
-	assert.NotEmpty(t, rec.Header().Get(echo.HeaderAccessControlAllowMethods))
-
-	// Preflight request with `AllowOrigins` which allow all subdomains with *
-	req = httptest.NewRequest(http.MethodOptions, "/", nil)
-	rec = httptest.NewRecorder()
-	c = e.NewContext(req, rec)
-	req.Header.Set(echo.HeaderOrigin, "http://aaa.example.com")
-	cors = CORSWithConfig(CORSConfig{
-		AllowOrigins: []string{"http://*.example.com"},
-	})
-	h = cors(echo.NotFoundHandler)
-	h(c)
-	assert.Equal(t, "http://aaa.example.com", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
-
-	req.Header.Set(echo.HeaderOrigin, "http://bbb.example.com")
-	h(c)
-	assert.Equal(t, "http://bbb.example.com", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
+			method := http.MethodGet
+			if tc.whenMethod != "" {
+				method = tc.whenMethod
+			}
+			req := httptest.NewRequest(method, "/", nil)
+			rec := httptest.NewRecorder()
+			c := e.NewContext(req, rec)
+			for k, v := range tc.whenHeaders {
+				req.Header.Set(k, v)
+			}
+
+			err := h(c)
+
+			assert.NoError(t, err)
+			header := rec.Header()
+			for k, v := range tc.expectHeaders {
+				assert.Equal(t, v, header.Get(k), "header: `%v` should be `%v`", k, v)
+			}
+			for k, v := range tc.notExpectHeaders {
+				if v == "" {
+					assert.Len(t, header.Values(k), 0, "header: `%v` should not be set", k)
+				} else {
+					assert.NotEqual(t, v, header.Get(k), "header: `%v` should not be `%v`", k, v)
+				}
+			}
+		})
+	}
 }
 
 func Test_allowOriginScheme(t *testing.T) {