From 7a49a7a3daea53b5c0878a25d614b94a9ce2b0e8 Mon Sep 17 00:00:00 2001 From: h5vx Date: Tue, 25 Aug 2026 17:23:27 +0500 Subject: [PATCH] feat: enforce per-bucket CORS with global fallback Signed-off-by: h5vx --- cmd/api-router.go | 90 ++++++++++++++++++++++++++++- cmd/bucket-cors-middleware_test.go | 93 ++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 cmd/bucket-cors-middleware_test.go diff --git a/cmd/api-router.go b/cmd/api-router.go index 99733f8b2..48d05f228 100644 --- a/cmd/api-router.go +++ b/cmd/api-router.go @@ -20,8 +20,11 @@ package cmd import ( "net" "net/http" + "strconv" + "strings" consoleapi "github.com/minio/console/api" + bktcors "github.com/minio/minio/internal/bucket/cors" xhttp "github.com/minio/minio/internal/http" "github.com/minio/mux" "github.com/minio/pkg/v3/wildcard" @@ -644,6 +647,79 @@ func registerAPIRouter(router *mux.Router) { } // corsHandler handler for CORS (Cross Origin Resource Sharing) +// applyBucketCors applies a bucket's CORS configuration to the request. +// For an OPTIONS preflight it writes the full CORS response and returns true +// (request is complete). For an actual request it adds the applicable +// Access-Control-* response headers and returns false so the request +// continues down the handler chain. If no rule matches a preflight it writes +// 403 and returns true. +func applyBucketCors(w http.ResponseWriter, r *http.Request, cfg *bktcors.Config) (handled bool) { + origin := r.Header.Get("Origin") + if origin == "" { + return false // not a CORS request + } + + isPreflight := r.Method == http.MethodOptions && + r.Header.Get("Access-Control-Request-Method") != "" + + if isPreflight { + method := r.Header.Get("Access-Control-Request-Method") + rule, ok := cfg.MatchRule(origin, method) + if !ok { + writeResponse(w, http.StatusForbidden, nil, mimeNone) + return true + } + reqHeaders := splitAndTrim(r.Header.Get("Access-Control-Request-Headers")) + allowedHeaders, ok := rule.FilterAllowedHeaders(reqHeaders) + if !ok { + writeResponse(w, http.StatusForbidden, nil, mimeNone) + return true + } + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Access-Control-Allow-Methods", method) + if len(allowedHeaders) > 0 { + h.Set("Access-Control-Allow-Headers", strings.Join(allowedHeaders, ", ")) + } + if rule.MaxAgeSeconds > 0 { + h.Set("Access-Control-Max-Age", strconv.Itoa(rule.MaxAgeSeconds)) + } + h.Set("Access-Control-Allow-Credentials", "true") + h.Add("Vary", "Origin") + writeResponse(w, http.StatusOK, nil, mimeNone) + return true + } + + // Actual request: attach headers if the origin+method match. + rule, ok := cfg.MatchRule(origin, r.Method) + if !ok { + return false // no matching rule → no CORS headers, continue normally + } + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Access-Control-Allow-Credentials", "true") + if len(rule.ExposeHeaders) > 0 { + h.Set("Access-Control-Expose-Headers", strings.Join(rule.ExposeHeaders, ", ")) + } + h.Add("Vary", "Origin") + return false +} + +// splitAndTrim splits a comma-separated header list into trimmed, non-empty values. +func splitAndTrim(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := parts[:0] + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + func corsHandler(handler http.Handler) http.Handler { commonS3Headers := []string{ xhttp.Date, @@ -688,5 +764,17 @@ func corsHandler(handler http.Handler) http.Handler { ExposedHeaders: commonS3Headers, AllowCredentials: true, } - return cors.New(opts).Handler(handler) + globalCors := cors.New(opts).Handler(handler) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if bucket, _ := request2BucketObjectName(r); bucket != "" && globalBucketMetadataSys != nil { + if cfg, _, err := globalBucketMetadataSys.GetCorsConfig(bucket); err == nil && cfg != nil { + if applyBucketCors(w, r, cfg) { + return + } + 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 new file mode 100644 index 000000000..d294ed811 --- /dev/null +++ b/cmd/bucket-cors-middleware_test.go @@ -0,0 +1,93 @@ +// Copyright (c) 2015-2021 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/minio/minio/internal/bucket/cors" +) + +func TestPerBucketCorsPreflight(t *testing.T) { + cfg := &cors.Config{CORSRules: []cors.Rule{{ + AllowedOrigins: []string{"http://example.com"}, + AllowedMethods: []string{"GET", "PUT"}, + AllowedHeaders: []string{"*"}, + ExposeHeaders: []string{"ETag"}, + MaxAgeSeconds: 3000, + }}} + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, "/mybucket/obj", nil) + req.Header.Set("Origin", "http://example.com") + req.Header.Set("Access-Control-Request-Method", "GET") + + handled := applyBucketCors(rec, req, cfg) + if !handled { + t.Fatal("expected preflight to be handled") + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://example.com" { + t.Fatalf("allow-origin = %q", got) + } + if rec.Code != http.StatusOK { + t.Fatalf("preflight status = %d", rec.Code) + } +} + +func TestPerBucketCorsPreflightNoMatch(t *testing.T) { + cfg := &cors.Config{CORSRules: []cors.Rule{{ + AllowedOrigins: []string{"http://example.com"}, + AllowedMethods: []string{"GET"}, + }}} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, "/mybucket/obj", nil) + req.Header.Set("Origin", "http://evil.com") + req.Header.Set("Access-Control-Request-Method", "GET") + + handled := applyBucketCors(rec, req, cfg) + if !handled { + t.Fatal("expected preflight to be handled (rejected)") + } + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for disallowed origin, got %d", rec.Code) + } +} + +func TestPerBucketCorsActualRequest(t *testing.T) { + cfg := &cors.Config{CORSRules: []cors.Rule{{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET"}, + ExposeHeaders: []string{"ETag"}, + }}} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/mybucket/obj", nil) + req.Header.Set("Origin", "http://any.com") + + handled := applyBucketCors(rec, req, cfg) + if handled { + t.Fatal("actual (non-preflight) request must not be terminated by CORS") + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://any.com" { + t.Fatalf("allow-origin = %q", got) + } + if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "ETag" { + t.Fatalf("expose-headers = %q", got) + } +}