From 91c0730d83d269bab4288d52b4b93ca665b2dc04 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Tue, 18 Aug 2026 22:22:20 +0100 Subject: [PATCH 1/3] fix!: reject wildcard CORS origin when credentials are enabled Previously, CORS(CorsAllowCredentials(true)) kept the default "*" origin list and reflected any request Origin back together with Access-Control-Allow-Credentials: true, which lets any site read cookie-authenticated responses. The config doc already claimed the combination was not allowed. CORS now panics at construction when "*" is among the allowed origins and credentials are on. This is a breaking change for callers relying on the old behaviour, who have to enumerate their origins instead. --- cors.go | 21 +++++++++++++++------ cors_test.go | 27 ++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/cors.go b/cors.go index f8d7edd..755486a 100644 --- a/cors.go +++ b/cors.go @@ -2,6 +2,7 @@ package rest import ( "net/http" + "slices" "strconv" "strings" ) @@ -10,7 +11,7 @@ import ( // Use CorsOpt functions to customize. type CORSConfig struct { // AllowedOrigins is a list of origins that may access the resource. - // use "*" to allow all origins (not recommended with credentials). + // use "*" to allow all origins, rejected by CORS when combined with credentials. // default: ["*"] AllowedOrigins []string // AllowedMethods is a list of methods the client is allowed to use. @@ -23,7 +24,7 @@ type CORSConfig struct { // default: empty ExposedHeaders []string // AllowCredentials indicates whether the request can include credentials. - // when true, AllowedOrigins cannot be "*" (browser security restriction). + // when true, AllowedOrigins cannot contain "*" and CORS panics if it does. // default: false AllowCredentials bool // MaxAge indicates how long (in seconds) the results of a preflight can be cached. @@ -47,7 +48,7 @@ func defaultCORSConfig() CORSConfig { } // CorsAllowedOrigins sets the list of allowed origins. -// Use "*" to allow all origins (not recommended with credentials). +// Use "*" to allow all origins, which CORS rejects when credentials are enabled. func CorsAllowedOrigins(origins ...string) CorsOpt { return func(c *CORSConfig) { c.AllowedOrigins = origins @@ -76,7 +77,7 @@ func CorsExposedHeaders(headers ...string) CorsOpt { } // CorsAllowCredentials enables or disables credentials. -// When true, AllowedOrigins cannot be "*". +// When true, AllowedOrigins cannot contain "*" and CORS panics if it does. func CorsAllowCredentials(allow bool) CorsOpt { return func(c *CORSConfig) { c.AllowCredentials = allow @@ -93,12 +94,20 @@ func CorsMaxAge(seconds int) CorsOpt { // CORS is middleware that handles Cross-Origin Resource Sharing. // It handles preflight OPTIONS requests and sets appropriate headers. // By default allows all origins with common methods and headers. +// +// Panics if credentials are enabled while "*" is among the allowed origins, including the default +// origin list. Such a configuration reflects any origin back with Access-Control-Allow-Credentials, +// which lets any site read authenticated responses. Enumerate the origins instead. func CORS(opts ...CorsOpt) func(http.Handler) http.Handler { cfg := defaultCORSConfig() for _, opt := range opts { opt(&cfg) } + if cfg.AllowCredentials && slices.Contains(cfg.AllowedOrigins, "*") { + panic(`rest: CORS with credentials can't allow "*" as an origin, list the allowed origins explicitly`) + } + // pre-compute joined strings for performance methodsStr := strings.Join(cfg.AllowedMethods, ", ") headersStr := strings.Join(cfg.AllowedHeaders, ", ") @@ -142,8 +151,8 @@ func CORS(opts ...CorsOpt) func(http.Handler) http.Handler { // set Vary header for caching w.Header().Add("Vary", "Origin") - // set allowed origin - if allowAll && !cfg.AllowCredentials { + // set allowed origin, allowAll rules out credentials as the constructor panics on that combination + if allowAll { w.Header().Set("Access-Control-Allow-Origin", "*") } else { // reflect the specific origin (required for credentials) diff --git a/cors_test.go b/cors_test.go index a0bd0f3..3259985 100644 --- a/cors_test.go +++ b/cors_test.go @@ -142,18 +142,35 @@ func TestCORS_Credentials(t *testing.T) { assert.Equal(t, "true", resp.Header.Get("Access-Control-Allow-Credentials")) }) - t.Run("wildcard with credentials reflects origin", func(t *testing.T) { + t.Run("credentials with wildcard origins rejected", func(t *testing.T) { + tbl := []struct { + name string + opts []CorsOpt + }{ + {"default origins", []CorsOpt{CorsAllowCredentials(true)}}, + {"explicit wildcard", []CorsOpt{CorsAllowedOrigins("*"), CorsAllowCredentials(true)}}, + {"wildcard among others", []CorsOpt{CorsAllowedOrigins("https://app.example.com", "*"), CorsAllowCredentials(true)}}, + } + for _, tt := range tbl { + t.Run(tt.name, func(t *testing.T) { + assert.PanicsWithValue(t, + `rest: CORS with credentials can't allow "*" as an origin, list the allowed origins explicitly`, + func() { CORS(tt.opts...) }) + }) + } + }) + + t.Run("wildcard without credentials allowed", func(t *testing.T) { req := httptest.NewRequest("GET", "/test", http.NoBody) req.Header.Set("Origin", "https://any.example.com") w := httptest.NewRecorder() - CORS(CorsAllowCredentials(true))(handler).ServeHTTP(w, req) + CORS()(handler).ServeHTTP(w, req) resp := w.Result() defer resp.Body.Close() - // with credentials, must reflect origin, not "*" - assert.Equal(t, "https://any.example.com", resp.Header.Get("Access-Control-Allow-Origin")) - assert.Equal(t, "true", resp.Header.Get("Access-Control-Allow-Credentials")) + assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) + assert.Empty(t, resp.Header.Get("Access-Control-Allow-Credentials")) }) } From 5fc3d2257c4fbd1a06315efb0495641e963dc792 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Tue, 18 Aug 2026 22:52:07 +0100 Subject: [PATCH 2/3] docs: document the CORS credentials restriction --- README.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 099e541..ad70f75 100644 --- a/README.md +++ b/README.md @@ -242,18 +242,29 @@ router.Use(rest.CORS( Features: - Automatic preflight (OPTIONS) handling - Origin validation with case-insensitive matching -- Credentials support (reflects origin instead of `*`) +- Credentials support (reflects the request origin instead of `*`) - Configurable cache duration for preflight results - Cache-correct `Vary` headers (adds `Access-Control-Request-Method` and `Access-Control-Request-Headers` on preflight) Available options: -- `CorsAllowedOrigins(origins...)` - allowed origins (default: `*`) +- `CorsAllowedOrigins(origins...)` - allowed origins (default: `*`), can't include `*` with credentials enabled - `CorsAllowedMethods(methods...)` - allowed HTTP methods (default: GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD) - `CorsAllowedHeaders(headers...)` - allowed request headers (default: Accept, Content-Type, Authorization, X-Requested-With) - `CorsExposedHeaders(headers...)` - headers exposed to client - `CorsAllowCredentials(bool)` - enable credentials (cookies, auth headers) - `CorsMaxAge(seconds)` - preflight cache duration +`CORS` panics if credentials are enabled while `*` is among the allowed origins, the default list included. +That combination reflects any origin back together with `Access-Control-Allow-Credentials: true`, which lets +any site read authenticated responses, so `rest.CORS(rest.CorsAllowCredentials(true))` has to name its origins: + +```go +router.Use(rest.CORS( + rest.CorsAllowedOrigins("https://app.example.com"), + rest.CorsAllowCredentials(true), +)) +``` + ### Secure middleware Adds security headers to responses. By default sets: `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `X-XSS-Protection`, and `Strict-Transport-Security` (for HTTPS only). From 98408c60bf62caaafd6be0ba0f026d434a5484d5 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Wed, 19 Aug 2026 00:26:27 +0100 Subject: [PATCH 3/3] feat: add CorsUnsafeAnyOriginWithCredentials opt-in Rejecting "*" with credentials stays the default, but it no longer removes the capability. A service that has to accept credentialed requests from arbitrary third-party origins, an embeddable widget being the obvious case, can ask for origin reflection by name, and the name and doc comment say what it costs. --- README.md | 16 +++++++++++++++- cors.go | 27 ++++++++++++++++++++++----- cors_test.go | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ad70f75..720acba 100644 --- a/README.md +++ b/README.md @@ -252,11 +252,13 @@ Available options: - `CorsAllowedHeaders(headers...)` - allowed request headers (default: Accept, Content-Type, Authorization, X-Requested-With) - `CorsExposedHeaders(headers...)` - headers exposed to client - `CorsAllowCredentials(bool)` - enable credentials (cookies, auth headers) +- `CorsUnsafeAnyOriginWithCredentials(bool)` - allow `*` together with credentials, see below - `CorsMaxAge(seconds)` - preflight cache duration `CORS` panics if credentials are enabled while `*` is among the allowed origins, the default list included. That combination reflects any origin back together with `Access-Control-Allow-Credentials: true`, which lets -any site read authenticated responses, so `rest.CORS(rest.CorsAllowCredentials(true))` has to name its origins: +any site a signed-in user visits read authenticated responses, so it should not be reached by accident. +Name the origins instead: ```go router.Use(rest.CORS( @@ -265,6 +267,18 @@ router.Use(rest.CORS( )) ``` +A service that genuinely has to accept credentialed requests from arbitrary third-party origins, such as an +embeddable widget, can opt back in explicitly. Do this only when state-changing requests are protected by +something other than the origin: + +```go +router.Use(rest.CORS( + rest.CorsAllowedOrigins("*"), + rest.CorsAllowCredentials(true), + rest.CorsUnsafeAnyOriginWithCredentials(true), +)) +``` + ### Secure middleware Adds security headers to responses. By default sets: `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `X-XSS-Protection`, and `Strict-Transport-Security` (for HTTPS only). diff --git a/cors.go b/cors.go index 755486a..d7094d4 100644 --- a/cors.go +++ b/cors.go @@ -24,9 +24,14 @@ type CORSConfig struct { // default: empty ExposedHeaders []string // AllowCredentials indicates whether the request can include credentials. - // when true, AllowedOrigins cannot contain "*" and CORS panics if it does. + // when true, AllowedOrigins cannot contain "*" unless UnsafeAnyOriginWithCredentials is set, + // and CORS panics otherwise. // default: false AllowCredentials bool + // UnsafeAnyOriginWithCredentials permits "*" together with credentials, making the middleware + // reflect whatever Origin the request carries alongside Access-Control-Allow-Credentials. + // default: false + UnsafeAnyOriginWithCredentials bool // MaxAge indicates how long (in seconds) the results of a preflight can be cached. // default: 0 (no caching) MaxAge int @@ -84,6 +89,17 @@ func CorsAllowCredentials(allow bool) CorsOpt { } } +// CorsUnsafeAnyOriginWithCredentials permits "*" among the allowed origins together with credentials. +// The middleware then reflects whatever Origin the request carries and sends +// Access-Control-Allow-Credentials: true with it, so any site a signed-in user visits can read +// authenticated responses. Only use it for a service meant to be embedded on arbitrary third-party +// origins, and make sure state-changing requests are protected by something other than the origin. +func CorsUnsafeAnyOriginWithCredentials(allow bool) CorsOpt { + return func(c *CORSConfig) { + c.UnsafeAnyOriginWithCredentials = allow + } +} + // CorsMaxAge sets how long (in seconds) preflight results can be cached. func CorsMaxAge(seconds int) CorsOpt { return func(c *CORSConfig) { @@ -104,8 +120,9 @@ func CORS(opts ...CorsOpt) func(http.Handler) http.Handler { opt(&cfg) } - if cfg.AllowCredentials && slices.Contains(cfg.AllowedOrigins, "*") { - panic(`rest: CORS with credentials can't allow "*" as an origin, list the allowed origins explicitly`) + if cfg.AllowCredentials && !cfg.UnsafeAnyOriginWithCredentials && slices.Contains(cfg.AllowedOrigins, "*") { + panic(`rest: CORS with credentials can't allow "*" as an origin, list the allowed origins explicitly ` + + `or opt in with CorsUnsafeAnyOriginWithCredentials`) } // pre-compute joined strings for performance @@ -151,8 +168,8 @@ func CORS(opts ...CorsOpt) func(http.Handler) http.Handler { // set Vary header for caching w.Header().Add("Vary", "Origin") - // set allowed origin, allowAll rules out credentials as the constructor panics on that combination - if allowAll { + // set allowed origin + if allowAll && !cfg.AllowCredentials { w.Header().Set("Access-Control-Allow-Origin", "*") } else { // reflect the specific origin (required for credentials) diff --git a/cors_test.go b/cors_test.go index 3259985..5ef1fac 100644 --- a/cors_test.go +++ b/cors_test.go @@ -154,7 +154,8 @@ func TestCORS_Credentials(t *testing.T) { for _, tt := range tbl { t.Run(tt.name, func(t *testing.T) { assert.PanicsWithValue(t, - `rest: CORS with credentials can't allow "*" as an origin, list the allowed origins explicitly`, + `rest: CORS with credentials can't allow "*" as an origin, list the allowed origins explicitly `+ + `or opt in with CorsUnsafeAnyOriginWithCredentials`, func() { CORS(tt.opts...) }) }) } @@ -363,3 +364,47 @@ func TestCORS_Integration(t *testing.T) { assert.Contains(t, resp.Header.Get("Access-Control-Expose-Headers"), "X-Request-Id") }) } + +func TestCORS_UnsafeAnyOriginWithCredentials(t *testing.T) { + handler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) + + t.Run("opting in keeps the wildcard working", func(t *testing.T) { + tbl := []struct { + name string + opts []CorsOpt + }{ + {"default origins", []CorsOpt{ + CorsAllowCredentials(true), CorsUnsafeAnyOriginWithCredentials(true)}}, + {"explicit wildcard", []CorsOpt{ + CorsAllowedOrigins("*"), CorsAllowCredentials(true), CorsUnsafeAnyOriginWithCredentials(true)}}, + } + for _, tt := range tbl { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/test", http.NoBody) + req.Header.Set("Origin", "https://any.example.com") + w := httptest.NewRecorder() + + require.NotPanics(t, func() { CORS(tt.opts...)(handler).ServeHTTP(w, req) }) + resp := w.Result() + defer resp.Body.Close() + + // the origin is reflected, not "*", which is what credentials require + assert.Equal(t, "https://any.example.com", resp.Header.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "true", resp.Header.Get("Access-Control-Allow-Credentials")) + }) + } + }) + + t.Run("opting in without credentials changes nothing", func(t *testing.T) { + req := httptest.NewRequest("GET", "/test", http.NoBody) + req.Header.Set("Origin", "https://any.example.com") + w := httptest.NewRecorder() + + CORS(CorsUnsafeAnyOriginWithCredentials(true))(handler).ServeHTTP(w, req) + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) + assert.Empty(t, resp.Header.Get("Access-Control-Allow-Credentials")) + }) +}