Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions middleware/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,25 @@ func (config CORSConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
res := c.Response()
origin := req.Header.Get(echo.HeaderOrigin)

res.Header().Add(echo.HeaderVary, echo.HeaderOrigin)
// Check if CORS headers already exist (e.g., from a proxied upstream service)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] CORS middleware bypass via pre-existing Access-Control-Allow-Origin header

The middleware checks if the Access-Control-Allow-Origin header is already set in the response and skips all CORS processing if it is. An attacker or a misconfigured upstream proxy can inject this header, causing the middleware to skip origin validation, method checks, and credential settings, effectively bypassing CORS security.

Justification: The code at lines 196-204 introduces a new check: if res.Header().Get(echo.HeaderAccessControlAllowOrigin) != "", it skips processing and either returns a 204 for preflight or calls next(c). This allows any upstream component (or an attacker via header injection in a request that proxies through) to set the CORS header and bypass the security validation.

Validation: Lines 198-204: if res.Header().Get(echo.HeaderAccessControlAllowOrigin) != "" { ... // CORS headers already present, likely from upstream - skip processing ... }. This conditional skip occurs before any origin validation (allowOriginFunc is never called), allowing the request to proceed without CORS checks.

Suggestion: Remove the early exit based on pre-existing CORS headers. Always validate the origin and apply CORS logic regardless of existing headers, or at least verify the existing header matches the validated origin before skipping.

// to avoid duplication in proxy chains
if res.Header().Get(echo.HeaderAccessControlAllowOrigin) != "" {
// CORS headers already present, likely from upstream - skip processing
if preflight := req.Method == http.MethodOptions; preflight {
return c.NoContent(http.StatusNoContent)
}
return next(c)
}

// Add Origin to Vary header only if not already present
varyHeader := res.Header().Get(echo.HeaderVary)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Vary header deduplication uses substring match instead of token comparison

The code checks if 'Origin' is present in the Vary header using substring matching (strings.Contains). This can incorrectly match headers like 'X-Origin-Custom' or 'Original', causing the code to skip adding 'Origin' when it should be added, leading to incorrect caching behavior.

Justification: The substring check on line 208 (!strings.Contains(varyHeader, echo.HeaderOrigin)) is vulnerable to false positives. If the Vary header contains 'X-Origin-Custom', the substring 'Origin' is found, causing the code to skip adding 'Origin' even though the exact token isn't present. This violates the HTTP specification which requires exact token matching for the Vary header.

Validation: Line 208: if !strings.Contains(varyHeader, echo.HeaderOrigin) { — uses substring search instead of token parsing. This will incorrectly match 'X-Origin-Custom' as containing 'Origin', preventing the proper addition of the 'Origin' token to the Vary header.

Suggestion: Split the Vary header on ', ' and check for exact token matches. Use a helper function like containsToken(varyHeader, echo.HeaderOrigin) that properly parses the header.

if !strings.Contains(varyHeader, echo.HeaderOrigin) {
if varyHeader == "" {
res.Header().Set(echo.HeaderVary, echo.HeaderOrigin)
} else {
res.Header().Set(echo.HeaderVary, varyHeader+", "+echo.HeaderOrigin)
}
}

// 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
Expand Down Expand Up @@ -261,8 +279,20 @@ func (config CORSConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
// Preflight will end with c.NoContent(http.StatusNoContent) as we do not know if
// at the end of handler chain is actual OPTIONS route or 404/405 route which
// response code will confuse browsers
res.Header().Add(echo.HeaderVary, echo.HeaderAccessControlRequestMethod)
res.Header().Add(echo.HeaderVary, echo.HeaderAccessControlRequestHeaders)

// Add to Vary header only if not already present
varyHeader = res.Header().Get(echo.HeaderVary)
varyValues := []string{echo.HeaderAccessControlRequestMethod, echo.HeaderAccessControlRequestHeaders}
for _, varyValue := range varyValues {
if !strings.Contains(varyHeader, varyValue) {
if varyHeader == "" {
varyHeader = varyValue
} else {
varyHeader = varyHeader + ", " + varyValue
}
}
}
res.Header().Set(echo.HeaderVary, varyHeader)

if !hasCustomAllowMethods && routerAllowMethods != "" {
res.Header().Set(echo.HeaderAccessControlAllowMethods, routerAllowMethods)
Expand Down
80 changes: 80 additions & 0 deletions middleware/cors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -626,3 +626,83 @@ func Test_allowOriginFunc(t *testing.T) {
}
}
}

// TestCORSProxyChain tests that CORS headers are not duplicated when
// multiple CORS middlewares are chained in a proxy scenario
func TestCORSProxyChain(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set(echo.HeaderOrigin, "http://example.com")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

// Simulate Service B (upstream) that already set CORS headers
upstreamHandler := func(c *echo.Context) error {
c.Response().Header().Set(echo.HeaderAccessControlAllowOrigin, "http://example.com")
c.Response().Header().Set(echo.HeaderVary, "Origin")
return c.String(http.StatusOK, "test")
}

// Apply CORS middleware on Service A (proxy layer)
mw := CORS("*")
handler := mw(upstreamHandler)

err := handler(c)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)

// Verify headers are not duplicated
assert.Equal(t, "http://example.com", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))

// Check that Vary header contains "Origin" only once
varyHeader := rec.Header().Get(echo.HeaderVary)
assert.Contains(t, varyHeader, "Origin")

// Count occurrences of "Origin" in Vary header - should be exactly 1
originCount := strings.Count(varyHeader, "Origin")
assert.Equal(t, 1, originCount, "Vary header should contain 'Origin' only once, got: %s", varyHeader)

// Verify there's no duplicate Access-Control-Allow-Origin header
allowOriginHeaders := rec.Header().Values(echo.HeaderAccessControlAllowOrigin)
assert.Equal(t, 1, len(allowOriginHeaders), "Should have exactly one Access-Control-Allow-Origin header")
}

// TestCORSProxyChainPreflight tests preflight requests in proxy chains
func TestCORSProxyChainPreflight(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodOptions, "/", nil)
req.Header.Set(echo.HeaderOrigin, "http://example.com")
req.Header.Set(echo.HeaderAccessControlRequestMethod, "POST")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

// Simulate Service B (upstream) that already set CORS headers
upstreamHandler := func(c *echo.Context) error {
c.Response().Header().Set(echo.HeaderAccessControlAllowOrigin, "*")
c.Response().Header().Set(echo.HeaderVary, "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
c.Response().Header().Set(echo.HeaderAccessControlAllowMethods, "GET,POST,PUT")
return c.NoContent(http.StatusNoContent)
}

// Apply CORS middleware on Service A (proxy layer)
mw := CORS("*")
handler := mw(upstreamHandler)

err := handler(c)
assert.NoError(t, err)
assert.Equal(t, http.StatusNoContent, rec.Code)

// Verify headers are not duplicated
assert.Equal(t, "*", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))

// Check that Vary header contains each value only once
varyHeader := rec.Header().Get(echo.HeaderVary)
assert.Contains(t, varyHeader, "Origin")

originCount := strings.Count(varyHeader, "Origin")
assert.Equal(t, 1, originCount, "Vary header should contain 'Origin' only once, got: %s", varyHeader)

// Verify there's no duplicate Access-Control-Allow-Origin header
allowOriginHeaders := rec.Header().Values(echo.HeaderAccessControlAllowOrigin)
assert.Equal(t, 1, len(allowOriginHeaders), "Should have exactly one Access-Control-Allow-Origin header")
}