Skip to content

Commit 05489dc

Browse files
authored
fix(middleware): classify CORS preflight per Fetch standard (#3060)
Only OPTIONS requests that include both Origin and Access-Control-Request-Method are treated as CORS preflights. Previously every OPTIONS request was short-circuited, so non-CORS OPTIONS handlers (and OPTIONS without ACR-Method) never ran (#2534).
1 parent a0a3b53 commit 05489dc

2 files changed

Lines changed: 118 additions & 18 deletions

File tree

middleware/cors.go

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -201,18 +201,16 @@ func (config CORSConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
201201

202202
res.Header().Add(echo.HeaderVary, echo.HeaderOrigin)
203203

204-
// Preflight request is an OPTIONS request, using three HTTP request headers: Access-Control-Request-Method,
205-
// Access-Control-Request-Headers, and the Origin header. See: https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request
206-
// For simplicity we just consider method type and later `Origin` header.
207-
preflight := req.Method == http.MethodOptions
208-
209-
// Although router adds special handler in case of OPTIONS method we avoid calling next for OPTIONS in this middleware
210-
// as CORS requests do not have cookies / authentication headers by default, so we could get stuck in auth
211-
// middlewares by calling next(c).
212-
// But we still want to send `Allow` header as response in case of Non-CORS OPTIONS request as router default
213-
// handler does.
204+
// A CORS preflight request is an OPTIONS request that includes both
205+
// Origin and Access-Control-Request-Method (Fetch standard / RFC 9110).
206+
// Treating every OPTIONS request as preflight incorrectly short-circuits
207+
// non-CORS OPTIONS handlers (see #2534).
208+
preflight := isCORSPreflight(req)
209+
210+
// Surface router Allow for OPTIONS (preflight and non-preflight). For
211+
// non-preflight OPTIONS the request continues to next(c).
214212
routerAllowMethods := ""
215-
if preflight {
213+
if req.Method == http.MethodOptions {
216214
tmpAllowMethods, ok := c.Get(echo.ContextKeyHeaderAllow).(string)
217215
if ok && tmpAllowMethods != "" {
218216
routerAllowMethods = tmpAllowMethods
@@ -222,10 +220,7 @@ func (config CORSConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
222220

223221
// No Origin provided. This is (probably) not request from actual browser - proceed executing middleware chain
224222
if origin == "" {
225-
if preflight { // req.Method=OPTIONS
226-
return c.NoContent(http.StatusNoContent)
227-
}
228-
return next(c) // let non-browser calls through
223+
return next(c) // let non-browser / non-CORS OPTIONS through
229224
}
230225

231226
allowedOrigin, allowed, err := allowOriginFunc(c, origin)
@@ -292,6 +287,17 @@ func (config CORSConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
292287
}, nil
293288
}
294289

290+
// isCORSPreflight reports whether r is a CORS preflight request.
291+
//
292+
// Per the Fetch standard, a preflight is an OPTIONS request with both an Origin
293+
// header and an Access-Control-Request-Method header. OPTIONS alone (with or
294+
// without Origin) is not sufficient.
295+
func isCORSPreflight(r *http.Request) bool {
296+
return r.Method == http.MethodOptions &&
297+
r.Header.Get(echo.HeaderOrigin) != "" &&
298+
r.Header.Get(echo.HeaderAccessControlRequestMethod) != ""
299+
}
300+
295301
func (config CORSConfig) starAllowOriginFunc(c *echo.Context, origin string) (string, bool, error) {
296302
return "*", true, nil
297303
}

middleware/cors_test.go

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func TestCORS(t *testing.T) {
1919
e := echo.New()
2020
req := httptest.NewRequest(http.MethodOptions, "/", nil) // Preflight request
2121
req.Header.Set(echo.HeaderOrigin, "http://example.com")
22+
req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodGet)
2223
rec := httptest.NewRecorder()
2324
c := e.NewContext(req, rec)
2425

@@ -33,6 +34,79 @@ func TestCORS(t *testing.T) {
3334
assert.Equal(t, "*", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
3435
}
3536

37+
// TestCORS_NonPreflightOPTIONSPassThrough locks in #2534: only true CORS
38+
// preflights short-circuit the middleware. OPTIONS without the preflight
39+
// headers must reach the next handler.
40+
func TestCORS_NonPreflightOPTIONSPassThrough(t *testing.T) {
41+
e := echo.New()
42+
mw := CORS("*")
43+
44+
t.Run("OPTIONS without Origin reaches next", func(t *testing.T) {
45+
req := httptest.NewRequest(http.MethodOptions, "/hello", nil)
46+
rec := httptest.NewRecorder()
47+
c := e.NewContext(req, rec)
48+
called := false
49+
handler := mw(func(c *echo.Context) error {
50+
called = true
51+
return c.NoContent(http.StatusNoContent)
52+
})
53+
assert.NoError(t, handler(c))
54+
assert.True(t, called, "expected next to run for non-preflight OPTIONS")
55+
assert.Empty(t, rec.Header().Get(echo.HeaderAccessControlAllowMethods))
56+
})
57+
58+
t.Run("OPTIONS with Origin but without Access-Control-Request-Method reaches next", func(t *testing.T) {
59+
req := httptest.NewRequest(http.MethodOptions, "/hello", nil)
60+
req.Header.Set(echo.HeaderOrigin, "https://example.com")
61+
rec := httptest.NewRecorder()
62+
c := e.NewContext(req, rec)
63+
called := false
64+
handler := mw(func(c *echo.Context) error {
65+
called = true
66+
return c.NoContent(http.StatusNoContent)
67+
})
68+
assert.NoError(t, handler(c))
69+
assert.True(t, called, "expected next to run when ACR-Method is missing")
70+
// Origin was allowed; simple CORS headers may be set, but not preflight Allow-Methods.
71+
assert.Equal(t, "*", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
72+
assert.Empty(t, rec.Header().Get(echo.HeaderAccessControlAllowMethods))
73+
})
74+
75+
t.Run("true preflight still short-circuits next", func(t *testing.T) {
76+
req := httptest.NewRequest(http.MethodOptions, "/hello", nil)
77+
req.Header.Set(echo.HeaderOrigin, "https://example.com")
78+
req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodPut)
79+
rec := httptest.NewRecorder()
80+
c := e.NewContext(req, rec)
81+
called := false
82+
handler := mw(func(c *echo.Context) error {
83+
called = true
84+
return c.String(http.StatusOK, "should not run")
85+
})
86+
assert.NoError(t, handler(c))
87+
assert.False(t, called, "preflight must not call next")
88+
assert.Equal(t, http.StatusNoContent, rec.Code)
89+
assert.Equal(t, "*", rec.Header().Get(echo.HeaderAccessControlAllowOrigin))
90+
assert.NotEmpty(t, rec.Header().Get(echo.HeaderAccessControlAllowMethods))
91+
})
92+
}
93+
94+
func TestIsCORSPreflight(t *testing.T) {
95+
req := httptest.NewRequest(http.MethodOptions, "/", nil)
96+
assert.False(t, isCORSPreflight(req))
97+
98+
req.Header.Set(echo.HeaderOrigin, "https://example.com")
99+
assert.False(t, isCORSPreflight(req))
100+
101+
req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodPut)
102+
assert.True(t, isCORSPreflight(req))
103+
104+
req = httptest.NewRequest(http.MethodGet, "/", nil)
105+
req.Header.Set(echo.HeaderOrigin, "https://example.com")
106+
req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodPut)
107+
assert.False(t, isCORSPreflight(req))
108+
}
109+
36110
func TestCORSConfig(t *testing.T) {
37111
var testCases = []struct {
38112
name string
@@ -275,6 +349,11 @@ func TestCORSConfig(t *testing.T) {
275349
for k, v := range tc.whenHeaders {
276350
req.Header.Set(k, v)
277351
}
352+
// Intentional preflight cases: OPTIONS + Origin need ACR-Method per Fetch.
353+
if method == http.MethodOptions && req.Header.Get(echo.HeaderOrigin) != "" &&
354+
req.Header.Get(echo.HeaderAccessControlRequestMethod) == "" {
355+
req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodGet)
356+
}
278357

279358
err = h(c)
280359

@@ -413,6 +492,10 @@ func TestCORSWithConfig_AllowMethods(t *testing.T) {
413492
c := e.NewContext(req, rec)
414493

415494
req.Header.Set(echo.HeaderOrigin, tc.whenOrigin)
495+
if tc.whenOrigin != "" {
496+
// Real preflight requires Access-Control-Request-Method (#2534).
497+
req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodGet)
498+
}
416499
if tc.whenAllowContextKey != "" {
417500
c.Set(echo.ContextKeyHeaderAllow, tc.whenAllowContextKey)
418501
}
@@ -479,12 +562,13 @@ func TestCorsHeaders(t *testing.T) {
479562
expectStatus: http.StatusOK,
480563
},
481564
{
482-
name: "preflight, allow any origin, missing origin header = no CORS logic done",
565+
// OPTIONS without Origin is not a CORS preflight; request continues to the router.
566+
name: "OPTIONS no origin, allow any origin = no CORS preflight short-circuit",
483567
originDomain: "", // Request does not have Origin header
484568
allowedOrigin: "*",
485569
method: http.MethodOptions,
486570
expected: false,
487-
expectStatus: http.StatusNoContent,
571+
expectStatus: http.StatusNoContent, // router default OPTIONS/Allow path
488572
expectAllowHeader: "OPTIONS, GET, POST",
489573
},
490574
{
@@ -497,7 +581,7 @@ func TestCorsHeaders(t *testing.T) {
497581
expectAllowHeader: "OPTIONS, GET, POST",
498582
},
499583
{
500-
name: "preflight, allow any origin, missing origin header = no CORS logic done",
584+
name: "OPTIONS no origin, allow specific origin = no CORS preflight short-circuit",
501585
originDomain: "", // Request does not have Origin header
502586
allowedOrigin: "http://example.com",
503587
method: http.MethodOptions,
@@ -548,6 +632,15 @@ func TestCorsHeaders(t *testing.T) {
548632
if tc.originDomain != "" {
549633
req.Header.Set(echo.HeaderOrigin, tc.originDomain)
550634
}
635+
// True CORS preflight requires Access-Control-Request-Method (#2534).
636+
if tc.method == http.MethodOptions && tc.originDomain != "" && tc.expected {
637+
req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodGet)
638+
}
639+
// Disallowed origin + OPTIONS still needs ACR-Method to be classified as preflight
640+
// (middleware then omits ACAO and returns 204).
641+
if tc.method == http.MethodOptions && tc.originDomain != "" && !tc.expected {
642+
req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodGet)
643+
}
551644

552645
// we run through whole Echo handler chain to see how CORS works with Router OPTIONS handler
553646
e.ServeHTTP(rec, req)
@@ -606,6 +699,7 @@ func Test_allowOriginFunc(t *testing.T) {
606699
rec := httptest.NewRecorder()
607700
c := e.NewContext(req, rec)
608701
req.Header.Set(echo.HeaderOrigin, origin)
702+
req.Header.Set(echo.HeaderAccessControlRequestMethod, http.MethodGet)
609703
cors, err := CORSConfig{UnsafeAllowOriginFunc: allowOriginFunc}.ToMiddleware()
610704
assert.NoError(t, err)
611705

0 commit comments

Comments
 (0)