From 07d92db52394c85eb262d68dccff847b53a4e0df Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Sat, 29 Aug 2026 16:34:51 +0800 Subject: [PATCH 1/3] fix: skip bucket CORS lookup without an origin Bypass per-bucket metadata work for non-CORS traffic, including admin and Console routes. Keep operational metadata errors fail-closed and pin the existing global fallback for missing buckets. Signed-off-by: Feng Ruohang --- cmd/api-router.go | 4 ++ cmd/bucket-cors-middleware_test.go | 75 +++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/cmd/api-router.go b/cmd/api-router.go index 8de86e9bb..b3cc17af8 100644 --- a/cmd/api-router.go +++ b/cmd/api-router.go @@ -785,6 +785,10 @@ func corsHandler(handler http.Handler) http.Handler { } globalCors := cors.New(opts).Handler(handler) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Origin") == "" { + handler.ServeHTTP(w, r) + return + } if bucket, _ := request2BucketObjectName(r); bucket != "" && globalBucketMetadataSys != nil { cfg, _, err := globalBucketMetadataSys.GetCorsConfig(bucket) if err == nil && cfg != nil { diff --git a/cmd/bucket-cors-middleware_test.go b/cmd/bucket-cors-middleware_test.go index be6d1ddda..8c1732a9a 100644 --- a/cmd/bucket-cors-middleware_test.go +++ b/cmd/bucket-cors-middleware_test.go @@ -18,15 +18,27 @@ package cmd import ( + "context" "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "github.com/minio/minio/internal/auth" "github.com/minio/minio/internal/bucket/cors" ) +type corsLookupCountingObjectLayer struct { + ObjectLayer + getObjectNInfoCalls atomic.Int64 +} + +func (o *corsLookupCountingObjectLayer) GetObjectNInfo(ctx context.Context, bucket, object string, rs *HTTPRangeSpec, h http.Header, opts ObjectOptions) (*GetObjectReader, error) { + o.getObjectNInfoCalls.Add(1) + return o.ObjectLayer.GetObjectNInfo(ctx, bucket, object, rs, h, opts) +} + func TestPerBucketCorsPreflight(t *testing.T) { cfg := &cors.Config{CORSRules: []cors.Rule{{ AllowedOrigins: []string{"http://example.com"}, @@ -245,8 +257,13 @@ func TestPerBucketCorsOriginPatternResponse(t *testing.T) { func TestBucketCorsMetadataErrorFailsClosed(t *testing.T) { oldObjectAPI := newObjectLayerFn() + oldMetadataSys := globalBucketMetadataSys setObjectLayer(nil) - defer setObjectLayer(oldObjectAPI) + globalBucketMetadataSys = NewBucketMetadataSys() + defer func() { + setObjectLayer(oldObjectAPI) + globalBucketMetadataSys = oldMetadataSys + }() wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) @@ -273,6 +290,34 @@ func TestBucketCorsMetadataErrorFailsClosed(t *testing.T) { } } +func TestBucketCorsSkipsMetadataLookupWithoutOrigin(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testBucketCorsSkipsMetadataLookupWithoutOrigin, + endpoints: []string{"GetObject"}, + }) +} + +func testBucketCorsSkipsMetadataLookupWithoutOrigin(obj ObjectLayer, _ string, _ string, _ http.Handler, _ auth.Credentials, t *testing.T) { + oldObjectAPI := newObjectLayerFn() + counting := &corsLookupCountingObjectLayer{ObjectLayer: obj} + setObjectLayer(counting) + defer setObjectLayer(oldObjectAPI) + + wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + rec := httptest.NewRecorder() + wrapped.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/login", nil)) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + if got := counting.getObjectNInfoCalls.Load(); got != 0 { + t.Fatalf("request without Origin performed %d bucket metadata reads", got) + } +} + func TestBucketCorsNoConfigUsesGlobalFallback(t *testing.T) { ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ t: t, @@ -281,6 +326,34 @@ func TestBucketCorsNoConfigUsesGlobalFallback(t *testing.T) { }) } +func TestBucketCorsMissingBucketUsesGlobalFallback(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testBucketCorsMissingBucketUsesGlobalFallback, + endpoints: []string{"GetBucketCors"}, + }) +} + +func testBucketCorsMissingBucketUsesGlobalFallback(_ ObjectLayer, _ string, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) { + wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, getGetObjectURL("", bucket+"-missing", "object"), nil) + req.Header.Set("Origin", "https://app.example.com") + wrapped.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" { + t.Fatalf("allow-origin = %q", got) + } + if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("allow-credentials = %q", got) + } +} + func testBucketCorsNoConfigUsesGlobalFallback(_ ObjectLayer, _ string, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) { wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) From fc7bf7b2959540e7bcf2fac5508dfa59da07baf0 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Sat, 29 Aug 2026 17:58:30 +0800 Subject: [PATCH 2/3] test: keep CORS hot-path coverage route-neutral Signed-off-by: Feng Ruohang --- cmd/bucket-cors-middleware_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/bucket-cors-middleware_test.go b/cmd/bucket-cors-middleware_test.go index 8c1732a9a..cd14978a7 100644 --- a/cmd/bucket-cors-middleware_test.go +++ b/cmd/bucket-cors-middleware_test.go @@ -308,7 +308,7 @@ func testBucketCorsSkipsMetadataLookupWithoutOrigin(obj ObjectLayer, _ string, _ w.WriteHeader(http.StatusNoContent) })) rec := httptest.NewRecorder() - wrapped.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/login", nil)) + wrapped.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, getGetObjectURL("", "api", "v1/login"), nil)) if rec.Code != http.StatusNoContent { t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) From 150e7b5f9e3f2b1522c84a04533e0354352f7cd8 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Sat, 29 Aug 2026 18:19:57 +0800 Subject: [PATCH 3/3] fix: preserve global CORS response semantics Guard only the per-bucket metadata lookup, then retain the global handler Vary and originless preflight behavior for non-CORS traffic. Signed-off-by: Feng Ruohang --- cmd/api-router.go | 28 +++++++++++++--------------- cmd/bucket-cors-middleware_test.go | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/cmd/api-router.go b/cmd/api-router.go index b3cc17af8..63164a06c 100644 --- a/cmd/api-router.go +++ b/cmd/api-router.go @@ -785,23 +785,21 @@ func corsHandler(handler http.Handler) http.Handler { } globalCors := cors.New(opts).Handler(handler) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Origin") == "" { - handler.ServeHTTP(w, r) - return - } - if bucket, _ := request2BucketObjectName(r); bucket != "" && globalBucketMetadataSys != nil { - cfg, _, err := globalBucketMetadataSys.GetCorsConfig(bucket) - if err == nil && cfg != nil { - if applyBucketCors(w, r, cfg) { + if r.Header.Get("Origin") != "" { + if bucket, _ := request2BucketObjectName(r); bucket != "" && globalBucketMetadataSys != nil { + cfg, _, err := globalBucketMetadataSys.GetCorsConfig(bucket) + if err == nil && cfg != nil { + if applyBucketCors(w, r, cfg) { + return + } + handler.ServeHTTP(w, r) + return + } + if err != nil && !errors.Is(err, errConfigNotFound) { + internalLogOnceIf(r.Context(), err, "bucket-cors-metadata") + handler.ServeHTTP(w, r) return } - handler.ServeHTTP(w, r) - return - } - if err != nil && !errors.Is(err, errConfigNotFound) && r.Header.Get("Origin") != "" { - internalLogOnceIf(r.Context(), err, "bucket-cors-metadata") - handler.ServeHTTP(w, r) - return } } globalCors.ServeHTTP(w, r) diff --git a/cmd/bucket-cors-middleware_test.go b/cmd/bucket-cors-middleware_test.go index cd14978a7..f168e0f31 100644 --- a/cmd/bucket-cors-middleware_test.go +++ b/cmd/bucket-cors-middleware_test.go @@ -313,11 +313,32 @@ func testBucketCorsSkipsMetadataLookupWithoutOrigin(obj ObjectLayer, _ string, _ if rec.Code != http.StatusNoContent { t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) } + requireCorsOriginVary(t, rec.Header()) if got := counting.getObjectNInfoCalls.Load(); got != 0 { t.Fatalf("request without Origin performed %d bucket metadata reads", got) } } +func TestBucketCorsOriginlessPreflightShapeUsesGlobalHandler(t *testing.T) { + nextCalled := false + wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusTeapot) + })) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, getGetObjectURL("", "api", "v1/login"), nil) + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + wrapped.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + if nextCalled { + t.Fatal("originless preflight-shaped OPTIONS reached the application handler") + } + requireCorsOriginVary(t, rec.Header()) +} + func TestBucketCorsNoConfigUsesGlobalFallback(t *testing.T) { ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ t: t,