From ff3395d3c675e367036144eccbaa5506aa37387f Mon Sep 17 00:00:00 2001 From: h5vx Date: Tue, 25 Aug 2026 17:19:02 +0500 Subject: [PATCH] feat: implement S3 per-bucket CORS handlers Signed-off-by: h5vx --- cmd/api-router.go | 5 - cmd/bucket-cors-handlers.go | 197 +++++++++++++++++++++++++++++++ cmd/bucket-cors-handlers_test.go | 100 ++++++++++++++++ cmd/dummy-handlers.go | 90 -------------- cmd/test-utils_test.go | 14 +++ 5 files changed, 311 insertions(+), 95 deletions(-) create mode 100644 cmd/bucket-cors-handlers.go create mode 100644 cmd/bucket-cors-handlers_test.go diff --git a/cmd/api-router.go b/cmd/api-router.go index 188dd854f..99733f8b2 100644 --- a/cmd/api-router.go +++ b/cmd/api-router.go @@ -111,11 +111,6 @@ var rejectedBucketAPIs = []rejectedAPI{ methods: []string{http.MethodGet, http.MethodPut, http.MethodDelete}, queries: []string{"inventory", ""}, }, - { - api: "cors", - methods: []string{http.MethodPut, http.MethodDelete}, - queries: []string{"cors", ""}, - }, { api: "metrics", methods: []string{http.MethodGet, http.MethodPut, http.MethodDelete}, diff --git a/cmd/bucket-cors-handlers.go b/cmd/bucket-cors-handlers.go new file mode 100644 index 000000000..d38e5f3be --- /dev/null +++ b/cmd/bucket-cors-handlers.go @@ -0,0 +1,197 @@ +// 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 ( + "bytes" + "encoding/base64" + "encoding/xml" + "errors" + "io" + "net/http" + + humanize "github.com/dustin/go-humanize" + "github.com/minio/madmin-go/v3" + "github.com/minio/minio/internal/bucket/cors" + "github.com/minio/minio/internal/logger" + "github.com/minio/mux" + "github.com/minio/pkg/v3/policy" +) + +// maxBucketCorsSize is the maximum allowed size of a CORS configuration document. +const maxBucketCorsSize = 64 * humanize.KiByte + +// PutBucketCorsHandler - PUT bucket cors. +func (api objectAPIHandlers) PutBucketCorsHandler(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "PutBucketCors") + + defer logger.AuditLog(ctx, w, r, mustGetClaimsFromToken(r)) + + objAPI := api.ObjectAPI() + if objAPI == nil { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL) + return + } + + vars := mux.Vars(r) + bucket := vars["bucket"] + + if s3Error := checkRequestAuthType(ctx, r, policy.PutBucketCorsAction, bucket, ""); s3Error != ErrNone { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) + return + } + + if _, err := objAPI.GetBucketInfo(ctx, bucket, BucketOptions{}); err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } + + if r.ContentLength <= 0 { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrMissingContentLength), r.URL) + return + } + if r.ContentLength > maxBucketCorsSize { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrEntityTooLarge), r.URL) + return + } + + corsBytes, err := io.ReadAll(io.LimitReader(r.Body, r.ContentLength)) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } + + corsCfg, err := cors.ParseBucketCorsConfig(bytes.NewReader(corsBytes)) + if err != nil { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrMalformedXML), r.URL) + return + } + if err := corsCfg.Validate(); err != nil { + writeErrorResponse(ctx, w, APIError{ + Code: "MalformedXML", + HTTPStatusCode: http.StatusBadRequest, + Description: err.Error(), + }, r.URL) + return + } + + updatedAt, err := globalBucketMetadataSys.Update(ctx, bucket, bucketCorsConfig, corsBytes) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } + + // Call site replication hook. + // + // We encode the xml bytes as base64 to ensure there are no encoding + // errors. + cfgStr := base64.StdEncoding.EncodeToString(corsBytes) + replLogIf(ctx, globalSiteReplicationSys.BucketMetaHook(ctx, madmin.SRBucketMeta{ + Type: madmin.SRBucketMetaTypeCorsConfig, + Bucket: bucket, + Cors: &cfgStr, + UpdatedAt: updatedAt, + })) + + writeSuccessResponseHeadersOnly(w) +} + +// GetBucketCorsHandler - GET bucket cors. +func (api objectAPIHandlers) GetBucketCorsHandler(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "GetBucketCors") + + defer logger.AuditLog(ctx, w, r, mustGetClaimsFromToken(r)) + + objAPI := api.ObjectAPI() + if objAPI == nil { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL) + return + } + + vars := mux.Vars(r) + bucket := vars["bucket"] + + if s3Error := checkRequestAuthType(ctx, r, policy.GetBucketCorsAction, bucket, ""); s3Error != ErrNone { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) + return + } + + if _, err := objAPI.GetBucketInfo(ctx, bucket, BucketOptions{}); err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } + + config, _, err := globalBucketMetadataSys.GetCorsConfig(bucket) + if err != nil { + if errors.Is(err, errConfigNotFound) { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrNoSuchCORSConfiguration), r.URL) + return + } + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } + + configData, err := xml.Marshal(config) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } + + writeSuccessResponseXML(w, configData) +} + +// DeleteBucketCorsHandler - DELETE bucket cors. +func (api objectAPIHandlers) DeleteBucketCorsHandler(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "DeleteBucketCors") + + defer logger.AuditLog(ctx, w, r, mustGetClaimsFromToken(r)) + + objAPI := api.ObjectAPI() + if objAPI == nil { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL) + return + } + + vars := mux.Vars(r) + bucket := vars["bucket"] + + if s3Error := checkRequestAuthType(ctx, r, policy.DeleteBucketCorsAction, bucket, ""); s3Error != ErrNone { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) + return + } + + if _, err := objAPI.GetBucketInfo(ctx, bucket, BucketOptions{}); err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } + + updatedAt, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketCorsConfig) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } + + replLogIf(ctx, globalSiteReplicationSys.BucketMetaHook(ctx, madmin.SRBucketMeta{ + Type: madmin.SRBucketMetaTypeCorsConfig, + Bucket: bucket, + Cors: nil, + UpdatedAt: updatedAt, + })) + + writeSuccessNoContent(w) +} diff --git a/cmd/bucket-cors-handlers_test.go b/cmd/bucket-cors-handlers_test.go new file mode 100644 index 000000000..22932fdaa --- /dev/null +++ b/cmd/bucket-cors-handlers_test.go @@ -0,0 +1,100 @@ +// 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 ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/minio/minio/internal/auth" +) + +const testCORSDoc = `http://example.comGETPUTETag3000` + +func TestBucketCorsHandlers(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testBucketCorsHandlers, endpoints: []string{"PutBucketCors", "GetBucketCors", "DeleteBucketCors"}}) +} + +func testBucketCorsHandlers(obj ObjectLayer, instanceType, bucketName string, apiRouter http.Handler, + creds auth.Credentials, t *testing.T, +) { + // PUT + req, err := newTestSignedRequestV4(http.MethodPut, getBucketCorsURL("", bucketName), + int64(len(testCORSDoc)), bytes.NewReader([]byte(testCORSDoc)), creds.AccessKey, creds.SecretKey, nil) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PUT cors: expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + // GET returns what we stored + req, err = newTestSignedRequestV4(http.MethodGet, getBucketCorsURL("", bucketName), + 0, nil, creds.AccessKey, creds.SecretKey, nil) + if err != nil { + t.Fatal(err) + } + rec = httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET cors: expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("http://example.com")) { + t.Fatalf("GET cors: body missing origin: %s", rec.Body.String()) + } + + // DELETE + req, err = newTestSignedRequestV4(http.MethodDelete, getBucketCorsURL("", bucketName), + 0, nil, creds.AccessKey, creds.SecretKey, nil) + if err != nil { + t.Fatal(err) + } + rec = httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("DELETE cors: expected 204, got %d", rec.Code) + } + + // GET after delete → 404 NoSuchCORSConfiguration + req, err = newTestSignedRequestV4(http.MethodGet, getBucketCorsURL("", bucketName), + 0, nil, creds.AccessKey, creds.SecretKey, nil) + if err != nil { + t.Fatal(err) + } + rec = httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("GET cors after delete: expected 404, got %d", rec.Code) + } + + // Malformed XML → 400 + req, err = newTestSignedRequestV4(http.MethodPut, getBucketCorsURL("", bucketName), + int64(len("")), bytes.NewReader([]byte("")), creds.AccessKey, creds.SecretKey, nil) + if err != nil { + t.Fatal(err) + } + rec = httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("PUT malformed cors: expected 400, got %d", rec.Code) + } +} diff --git a/cmd/dummy-handlers.go b/cmd/dummy-handlers.go index 685b79256..1781c228f 100644 --- a/cmd/dummy-handlers.go +++ b/cmd/dummy-handlers.go @@ -165,93 +165,3 @@ func (api objectAPIHandlers) GetBucketLoggingHandler(w http.ResponseWriter, r *h func (api objectAPIHandlers) DeleteBucketWebsiteHandler(w http.ResponseWriter, r *http.Request) { writeSuccessResponseHeadersOnly(w) } - -// GetBucketCorsHandler - GET bucket cors, a dummy api -func (api objectAPIHandlers) GetBucketCorsHandler(w http.ResponseWriter, r *http.Request) { - ctx := newContext(r, w, "GetBucketCors") - - defer logger.AuditLog(ctx, w, r, mustGetClaimsFromToken(r)) - - vars := mux.Vars(r) - bucket := vars["bucket"] - - objAPI := api.ObjectAPI() - if objAPI == nil { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL) - return - } - - if s3Error := checkRequestAuthType(ctx, r, policy.GetBucketCorsAction, bucket, ""); s3Error != ErrNone { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) - return - } - - // Validate if bucket exists, before proceeding further... - _, err := objAPI.GetBucketInfo(ctx, bucket, BucketOptions{}) - if err != nil { - writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) - return - } - - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrNoSuchCORSConfiguration), r.URL) -} - -// PutBucketCorsHandler - PUT bucket cors, a dummy api -func (api objectAPIHandlers) PutBucketCorsHandler(w http.ResponseWriter, r *http.Request) { - ctx := newContext(r, w, "PutBucketCors") - - defer logger.AuditLog(ctx, w, r, mustGetClaimsFromToken(r)) - - vars := mux.Vars(r) - bucket := vars["bucket"] - - objAPI := api.ObjectAPI() - if objAPI == nil { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL) - return - } - - if s3Error := checkRequestAuthType(ctx, r, policy.PutBucketCorsAction, bucket, ""); s3Error != ErrNone { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) - return - } - - // Validate if bucket exists, before proceeding further... - _, err := objAPI.GetBucketInfo(ctx, bucket, BucketOptions{}) - if err != nil { - writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) - return - } - - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrNotImplemented), r.URL) -} - -// DeleteBucketCorsHandler - DELETE bucket cors, a dummy api -func (api objectAPIHandlers) DeleteBucketCorsHandler(w http.ResponseWriter, r *http.Request) { - ctx := newContext(r, w, "DeleteBucketCors") - - defer logger.AuditLog(ctx, w, r, mustGetClaimsFromToken(r)) - - vars := mux.Vars(r) - bucket := vars["bucket"] - - objAPI := api.ObjectAPI() - if objAPI == nil { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL) - return - } - - if s3Error := checkRequestAuthType(ctx, r, policy.DeleteBucketCorsAction, bucket, ""); s3Error != ErrNone { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) - return - } - - // Validate if bucket exists, before proceeding further... - _, err := objAPI.GetBucketInfo(ctx, bucket, BucketOptions{}) - if err != nil { - writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) - return - } - - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrNotImplemented), r.URL) -} diff --git a/cmd/test-utils_test.go b/cmd/test-utils_test.go index 0f903625c..6d0e7bf64 100644 --- a/cmd/test-utils_test.go +++ b/cmd/test-utils_test.go @@ -1373,6 +1373,11 @@ func getBucketLifecycleURL(endPoint, bucketName string) (ret string) { return makeTestTargetURL(endPoint, bucketName, "", queryValue) } +// return URL for set/get/delete cors of the bucket. +func getBucketCorsURL(endPoint, bucketName string) string { + return makeTestTargetURL(endPoint, bucketName, "", url.Values{"cors": []string{""}}) +} + // return URL for listing objects in the bucket with V1 legacy API. func getListObjectsV1URL(endPoint, bucketName, prefix, maxKeys, encodingType string) string { queryValue := url.Values{} @@ -2052,6 +2057,15 @@ func registerBucketLevelFunc(bucket *mux.Router, api objectAPIHandlers, apiFunct case "ListenNotification": // Register ListenNotification Handler. bucket.Methods(http.MethodGet).HandlerFunc(api.ListenNotificationHandler).Queries("events", "{events:.*}") + case "PutBucketCors": + // Register PutBucketCors handler. + bucket.Methods(http.MethodPut).HandlerFunc(api.PutBucketCorsHandler).Queries("cors", "") + case "GetBucketCors": + // Register GetBucketCors handler. + bucket.Methods(http.MethodGet).HandlerFunc(api.GetBucketCorsHandler).Queries("cors", "") + case "DeleteBucketCors": + // Register DeleteBucketCors handler. + bucket.Methods(http.MethodDelete).HandlerFunc(api.DeleteBucketCorsHandler).Queries("cors", "") } } }