Repository: labstack/echo
Pull request #2518 (2023-09-12 19:23:30)

Allow CORS middleware to send `Access-Control-Max-Age: 0` 
Allow CORS middleware to send `Access-Control-Max-Age: 0` value (when config.MaxAge is negative number) to instruct browsers not to cache that response.   This is backwards compatible change.

implements #2471

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/labstack/echo/pull/2518?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) Report
Patch coverage: **`100.00%`** and project coverage change: **`+0.06%`** :tada:
> Comparison is base [(`77d5ae6`)](https://app.codecov.io/gh/labstack/echo/commit/77d5ae6a9173d89c49e008607d08df7ba41336f0?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) 92.82% compared to head [(`f8f875a`)](https://app.codecov.io/gh/labstack/echo/pull/2518?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) 92.88%.

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


```diff
@@            Coverage Diff             @@
##           master    #2518      +/-   ##
==========================================
+ Coverage   92.82%   92.88%   +0.06%     
==========================================
  Files          39       39              
  Lines        4652     4655       +3     
==========================================
+ Hits         4318     4324       +6     
+ Misses        242      240       -2     
+ Partials       92       91       -1     
```


| [Files Changed](https://app.codecov.io/gh/labstack/echo/pull/2518?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack) | Coverage Δ | |
|---|---|---|
| [middleware/cors.go](https://app.codecov.io/gh/labstack/echo/pull/2518?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack#diff-bWlkZGxld2FyZS9jb3JzLmdv) | `95.34% <100.00%> (+2.49%)` | :arrow_up: |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/labstack/echo/pull/2518?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=labstack).   
:loudspeaker: Have feedback on the report? [Share it here](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 or @vishr please review

Patch:
diff --git a/middleware/context_timeout.go b/middleware/context_timeout.go
index be260e1..1937693 100644
--- a/middleware/context_timeout.go
+++ b/middleware/context_timeout.go
@@ -13,7 +13,7 @@ type ContextTimeoutConfig struct {
 	// Skipper defines a function to skip middleware.
 	Skipper Skipper
 
-	// ErrorHandler is a function when error aries in middeware execution.
+	// ErrorHandler is a function when error aries in middleware execution.
 	ErrorHandler func(err error, c echo.Context) error
 
 	// Timeout configures a timeout for the middleware, defaults to 0 for no timeout
diff --git a/middleware/cors.go b/middleware/cors.go
index 6ddb540..1050435 100644
--- a/middleware/cors.go
+++ b/middleware/cors.go
@@ -99,8 +99,9 @@ type (
 		// 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.
+		// The header is set only if MaxAge != 0, negative value sends "0" which instructs browsers not to cache that response.
 		//
-		// Optional. Default value 0.  The header is set only if MaxAge > 0.
+		// Optional. Default value 0 - meaning header is not sent.
 		//
 		// See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
 		MaxAge int `yaml:"max_age"`
@@ -159,7 +160,11 @@ func CORSWithConfig(config CORSConfig) echo.MiddlewareFunc {
 	allowMethods := strings.Join(config.AllowMethods, ",")
 	allowHeaders := strings.Join(config.AllowHeaders, ",")
 	exposeHeaders := strings.Join(config.ExposeHeaders, ",")
-	maxAge := strconv.Itoa(config.MaxAge)
+
+	maxAge := "0"
+	if config.MaxAge > 0 {
+		maxAge = strconv.Itoa(config.MaxAge)
+	}
 
 	return func(next echo.HandlerFunc) echo.HandlerFunc {
 		return func(c echo.Context) error {
@@ -282,7 +287,7 @@ func CORSWithConfig(config CORSConfig) echo.MiddlewareFunc {
 					res.Header().Set(echo.HeaderAccessControlAllowHeaders, h)
 				}
 			}
-			if config.MaxAge > 0 {
+			if config.MaxAge != 0 {
 				res.Header().Set(echo.HeaderAccessControlMaxAge, maxAge)
 			}
 			return c.NoContent(http.StatusNoContent)


Test patch:
diff --git a/middleware/cors_test.go b/middleware/cors_test.go
index c1bb91e..797600c 100644
--- a/middleware/cors_test.go
+++ b/middleware/cors_test.go
@@ -60,6 +60,59 @@ func TestCORS(t *testing.T) {
 				echo.HeaderAccessControlMaxAge:           "3600",
 			},
 		},
+		{
+			name: "ok, preflight request when `Access-Control-Max-Age` is set",
+			givenMW: CORSWithConfig(CORSConfig{
+				AllowOrigins:     []string{"localhost"},
+				AllowCredentials: true,
+				MaxAge:           1,
+			}),
+			whenMethod: http.MethodOptions,
+			whenHeaders: map[string]string{
+				echo.HeaderOrigin:      "localhost",
+				echo.HeaderContentType: echo.MIMEApplicationJSON,
+			},
+			expectHeaders: map[string]string{
+				echo.HeaderAccessControlMaxAge: "1",
+			},
+		},
+		{
+			name: "ok, preflight request when `Access-Control-Max-Age` is set to 0 - not to cache response",
+			givenMW: CORSWithConfig(CORSConfig{
+				AllowOrigins:     []string{"localhost"},
+				AllowCredentials: true,
+				MaxAge:           -1, // forces `Access-Control-Max-Age: 0`
+			}),
+			whenMethod: http.MethodOptions,
+			whenHeaders: map[string]string{
+				echo.HeaderOrigin:      "localhost",
+				echo.HeaderContentType: echo.MIMEApplicationJSON,
+			},
+			expectHeaders: map[string]string{
+				echo.HeaderAccessControlMaxAge: "0",
+			},
+		},
+		{
+			name: "ok, CORS check are skipped",
+			givenMW: CORSWithConfig(CORSConfig{
+				AllowOrigins:     []string{"localhost"},
+				AllowCredentials: true,
+				Skipper: func(c echo.Context) bool {
+					return true
+				},
+			}),
+			whenMethod: http.MethodOptions,
+			whenHeaders: map[string]string{
+				echo.HeaderOrigin:      "localhost",
+				echo.HeaderContentType: echo.MIMEApplicationJSON,
+			},
+			notExpectHeaders: 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{
diff --git a/middleware/proxy_test.go b/middleware/proxy_test.go
index 1b5ba6c..415d68e 100644
--- a/middleware/proxy_test.go
+++ b/middleware/proxy_test.go
@@ -188,7 +188,7 @@ func TestProxyRealIPHeader(t *testing.T) {
 	tests := []*struct {
 		hasRealIPheader bool
 		hasIPExtractor  bool
-		extectedXRealIP string
+		expectedXRealIP string
 	}{
 		{false, false, remoteAddrIP},
 		{false, true, extractedRealIP},
@@ -210,7 +210,7 @@ func TestProxyRealIPHeader(t *testing.T) {
 			e.IPExtractor = nil
 		}
 		e.ServeHTTP(rec, req)
-		assert.Equal(t, tt.extectedXRealIP, req.Header.Get(echo.HeaderXRealIP), "hasRealIPheader: %t / hasIPExtractor: %t", tt.hasRealIPheader, tt.hasIPExtractor)
+		assert.Equal(t, tt.expectedXRealIP, req.Header.Get(echo.HeaderXRealIP), "hasRealIPheader: %t / hasIPExtractor: %t", tt.hasRealIPheader, tt.hasIPExtractor)
 	}
 }