diff --git a/buildscripts/rebrand-guard/compat-baseline.json b/buildscripts/rebrand-guard/compat-baseline.json
index ccacdc50200e7..556c85ea9013b 100644
--- a/buildscripts/rebrand-guard/compat-baseline.json
+++ b/buildscripts/rebrand-guard/compat-baseline.json
@@ -41,6 +41,7 @@
"github.com/minio/minio/internal/auth",
"github.com/minio/minio/internal/bpool",
"github.com/minio/minio/internal/bucket/bandwidth",
+ "github.com/minio/minio/internal/bucket/cors",
"github.com/minio/minio/internal/bucket/encryption",
"github.com/minio/minio/internal/bucket/lifecycle",
"github.com/minio/minio/internal/bucket/object/lock",
@@ -857,6 +858,7 @@
"/minio/health/cluster/read",
"/minio/health/live",
"/minio/health/ready",
+ "/mybucket/obj",
"/myobject*",
"/netperf",
"/newfolder",
@@ -1708,6 +1710,8 @@
"cmd:cmd:field:BucketMetadata.BucketTargetsConfigMetaJSON",
"cmd:cmd:field:BucketMetadata.BucketTargetsConfigMetaUpdatedAt",
"cmd:cmd:field:BucketMetadata.BucketTargetsConfigUpdatedAt",
+ "cmd:cmd:field:BucketMetadata.CorsConfigUpdatedAt",
+ "cmd:cmd:field:BucketMetadata.CorsConfigXML",
"cmd:cmd:field:BucketMetadata.Created",
"cmd:cmd:field:BucketMetadata.EncryptionConfigUpdatedAt",
"cmd:cmd:field:BucketMetadata.EncryptionConfigXML",
@@ -3126,6 +3130,8 @@
"cmd:cmd:method:BucketMetadataSys.GetBucketTargetsConfig",
"cmd:cmd:method:BucketMetadataSys.GetConfig",
"cmd:cmd:method:BucketMetadataSys.GetConfigFromDisk",
+ "cmd:cmd:method:BucketMetadataSys.GetCorsConfig",
+ "cmd:cmd:method:BucketMetadataSys.GetCorsConfigXML",
"cmd:cmd:method:BucketMetadataSys.GetLifecycleConfig",
"cmd:cmd:method:BucketMetadataSys.GetNotificationConfig",
"cmd:cmd:method:BucketMetadataSys.GetObjectLockConfig",
@@ -4031,6 +4037,7 @@
"cmd:cmd:method:SiteReplicationSys.Netperf",
"cmd:cmd:method:SiteReplicationSys.PeerAddPolicyHandler",
"cmd:cmd:method:SiteReplicationSys.PeerBucketConfigureReplHandler",
+ "cmd:cmd:method:SiteReplicationSys.PeerBucketCorsConfigHandler",
"cmd:cmd:method:SiteReplicationSys.PeerBucketDeleteHandler",
"cmd:cmd:method:SiteReplicationSys.PeerBucketLCConfigHandler",
"cmd:cmd:method:SiteReplicationSys.PeerBucketMakeWithVersioningHandler",
@@ -5872,6 +5879,23 @@
"internal/bucket/bandwidth:bandwidth:type:MonitorReaderOptions",
"internal/bucket/bandwidth:bandwidth:type:MonitoredReader",
"internal/bucket/bandwidth:bandwidth:type:SelectionFunction",
+ "internal/bucket/cors:cors:field:Config.CORSRules",
+ "internal/bucket/cors:cors:field:Config.XMLName",
+ "internal/bucket/cors:cors:field:Rule.AllowedHeaders",
+ "internal/bucket/cors:cors:field:Rule.AllowedMethods",
+ "internal/bucket/cors:cors:field:Rule.AllowedOrigins",
+ "internal/bucket/cors:cors:field:Rule.ExposeHeaders",
+ "internal/bucket/cors:cors:field:Rule.ID",
+ "internal/bucket/cors:cors:field:Rule.MaxAgeSeconds",
+ "internal/bucket/cors:cors:func:ParseBucketCorsConfig",
+ "internal/bucket/cors:cors:method:Config.MatchPreflight",
+ "internal/bucket/cors:cors:method:Config.MatchRule",
+ "internal/bucket/cors:cors:method:Config.Validate",
+ "internal/bucket/cors:cors:method:Rule.FilterAllowedHeaders",
+ "internal/bucket/cors:cors:method:Rule.HasAllowedMethod",
+ "internal/bucket/cors:cors:method:Rule.HasAllowedOrigin",
+ "internal/bucket/cors:cors:type:Config",
+ "internal/bucket/cors:cors:type:Rule",
"internal/bucket/encryption:sse:const:AES256",
"internal/bucket/encryption:sse:const:AWSKms",
"internal/bucket/encryption:sse:field:ApplyOptions.AutoEncrypt",
diff --git a/cmd/admin-handlers-site-replication.go b/cmd/admin-handlers-site-replication.go
index bda0939554858..ef74c96763979 100644
--- a/cmd/admin-handlers-site-replication.go
+++ b/cmd/admin-handlers-site-replication.go
@@ -258,6 +258,8 @@ func (a adminAPIHandlers) SRPeerReplicateBucketItem(w http.ResponseWriter, r *ht
err = globalSiteReplicationSys.PeerBucketObjectLockConfigHandler(ctx, item.Bucket, item.ObjectLockConfig, item.UpdatedAt)
case madmin.SRBucketMetaTypeSSEConfig:
err = globalSiteReplicationSys.PeerBucketSSEConfigHandler(ctx, item.Bucket, item.SSEConfig, item.UpdatedAt)
+ case madmin.SRBucketMetaTypeCorsConfig:
+ err = globalSiteReplicationSys.PeerBucketCorsConfigHandler(ctx, item.Bucket, item.Cors, item.UpdatedAt)
case madmin.SRBucketMetaLCConfig:
err = globalSiteReplicationSys.PeerBucketLCConfigHandler(ctx, item.Bucket, item.ExpiryLCConfig, item.UpdatedAt)
}
diff --git a/cmd/api-router.go b/cmd/api-router.go
index 188dd854fdf3d..00048f15cca1f 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"
@@ -111,11 +114,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},
@@ -648,6 +646,78 @@ func registerAPIRouter(router *mux.Router) {
apiRouter.MethodNotAllowedHandler = collectAPIStats("methodnotallowed", httpTraceAll(methodNotAllowedHandler("S3")))
}
+// 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")
+ reqHeaders := splitAndTrim(r.Header.Get("Access-Control-Request-Headers"))
+ rule, allowedHeaders, ok := cfg.MatchPreflight(origin, method, 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")
+ // A preflight response depends on all three request headers that
+ // determine the outcome, so cache variation must key on each of them.
+ h.Add("Vary", "Origin")
+ h.Add("Vary", "Access-Control-Request-Method")
+ h.Add("Vary", "Access-Control-Request-Headers")
+ 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
+}
+
// corsHandler handler for CORS (Cross Origin Resource Sharing)
func corsHandler(handler http.Handler) http.Handler {
commonS3Headers := []string{
@@ -693,5 +763,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-handlers.go b/cmd/bucket-cors-handlers.go
new file mode 100644
index 0000000000000..9f66471a0671f
--- /dev/null
+++ b/cmd/bucket-cors-handlers.go
@@ -0,0 +1,198 @@
+// 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"
+ "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
+ }
+
+ // PutBucketCors requires a Content-Md5 (or a supported trailing/full
+ // checksum). validateLengthAndChecksum wraps r.Body so the supplied
+ // digest is verified as the body is read below.
+ if !validateLengthAndChecksum(r) {
+ writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrMissingContentMD5), 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
+ }
+
+ configData, _, err := globalBucketMetadataSys.GetCorsConfigXML(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
+ }
+
+ 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 0000000000000..f442c2b79f989
--- /dev/null
+++ b/cmd/bucket-cors-handlers_test.go
@@ -0,0 +1,132 @@
+// 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)
+ }
+
+ // Re-PUT the config so the store→GetCorsConfig→enforce seam below has
+ // something to enforce (the earlier DELETE removed it).
+ 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 (re-put): expected 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ // End-to-end enforcement: drive an OPTIONS preflight through the real
+ // corsHandler wrapper (not applyBucketCors in isolation), exercising the
+ // full store -> globalBucketMetadataSys.GetCorsConfig -> enforce seam.
+ wrapped := corsHandler(apiRouter)
+
+ preflightURL := getBucketCorsURL("", bucketName)
+ preflightReq := httptest.NewRequest(http.MethodOptions, preflightURL, nil)
+ preflightReq.Header.Set("Origin", "http://example.com")
+ preflightReq.Header.Set("Access-Control-Request-Method", http.MethodGet)
+
+ rec = httptest.NewRecorder()
+ wrapped.ServeHTTP(rec, preflightReq)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("OPTIONS preflight via corsHandler: expected 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://example.com" {
+ t.Fatalf("OPTIONS preflight via corsHandler: expected Access-Control-Allow-Origin echoed, got %q", got)
+ }
+}
diff --git a/cmd/bucket-cors-middleware_test.go b/cmd/bucket-cors-middleware_test.go
new file mode 100644
index 0000000000000..d294ed811d027
--- /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)
+ }
+}
diff --git a/cmd/bucket-metadata-sys.go b/cmd/bucket-metadata-sys.go
index 20be4ffd3e36a..cb442ca5eaad4 100644
--- a/cmd/bucket-metadata-sys.go
+++ b/cmd/bucket-metadata-sys.go
@@ -29,6 +29,7 @@ import (
"github.com/minio/madmin-go/v3"
"github.com/minio/minio-go/v7/pkg/set"
"github.com/minio/minio-go/v7/pkg/tags"
+ "github.com/minio/minio/internal/bucket/cors"
bucketsse "github.com/minio/minio/internal/bucket/encryption"
"github.com/minio/minio/internal/bucket/lifecycle"
objectlock "github.com/minio/minio/internal/bucket/object/lock"
@@ -134,6 +135,9 @@ func (sys *BucketMetadataSys) updateAndParse(ctx context.Context, bucket string,
case bucketTaggingConfig:
meta.TaggingConfigXML = configData
meta.TaggingConfigUpdatedAt = updatedAt
+ case bucketCorsConfig:
+ meta.CorsConfigXML = configData
+ meta.CorsConfigUpdatedAt = updatedAt
case bucketQuotaConfigFile:
meta.QuotaConfigJSON = configData
meta.QuotaConfigUpdatedAt = updatedAt
@@ -359,6 +363,33 @@ func (sys *BucketMetadataSys) GetSSEConfig(bucket string) (*bucketsse.BucketSSEC
return meta.sseConfig, meta.EncryptionConfigUpdatedAt, nil
}
+// GetCorsConfig returns the CORS configuration for the given bucket.
+// The returned object must not be modified.
+func (sys *BucketMetadataSys) GetCorsConfig(bucket string) (*cors.Config, time.Time, error) {
+ meta, _, err := sys.GetConfig(GlobalContext, bucket)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ if meta.corsConfig == nil {
+ return nil, time.Time{}, errConfigNotFound
+ }
+ return meta.corsConfig, meta.CorsConfigUpdatedAt, nil
+}
+
+// GetCorsConfigXML returns the raw stored CORS configuration XML for the
+// given bucket, preserving the document exactly as it was PUT (including
+// the S3 xmlns and any unmodeled elements).
+func (sys *BucketMetadataSys) GetCorsConfigXML(bucket string) ([]byte, time.Time, error) {
+ meta, _, err := sys.GetConfig(GlobalContext, bucket)
+ if err != nil {
+ return nil, time.Time{}, err
+ }
+ if len(meta.CorsConfigXML) == 0 {
+ return nil, time.Time{}, errConfigNotFound
+ }
+ return meta.CorsConfigXML, meta.CorsConfigUpdatedAt, nil
+}
+
// CreatedAt returns the time of creation of bucket
func (sys *BucketMetadataSys) CreatedAt(bucket string) (time.Time, error) {
meta, _, err := sys.GetConfig(GlobalContext, bucket)
diff --git a/cmd/bucket-metadata.go b/cmd/bucket-metadata.go
index e78118175e58b..556510b61fd3e 100644
--- a/cmd/bucket-metadata.go
+++ b/cmd/bucket-metadata.go
@@ -31,6 +31,7 @@ import (
"github.com/minio/madmin-go/v3"
"github.com/minio/minio-go/v7/pkg/tags"
+ "github.com/minio/minio/internal/bucket/cors"
bucketsse "github.com/minio/minio/internal/bucket/encryption"
"github.com/minio/minio/internal/bucket/lifecycle"
objectlock "github.com/minio/minio/internal/bucket/object/lock"
@@ -58,6 +59,9 @@ var (
enabledBucketVersioningConfig = []byte(`Enabled`)
)
+// Bucket CORS configuration file.
+const bucketCorsConfig = "cors.xml"
+
//go:generate msgp -file $GOFILE
// BucketMetadata contains bucket metadata.
@@ -80,6 +84,7 @@ type BucketMetadata struct {
ReplicationConfigXML []byte
BucketTargetsConfigJSON []byte
BucketTargetsConfigMetaJSON []byte
+ CorsConfigXML []byte
PolicyConfigUpdatedAt time.Time
ObjectLockConfigUpdatedAt time.Time
@@ -92,6 +97,7 @@ type BucketMetadata struct {
NotificationConfigUpdatedAt time.Time
BucketTargetsConfigUpdatedAt time.Time
BucketTargetsConfigMetaUpdatedAt time.Time
+ CorsConfigUpdatedAt time.Time
// Add a new UpdatedAt field and update lastUpdate function
// Unexported fields. Must be updated atomically.
@@ -106,6 +112,7 @@ type BucketMetadata struct {
replicationConfig *replication.Config
bucketTargetConfig *madmin.BucketTargets
bucketTargetConfigMeta map[string]string
+ corsConfig *cors.Config
}
// newBucketMetadata creates BucketMetadata with the supplied name and Created to Now.
@@ -160,6 +167,9 @@ func (b BucketMetadata) lastUpdate() (t time.Time) {
if b.BucketTargetsConfigMetaUpdatedAt.After(t) {
t = b.BucketTargetsConfigMetaUpdatedAt
}
+ if b.CorsConfigUpdatedAt.After(t) {
+ t = b.CorsConfigUpdatedAt
+ }
return t
}
@@ -310,6 +320,15 @@ func (b *BucketMetadata) parseAllConfigs(ctx context.Context, objectAPI ObjectLa
b.taggingConfig = nil
}
+ if len(b.CorsConfigXML) != 0 {
+ b.corsConfig, err = cors.ParseBucketCorsConfig(bytes.NewReader(b.CorsConfigXML))
+ if err != nil {
+ return err
+ }
+ } else {
+ b.corsConfig = nil
+ }
+
if bytes.Equal(b.ObjectLockConfigXML, enabledBucketObjectLockConfig) {
b.VersioningConfigXML = enabledBucketVersioningConfig
}
diff --git a/cmd/bucket-metadata_gen.go b/cmd/bucket-metadata_gen.go
index 0407b66ea8db8..b074b3e1302b0 100644
--- a/cmd/bucket-metadata_gen.go
+++ b/cmd/bucket-metadata_gen.go
@@ -108,6 +108,12 @@ func (z *BucketMetadata) DecodeMsg(dc *msgp.Reader) (err error) {
err = msgp.WrapError(err, "BucketTargetsConfigMetaJSON")
return
}
+ case "CorsConfigXML":
+ z.CorsConfigXML, err = dc.ReadBytes(z.CorsConfigXML)
+ if err != nil {
+ err = msgp.WrapError(err, "CorsConfigXML")
+ return
+ }
case "PolicyConfigUpdatedAt":
z.PolicyConfigUpdatedAt, err = dc.ReadTime()
if err != nil {
@@ -174,6 +180,12 @@ func (z *BucketMetadata) DecodeMsg(dc *msgp.Reader) (err error) {
err = msgp.WrapError(err, "BucketTargetsConfigMetaUpdatedAt")
return
}
+ case "CorsConfigUpdatedAt":
+ z.CorsConfigUpdatedAt, err = dc.ReadTime()
+ if err != nil {
+ err = msgp.WrapError(err, "CorsConfigUpdatedAt")
+ return
+ }
default:
err = dc.Skip()
if err != nil {
@@ -187,9 +199,9 @@ func (z *BucketMetadata) DecodeMsg(dc *msgp.Reader) (err error) {
// EncodeMsg implements msgp.Encodable
func (z *BucketMetadata) EncodeMsg(en *msgp.Writer) (err error) {
- // map header, size 25
+ // map header, size 27
// write "Name"
- err = en.Append(0xde, 0x0, 0x19, 0xa4, 0x4e, 0x61, 0x6d, 0x65)
+ err = en.Append(0xde, 0x0, 0x1b, 0xa4, 0x4e, 0x61, 0x6d, 0x65)
if err != nil {
return
}
@@ -328,6 +340,16 @@ func (z *BucketMetadata) EncodeMsg(en *msgp.Writer) (err error) {
err = msgp.WrapError(err, "BucketTargetsConfigMetaJSON")
return
}
+ // write "CorsConfigXML"
+ err = en.Append(0xad, 0x43, 0x6f, 0x72, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x58, 0x4d, 0x4c)
+ if err != nil {
+ return
+ }
+ err = en.WriteBytes(z.CorsConfigXML)
+ if err != nil {
+ err = msgp.WrapError(err, "CorsConfigXML")
+ return
+ }
// write "PolicyConfigUpdatedAt"
err = en.Append(0xb5, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74)
if err != nil {
@@ -438,15 +460,25 @@ func (z *BucketMetadata) EncodeMsg(en *msgp.Writer) (err error) {
err = msgp.WrapError(err, "BucketTargetsConfigMetaUpdatedAt")
return
}
+ // write "CorsConfigUpdatedAt"
+ err = en.Append(0xb3, 0x43, 0x6f, 0x72, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74)
+ if err != nil {
+ return
+ }
+ err = en.WriteTime(z.CorsConfigUpdatedAt)
+ if err != nil {
+ err = msgp.WrapError(err, "CorsConfigUpdatedAt")
+ return
+ }
return
}
// MarshalMsg implements msgp.Marshaler
func (z *BucketMetadata) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
- // map header, size 25
+ // map header, size 27
// string "Name"
- o = append(o, 0xde, 0x0, 0x19, 0xa4, 0x4e, 0x61, 0x6d, 0x65)
+ o = append(o, 0xde, 0x0, 0x1b, 0xa4, 0x4e, 0x61, 0x6d, 0x65)
o = msgp.AppendString(o, z.Name)
// string "Created"
o = append(o, 0xa7, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64)
@@ -487,6 +519,9 @@ func (z *BucketMetadata) MarshalMsg(b []byte) (o []byte, err error) {
// string "BucketTargetsConfigMetaJSON"
o = append(o, 0xbb, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x4d, 0x65, 0x74, 0x61, 0x4a, 0x53, 0x4f, 0x4e)
o = msgp.AppendBytes(o, z.BucketTargetsConfigMetaJSON)
+ // string "CorsConfigXML"
+ o = append(o, 0xad, 0x43, 0x6f, 0x72, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x58, 0x4d, 0x4c)
+ o = msgp.AppendBytes(o, z.CorsConfigXML)
// string "PolicyConfigUpdatedAt"
o = append(o, 0xb5, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74)
o = msgp.AppendTime(o, z.PolicyConfigUpdatedAt)
@@ -520,6 +555,9 @@ func (z *BucketMetadata) MarshalMsg(b []byte) (o []byte, err error) {
// string "BucketTargetsConfigMetaUpdatedAt"
o = append(o, 0xd9, 0x20, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x4d, 0x65, 0x74, 0x61, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74)
o = msgp.AppendTime(o, z.BucketTargetsConfigMetaUpdatedAt)
+ // string "CorsConfigUpdatedAt"
+ o = append(o, 0xb3, 0x43, 0x6f, 0x72, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74)
+ o = msgp.AppendTime(o, z.CorsConfigUpdatedAt)
return
}
@@ -625,6 +663,12 @@ func (z *BucketMetadata) UnmarshalMsg(bts []byte) (o []byte, err error) {
err = msgp.WrapError(err, "BucketTargetsConfigMetaJSON")
return
}
+ case "CorsConfigXML":
+ z.CorsConfigXML, bts, err = msgp.ReadBytesBytes(bts, z.CorsConfigXML)
+ if err != nil {
+ err = msgp.WrapError(err, "CorsConfigXML")
+ return
+ }
case "PolicyConfigUpdatedAt":
z.PolicyConfigUpdatedAt, bts, err = msgp.ReadTimeBytes(bts)
if err != nil {
@@ -691,6 +735,12 @@ func (z *BucketMetadata) UnmarshalMsg(bts []byte) (o []byte, err error) {
err = msgp.WrapError(err, "BucketTargetsConfigMetaUpdatedAt")
return
}
+ case "CorsConfigUpdatedAt":
+ z.CorsConfigUpdatedAt, bts, err = msgp.ReadTimeBytes(bts)
+ if err != nil {
+ err = msgp.WrapError(err, "CorsConfigUpdatedAt")
+ return
+ }
default:
bts, err = msgp.Skip(bts)
if err != nil {
@@ -705,6 +755,6 @@ func (z *BucketMetadata) UnmarshalMsg(bts []byte) (o []byte, err error) {
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z *BucketMetadata) Msgsize() (s int) {
- s = 3 + 5 + msgp.StringPrefixSize + len(z.Name) + 8 + msgp.TimeSize + 12 + msgp.BoolSize + 17 + msgp.BytesPrefixSize + len(z.PolicyConfigJSON) + 22 + msgp.BytesPrefixSize + len(z.NotificationConfigXML) + 19 + msgp.BytesPrefixSize + len(z.LifecycleConfigXML) + 20 + msgp.BytesPrefixSize + len(z.ObjectLockConfigXML) + 20 + msgp.BytesPrefixSize + len(z.VersioningConfigXML) + 20 + msgp.BytesPrefixSize + len(z.EncryptionConfigXML) + 17 + msgp.BytesPrefixSize + len(z.TaggingConfigXML) + 16 + msgp.BytesPrefixSize + len(z.QuotaConfigJSON) + 21 + msgp.BytesPrefixSize + len(z.ReplicationConfigXML) + 24 + msgp.BytesPrefixSize + len(z.BucketTargetsConfigJSON) + 28 + msgp.BytesPrefixSize + len(z.BucketTargetsConfigMetaJSON) + 22 + msgp.TimeSize + 26 + msgp.TimeSize + 26 + msgp.TimeSize + 23 + msgp.TimeSize + 21 + msgp.TimeSize + 27 + msgp.TimeSize + 26 + msgp.TimeSize + 25 + msgp.TimeSize + 28 + msgp.TimeSize + 29 + msgp.TimeSize + 34 + msgp.TimeSize
+ s = 3 + 5 + msgp.StringPrefixSize + len(z.Name) + 8 + msgp.TimeSize + 12 + msgp.BoolSize + 17 + msgp.BytesPrefixSize + len(z.PolicyConfigJSON) + 22 + msgp.BytesPrefixSize + len(z.NotificationConfigXML) + 19 + msgp.BytesPrefixSize + len(z.LifecycleConfigXML) + 20 + msgp.BytesPrefixSize + len(z.ObjectLockConfigXML) + 20 + msgp.BytesPrefixSize + len(z.VersioningConfigXML) + 20 + msgp.BytesPrefixSize + len(z.EncryptionConfigXML) + 17 + msgp.BytesPrefixSize + len(z.TaggingConfigXML) + 16 + msgp.BytesPrefixSize + len(z.QuotaConfigJSON) + 21 + msgp.BytesPrefixSize + len(z.ReplicationConfigXML) + 24 + msgp.BytesPrefixSize + len(z.BucketTargetsConfigJSON) + 28 + msgp.BytesPrefixSize + len(z.BucketTargetsConfigMetaJSON) + 14 + msgp.BytesPrefixSize + len(z.CorsConfigXML) + 22 + msgp.TimeSize + 26 + msgp.TimeSize + 26 + msgp.TimeSize + 23 + msgp.TimeSize + 21 + msgp.TimeSize + 27 + msgp.TimeSize + 26 + msgp.TimeSize + 25 + msgp.TimeSize + 28 + msgp.TimeSize + 29 + msgp.TimeSize + 34 + msgp.TimeSize + 20 + msgp.TimeSize
return
}
diff --git a/cmd/bucket-metadata_test.go b/cmd/bucket-metadata_test.go
new file mode 100644
index 0000000000000..70447bcb28092
--- /dev/null
+++ b/cmd/bucket-metadata_test.go
@@ -0,0 +1,41 @@
+// Copyright (c) 2015-2026 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 "testing"
+
+func TestBucketMetadataCorsRoundTrip(t *testing.T) {
+ meta := newBucketMetadata("test-cors")
+ meta.CorsConfigXML = []byte(`*GET`)
+ meta.CorsConfigUpdatedAt = UTCNow()
+
+ buf, err := meta.MarshalMsg(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got BucketMetadata
+ if _, err := got.UnmarshalMsg(buf); err != nil {
+ t.Fatal(err)
+ }
+ if string(got.CorsConfigXML) != string(meta.CorsConfigXML) {
+ t.Fatalf("CorsConfigXML not preserved: %q", string(got.CorsConfigXML))
+ }
+ if !got.CorsConfigUpdatedAt.Equal(meta.CorsConfigUpdatedAt) {
+ t.Fatalf("CorsConfigUpdatedAt not preserved")
+ }
+}
diff --git a/cmd/dummy-handlers.go b/cmd/dummy-handlers.go
index 685b792564a55..1781c228f4cf4 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/site-replication.go b/cmd/site-replication.go
index a166ae2adddf9..76c880a692fac 100644
--- a/cmd/site-replication.go
+++ b/cmd/site-replication.go
@@ -1632,6 +1632,15 @@ func (c *SiteReplicationSys) PeerBucketMetadataUpdateHandler(ctx context.Context
meta.QuotaConfigUpdatedAt = item.UpdatedAt
}
+ if item.Cors != nil {
+ configData, err := base64.StdEncoding.DecodeString(*item.Cors)
+ if err != nil {
+ return wrapSRErr(err)
+ }
+ meta.CorsConfigXML = configData
+ meta.CorsConfigUpdatedAt = item.UpdatedAt
+ }
+
return globalBucketMetadataSys.save(ctx, meta)
}
@@ -1749,6 +1758,35 @@ func (c *SiteReplicationSys) PeerBucketSSEConfigHandler(ctx context.Context, buc
return nil
}
+// PeerBucketCorsConfigHandler - copies/deletes CORS config to local cluster.
+func (c *SiteReplicationSys) PeerBucketCorsConfigHandler(ctx context.Context, bucket string, corsConfig *string, updatedAt time.Time) error {
+ // skip overwrite if local update is newer than peer update.
+ if !updatedAt.IsZero() {
+ if _, updateTm, err := globalBucketMetadataSys.GetCorsConfig(bucket); err == nil && updateTm.After(updatedAt) {
+ return nil
+ }
+ }
+
+ if corsConfig != nil {
+ configData, err := base64.StdEncoding.DecodeString(*corsConfig)
+ if err != nil {
+ return wrapSRErr(err)
+ }
+ _, err = globalBucketMetadataSys.Update(ctx, bucket, bucketCorsConfig, configData)
+ if err != nil {
+ return wrapSRErr(err)
+ }
+ return nil
+ }
+
+ // Delete cors config
+ _, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketCorsConfig)
+ if err != nil {
+ return wrapSRErr(err)
+ }
+ return nil
+}
+
// PeerBucketQuotaConfigHandler - copies/deletes policy to local cluster.
func (c *SiteReplicationSys) PeerBucketQuotaConfigHandler(ctx context.Context, bucket string, quota *madmin.BucketQuota, updatedAt time.Time) error {
// skip overwrite if local update is newer than peer update.
@@ -1950,6 +1988,21 @@ func (c *SiteReplicationSys) syncToAllPeers(ctx context.Context, addOpts madmin.
}
}
+ // Replicate existing bucket CORS settings
+ corsConfigData, tm := meta.CorsConfigXML, meta.CorsConfigUpdatedAt
+ if len(corsConfigData) > 0 {
+ corsConfigStr := base64.StdEncoding.EncodeToString(corsConfigData)
+ err = c.BucketMetaHook(ctx, madmin.SRBucketMeta{
+ Type: madmin.SRBucketMetaTypeCorsConfig,
+ Bucket: bucket,
+ Cors: &corsConfigStr,
+ UpdatedAt: tm,
+ })
+ if err != nil {
+ return errSRBucketMetaError(err)
+ }
+ }
+
// Replicate existing bucket quotas settings
quotaConfigJSON, tm := meta.QuotaConfigJSON, meta.QuotaConfigUpdatedAt
if len(quotaConfigJSON) > 0 {
@@ -2720,6 +2773,7 @@ func (c *SiteReplicationSys) SiteReplicationStatus(ctx context.Context, objAPI O
st.VersioningConfigMismatch ||
st.OLockConfigMismatch ||
st.SSEConfigMismatch ||
+ st.CorsCfgMismatch ||
st.PolicyMismatch ||
st.ReplicationCfgMismatch ||
st.QuotaCfgMismatch ||
@@ -3144,8 +3198,9 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
replCfgs := make([]*sreplication.Config, numSites)
quotaCfgs := make([]*madmin.BucketQuota, numSites)
sseCfgSet := set.NewStringSet()
+ corsCfgSet := set.NewStringSet()
versionCfgSet := set.NewStringSet()
- var tagCount, olockCfgCount, sseCfgCount, versionCfgCount int
+ var tagCount, olockCfgCount, sseCfgCount, corsCfgCount, versionCfgCount int
for i, s := range slc {
if s.ReplicationConfig != nil {
cfgBytes, err := base64.StdEncoding.DecodeString(*s.ReplicationConfig)
@@ -3216,6 +3271,16 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
sseCfgSet.Add(string(configData))
}
}
+ if s.CorsConfig != nil {
+ configData, err := base64.StdEncoding.DecodeString(*s.CorsConfig)
+ if err != nil {
+ continue
+ }
+ corsCfgCount++
+ if !corsCfgSet.Contains(string(configData)) {
+ corsCfgSet.Add(string(configData))
+ }
+ }
ss, ok := info.StatsSummary[s.DeploymentID]
if !ok {
ss = madmin.SRSiteSummary{}
@@ -3234,6 +3299,9 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
if sseCfgCount > 0 {
ss.TotalSSEConfigCount++
}
+ if corsCfgCount > 0 {
+ ss.TotalCorsConfigCount++
+ }
if versionCfgCount > 0 {
ss.TotalVersioningConfigCount++
}
@@ -3245,6 +3313,7 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
tagMismatch := !isReplicated(tagCount, numSites, tagSet)
olockCfgMismatch := !isReplicated(olockCfgCount, numSites, olockConfigSet)
sseCfgMismatch := !isReplicated(sseCfgCount, numSites, sseCfgSet)
+ corsCfgMismatch := !isReplicated(corsCfgCount, numSites, corsCfgSet)
versionCfgMismatch := !isReplicated(versionCfgCount, numSites, versionCfgSet)
policyMismatch := !isBktPolicyReplicated(numSites, policies)
replCfgMismatch := !isBktReplCfgReplicated(numSites, replCfgs)
@@ -3267,6 +3336,7 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
TagMismatch: tagMismatch,
OLockConfigMismatch: olockCfgMismatch,
SSEConfigMismatch: sseCfgMismatch,
+ CorsCfgMismatch: corsCfgMismatch,
VersioningConfigMismatch: versionCfgMismatch,
PolicyMismatch: policyMismatch,
ReplicationCfgMismatch: replCfgMismatch,
@@ -3277,6 +3347,7 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
HasPolicySet: s.Policy != nil,
HasQuotaCfgSet: quotaCfgSet,
HasSSECfgSet: s.SSEConfig != nil,
+ HasCorsCfgSet: s.CorsConfig != nil,
}
var m srBucketMetaInfo
if len(bucketStats[s.Bucket]) > dIdx {
@@ -3299,6 +3370,9 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
if !sseCfgMismatch && sseCfgCount == numSites {
sum.ReplicatedSSEConfig++
}
+ if !corsCfgMismatch && corsCfgCount == numSites {
+ sum.ReplicatedCorsConfig++
+ }
if !policyMismatch && len(policies) == numSites {
sum.ReplicatedBucketPolicies++
}
@@ -3709,6 +3783,12 @@ func (c *SiteReplicationSys) SiteReplicationMetaInfo(ctx context.Context, objAPI
bms.SSEConfigUpdatedAt = meta.EncryptionConfigUpdatedAt
}
+ if len(meta.CorsConfigXML) > 0 {
+ corsConfigStr := base64.StdEncoding.EncodeToString(meta.CorsConfigXML)
+ bms.CorsConfig = &corsConfigStr
+ bms.CorsConfigUpdatedAt = meta.CorsConfigUpdatedAt
+ }
+
if len(meta.ReplicationConfigXML) > 0 {
rcfgXMLStr := base64.StdEncoding.EncodeToString(meta.ReplicationConfigXML)
bms.ReplicationConfig = &rcfgXMLStr
@@ -4459,6 +4539,7 @@ func (c *SiteReplicationSys) healBuckets(ctx context.Context, objAPI ObjectLayer
c.healVersioningMetadata(ctx, objAPI, bucket, info)
c.healOLockConfigMetadata(ctx, objAPI, bucket, info)
c.healSSEMetadata(ctx, objAPI, bucket, info)
+ c.healCORSMetadata(ctx, objAPI, bucket, info)
c.healBucketReplicationConfig(ctx, objAPI, bucket, info, &opts)
c.healBucketPolicies(ctx, objAPI, bucket, info)
c.healTagMetadata(ctx, objAPI, bucket, info)
@@ -4916,6 +4997,87 @@ func (c *SiteReplicationSys) healSSEMetadata(ctx context.Context, objAPI ObjectL
return nil
}
+func (c *SiteReplicationSys) healCORSMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
+ c.RLock()
+ defer c.RUnlock()
+ if !c.enabled {
+ return nil
+ }
+ var (
+ latestID, latestPeerName string
+ lastUpdate time.Time
+ latestCorsConfig *string
+ )
+
+ bs := info.BucketStats[bucket]
+ for dID, ss := range bs {
+ if lastUpdate.IsZero() {
+ lastUpdate = ss.meta.CorsConfigUpdatedAt
+ latestID = dID
+ latestCorsConfig = ss.meta.CorsConfig
+ }
+ // avoid considering just created buckets as latest. Perhaps this site
+ // just joined cluster replication and yet to be sync'd
+ if ss.meta.CreatedAt.Equal(ss.meta.CorsConfigUpdatedAt) {
+ continue
+ }
+ if ss.meta.CorsConfigUpdatedAt.After(lastUpdate) {
+ lastUpdate = ss.meta.CorsConfigUpdatedAt
+ latestID = dID
+ latestCorsConfig = ss.meta.CorsConfig
+ }
+ }
+
+ latestPeerName = info.Sites[latestID].Name
+ var latestCorsConfigBytes []byte
+ var err error
+ if latestCorsConfig != nil {
+ latestCorsConfigBytes, err = base64.StdEncoding.DecodeString(*latestCorsConfig)
+ if err != nil {
+ return err
+ }
+ }
+
+ for dID, bStatus := range bs {
+ if !bStatus.CorsCfgMismatch {
+ continue
+ }
+ if isBucketMetadataEqual(latestCorsConfig, bStatus.meta.CorsConfig) {
+ continue
+ }
+ if dID == globalDeploymentID() {
+ if latestCorsConfig == nil {
+ if _, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketCorsConfig); err != nil {
+ replLogIf(ctx, fmt.Errorf("Unable to heal CORS metadata from peer site %s : %w", latestPeerName, err))
+ }
+ continue
+ }
+ if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketCorsConfig, latestCorsConfigBytes); err != nil {
+ replLogIf(ctx, fmt.Errorf("Unable to heal CORS metadata from peer site %s : %w", latestPeerName, err))
+ }
+ continue
+ }
+
+ admClient, err := c.getAdminClient(ctx, dID)
+ if err != nil {
+ return wrapSRErr(err)
+ }
+ peerName := info.Sites[dID].Name
+ err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
+ Type: madmin.SRBucketMetaTypeCorsConfig,
+ Bucket: bucket,
+ Cors: latestCorsConfig,
+ UpdatedAt: lastUpdate,
+ })
+ if err != nil {
+ replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
+ fmt.Errorf("Unable to heal CORS config metadata for peer %s from peer %s : %w",
+ peerName, latestPeerName, err)))
+ }
+ }
+ return nil
+}
+
func (c *SiteReplicationSys) healOLockConfigMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
bs := info.BucketStats[bucket]
diff --git a/cmd/site-replication_test.go b/cmd/site-replication_test.go
index 397bb9f99228b..6f4064b04fba6 100644
--- a/cmd/site-replication_test.go
+++ b/cmd/site-replication_test.go
@@ -18,7 +18,10 @@
package cmd
import (
+ "encoding/base64"
+ "encoding/json"
"testing"
+ "time"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio-go/v7/pkg/set"
@@ -66,3 +69,92 @@ func TestGetMissingSiteNames(t *testing.T) {
}
}
}
+
+// TestSRBucketMetaCorsRoundTrip verifies that a CORS bucket-meta item
+// survives the JSON transport used by SRPeerReplicateBucketItem and that
+// the base64-encoded payload decodes back to the original XML bytes. This
+// mirrors the initial-sync push, the peer-apply path, and the heal path,
+// all of which carry the config through SRBucketMeta.Cors as base64.
+func TestSRBucketMetaCorsRoundTrip(t *testing.T) {
+ const corsXML = `https://app.example.comGET`
+ b64 := base64.StdEncoding.EncodeToString([]byte(corsXML))
+ updatedAt := time.Now().UTC().Truncate(time.Second)
+
+ item := madmin.SRBucketMeta{
+ Type: madmin.SRBucketMetaTypeCorsConfig,
+ Bucket: "testbucket",
+ Cors: &b64,
+ UpdatedAt: updatedAt,
+ }
+
+ data, err := json.Marshal(item)
+ if err != nil {
+ t.Fatalf("marshal failed: %v", err)
+ }
+
+ var got madmin.SRBucketMeta
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal failed: %v", err)
+ }
+
+ if got.Type != madmin.SRBucketMetaTypeCorsConfig {
+ t.Fatalf("type mismatch: got %q", got.Type)
+ }
+ if got.Cors == nil {
+ t.Fatal("expected non-nil Cors after round-trip")
+ }
+ decoded, err := base64.StdEncoding.DecodeString(*got.Cors)
+ if err != nil {
+ t.Fatalf("decode failed: %v", err)
+ }
+ if string(decoded) != corsXML {
+ t.Fatalf("payload mismatch:\n got %q\nwant %q", decoded, corsXML)
+ }
+ if !got.UpdatedAt.Equal(updatedAt) {
+ t.Fatalf("UpdatedAt mismatch: got %v want %v", got.UpdatedAt, updatedAt)
+ }
+
+ // A deletion is signaled with a nil Cors pointer; it must survive too.
+ del := madmin.SRBucketMeta{
+ Type: madmin.SRBucketMetaTypeCorsConfig,
+ Bucket: "testbucket",
+ Cors: nil,
+ UpdatedAt: updatedAt,
+ }
+ data, err = json.Marshal(del)
+ if err != nil {
+ t.Fatalf("marshal (delete) failed: %v", err)
+ }
+ var gotDel madmin.SRBucketMeta
+ if err := json.Unmarshal(data, &gotDel); err != nil {
+ t.Fatalf("unmarshal (delete) failed: %v", err)
+ }
+ if gotDel.Cors != nil {
+ t.Fatalf("expected nil Cors for deletion, got %q", *gotDel.Cors)
+ }
+}
+
+// TestIsBucketMetadataEqualCors covers the pointer-comparison helper used by
+// the CORS heal path to decide whether a peer already holds the latest config.
+func TestIsBucketMetadataEqualCors(t *testing.T) {
+ a := base64.StdEncoding.EncodeToString([]byte("config-a"))
+ b := base64.StdEncoding.EncodeToString([]byte("config-b"))
+
+ cases := []struct {
+ name string
+ one *string
+ two *string
+ want bool
+ }{
+ {"both nil", nil, nil, true},
+ {"one nil", &a, nil, false},
+ {"other nil", nil, &b, false},
+ {"equal", &a, &a, true},
+ {"different", &a, &b, false},
+ }
+ for _, tc := range cases {
+ if got := isBucketMetadataEqual(tc.one, tc.two); got != tc.want {
+ t.Errorf("%s: got %v want %v", tc.name, got, tc.want)
+ }
+ }
+}
diff --git a/cmd/test-utils_test.go b/cmd/test-utils_test.go
index 0f903625c3244..6d0e7bf64d906 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", "")
}
}
}
diff --git a/internal/bucket/cors/cors.go b/internal/bucket/cors/cors.go
new file mode 100644
index 0000000000000..daf3db202bac7
--- /dev/null
+++ b/internal/bucket/cors/cors.go
@@ -0,0 +1,186 @@
+// 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 cors implements the S3 per-bucket CORS configuration type,
+// its validation, and origin/method/header matching helpers.
+package cors
+
+import (
+ "encoding/xml"
+ "errors"
+ "io"
+ "strings"
+
+ "github.com/minio/pkg/v3/wildcard"
+)
+
+// maxCORSRules is the maximum number of rules allowed per bucket (AWS S3 limit).
+const maxCORSRules = 100
+
+// maxCORSRuleIDLen is the maximum length of a CORSRule (AWS S3 limit).
+const maxCORSRuleIDLen = 255
+
+// supportedMethods are the HTTP methods permitted in an AllowedMethod element.
+var supportedMethods = map[string]bool{
+ "GET": true,
+ "PUT": true,
+ "HEAD": true,
+ "POST": true,
+ "DELETE": true,
+}
+
+// Config is the S3 document.
+type Config struct {
+ XMLName xml.Name `xml:"CORSConfiguration"`
+ CORSRules []Rule `xml:"CORSRule"`
+}
+
+// Rule is a single .
+type Rule struct {
+ ID string `xml:"ID,omitempty"`
+ AllowedHeaders []string `xml:"AllowedHeader"`
+ AllowedMethods []string `xml:"AllowedMethod"`
+ AllowedOrigins []string `xml:"AllowedOrigin"`
+ ExposeHeaders []string `xml:"ExposeHeader"`
+ MaxAgeSeconds int `xml:"MaxAgeSeconds"`
+}
+
+// ParseBucketCorsConfig parses a CORS configuration from the given reader.
+func ParseBucketCorsConfig(r io.Reader) (*Config, error) {
+ var c Config
+ if err := xml.NewDecoder(r).Decode(&c); err != nil {
+ return nil, err
+ }
+ return &c, nil
+}
+
+// Validate checks the config against the S3 constraints.
+func (c *Config) Validate() error {
+ if len(c.CORSRules) == 0 {
+ return errors.New("CORSConfiguration must contain at least one rule")
+ }
+ if len(c.CORSRules) > maxCORSRules {
+ return errors.New("CORSConfiguration exceeds the maximum number of rules")
+ }
+ for _, r := range c.CORSRules {
+ if len(r.ID) > maxCORSRuleIDLen {
+ return errors.New("CORSRule ID exceeds the maximum length of 255 characters")
+ }
+ if len(r.AllowedOrigins) == 0 {
+ return errors.New("CORSRule must contain at least one AllowedOrigin")
+ }
+ if len(r.AllowedMethods) == 0 {
+ return errors.New("CORSRule must contain at least one AllowedMethod")
+ }
+ for _, o := range r.AllowedOrigins {
+ if strings.Count(o, "*") > 1 {
+ return errors.New("AllowedOrigin may contain at most one wildcard '*': " + o)
+ }
+ }
+ for _, m := range r.AllowedMethods {
+ if !supportedMethods[strings.ToUpper(m)] {
+ return errors.New("unsupported method in CORSRule: " + m)
+ }
+ }
+ for _, h := range r.AllowedHeaders {
+ if strings.Count(h, "*") > 1 {
+ return errors.New("AllowedHeader may contain at most one wildcard '*': " + h)
+ }
+ }
+ if r.MaxAgeSeconds < 0 {
+ return errors.New("MaxAgeSeconds must not be negative")
+ }
+ }
+ return nil
+}
+
+// HasAllowedOrigin reports whether the rule allows the given origin.
+func (r Rule) HasAllowedOrigin(origin string) bool {
+ for _, o := range r.AllowedOrigins {
+ if o == "*" || wildcard.MatchSimple(o, origin) {
+ return true
+ }
+ }
+ return false
+}
+
+// HasAllowedMethod reports whether the rule allows the given HTTP method.
+func (r Rule) HasAllowedMethod(method string) bool {
+ for _, m := range r.AllowedMethods {
+ if strings.EqualFold(m, method) {
+ return true
+ }
+ }
+ return false
+}
+
+// FilterAllowedHeaders returns the subset of reqHeaders permitted by the rule
+// and whether every requested header was allowed.
+func (r Rule) FilterAllowedHeaders(reqHeaders []string) ([]string, bool) {
+ var allowed []string
+ for _, h := range reqHeaders {
+ h = strings.TrimSpace(h)
+ if h == "" {
+ continue
+ }
+ if !r.headerAllowed(h) {
+ return nil, false
+ }
+ allowed = append(allowed, h)
+ }
+ return allowed, true
+}
+
+func (r Rule) headerAllowed(header string) bool {
+ for _, h := range r.AllowedHeaders {
+ if h == "*" || wildcard.MatchSimple(strings.ToLower(h), strings.ToLower(header)) {
+ return true
+ }
+ }
+ return false
+}
+
+// MatchRule returns the first rule whose origin and method both match.
+func (c *Config) MatchRule(origin, method string) (*Rule, bool) {
+ for i := range c.CORSRules {
+ r := &c.CORSRules[i]
+ if r.HasAllowedOrigin(origin) && r.HasAllowedMethod(method) {
+ return r, true
+ }
+ }
+ return nil, false
+}
+
+// MatchPreflight returns the first rule whose origin and method match and
+// whose AllowedHeaders permit every header in reqHeaders. Unlike MatchRule,
+// this keeps evaluating subsequent rules until one fully satisfies the
+// preflight request, since an earlier origin/method match with a more
+// restrictive header list must not shadow a later, more permissive rule.
+func (c *Config) MatchPreflight(origin, method string, reqHeaders []string) (rule *Rule, allowedHeaders []string, ok bool) {
+ for i := range c.CORSRules {
+ r := &c.CORSRules[i]
+ if !r.HasAllowedOrigin(origin) || !r.HasAllowedMethod(method) {
+ continue
+ }
+ allowed, headersOK := r.FilterAllowedHeaders(reqHeaders)
+ if !headersOK {
+ continue
+ }
+ return r, allowed, true
+ }
+ return nil, nil, false
+}
diff --git a/internal/bucket/cors/cors_test.go b/internal/bucket/cors/cors_test.go
new file mode 100644
index 0000000000000..dd6ce995ef004
--- /dev/null
+++ b/internal/bucket/cors/cors_test.go
@@ -0,0 +1,131 @@
+// 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 cors
+
+import (
+ "strings"
+ "testing"
+)
+
+const sampleCORS = `
+
+ rule1
+ http://www.example.com
+ https://*.example.org
+ GET
+ PUT
+ x-amz-*
+ ETag
+ 3000
+
+`
+
+func TestParseAndValidate(t *testing.T) {
+ c, err := ParseBucketCorsConfig(strings.NewReader(sampleCORS))
+ if err != nil {
+ t.Fatalf("parse failed: %v", err)
+ }
+ if err := c.Validate(); err != nil {
+ t.Fatalf("validate failed: %v", err)
+ }
+ if len(c.CORSRules) != 1 {
+ t.Fatalf("expected 1 rule, got %d", len(c.CORSRules))
+ }
+ if c.CORSRules[0].MaxAgeSeconds != 3000 {
+ t.Fatalf("MaxAgeSeconds mismatch: %d", c.CORSRules[0].MaxAgeSeconds)
+ }
+}
+
+func TestValidateRejections(t *testing.T) {
+ cases := map[string]string{
+ "bad method": `*TRACE`,
+ "no origin": `GET`,
+ "no method": `*`,
+ "negative age": `*GET-1`,
+ "multi wildcard origin": `https://*.*.example.comGET`,
+ "multi wildcard header": `*GETx-*-*`,
+ "overlong id": `` + strings.Repeat("a", 256) + `*GET`,
+ }
+ for name, doc := range cases {
+ c, err := ParseBucketCorsConfig(strings.NewReader(doc))
+ if err != nil {
+ continue // parse-level rejection is acceptable
+ }
+ if err := c.Validate(); err == nil {
+ t.Errorf("%s: expected validation error, got nil", name)
+ }
+ }
+}
+
+func TestMatching(t *testing.T) {
+ c, _ := ParseBucketCorsConfig(strings.NewReader(sampleCORS))
+ rule, ok := c.MatchRule("https://api.example.org", "GET")
+ if !ok {
+ t.Fatal("expected origin+method to match")
+ }
+ if _, ok := c.MatchRule("http://evil.com", "GET"); ok {
+ t.Fatal("did not expect match for disallowed origin")
+ }
+ if _, ok := c.MatchRule("http://www.example.com", "DELETE"); ok {
+ t.Fatal("did not expect match for disallowed method")
+ }
+ allowed, ok := rule.FilterAllowedHeaders([]string{"x-amz-date", "x-amz-content-sha256"})
+ if !ok || len(allowed) != 2 {
+ t.Fatalf("expected both headers allowed via wildcard, got %v ok=%v", allowed, ok)
+ }
+ if _, ok := rule.FilterAllowedHeaders([]string{"authorization"}); ok {
+ t.Fatal("did not expect authorization to be allowed")
+ }
+}
+
+func TestMatchPreflightFallsThroughToLaterRule(t *testing.T) {
+ // Rule A matches origin+method but only allows a restrictive header set.
+ // Rule B, listed after A, matches the same origin+method and allows any
+ // header. A preflight requesting a header only B permits must not be
+ // rejected just because A was tried first.
+ const doc = `
+
+ A-restrictive
+ https://app.example.com
+ GET
+ x-amz-date
+
+
+ B-permissive
+ https://app.example.com
+ GET
+ *
+
+`
+
+ c, err := ParseBucketCorsConfig(strings.NewReader(doc))
+ if err != nil {
+ t.Fatalf("parse failed: %v", err)
+ }
+
+ rule, allowed, ok := c.MatchPreflight("https://app.example.com", "GET", []string{"x-custom-header"})
+ if !ok {
+ t.Fatal("expected MatchPreflight to succeed via the later, permissive rule")
+ }
+ if rule.ID != "B-permissive" {
+ t.Fatalf("expected rule B-permissive to be selected, got %q", rule.ID)
+ }
+ if len(allowed) != 1 || allowed[0] != "x-custom-header" {
+ t.Fatalf("unexpected allowed headers: %v", allowed)
+ }
+}