diff --git a/buildscripts/rebrand-guard/compat-baseline.json b/buildscripts/rebrand-guard/compat-baseline.json
index 1ab6313da..556c85ea9 100644
--- a/buildscripts/rebrand-guard/compat-baseline.json
+++ b/buildscripts/rebrand-guard/compat-baseline.json
@@ -4037,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",
diff --git a/cmd/admin-handlers-site-replication.go b/cmd/admin-handlers-site-replication.go
index bda093955..ef74c9676 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 bd0af5a65..00048f15c 100644
--- a/cmd/api-router.go
+++ b/cmd/api-router.go
@@ -679,7 +679,11 @@ func applyBucketCors(w http.ResponseWriter, r *http.Request, cfg *bktcors.Config
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
}
diff --git a/cmd/bucket-cors-handlers.go b/cmd/bucket-cors-handlers.go
index 81525e606..9f66471a0 100644
--- a/cmd/bucket-cors-handlers.go
+++ b/cmd/bucket-cors-handlers.go
@@ -69,6 +69,14 @@ func (api objectAPIHandlers) PutBucketCorsHandler(w http.ResponseWriter, r *http
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)
diff --git a/cmd/site-replication.go b/cmd/site-replication.go
index a166ae2ad..76c880a69 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 397bb9f99..6f4064b04 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/internal/bucket/cors/cors.go b/internal/bucket/cors/cors.go
index 5fe0fcce9..daf3db202 100644
--- a/internal/bucket/cors/cors.go
+++ b/internal/bucket/cors/cors.go
@@ -31,6 +31,9 @@ import (
// 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,
@@ -74,17 +77,30 @@ func (c *Config) Validate() error {
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")
}
diff --git a/internal/bucket/cors/cors_test.go b/internal/bucket/cors/cors_test.go
index 3c02c14db..dd6ce995e 100644
--- a/internal/bucket/cors/cors_test.go
+++ b/internal/bucket/cors/cors_test.go
@@ -53,10 +53,13 @@ func TestParseAndValidate(t *testing.T) {
func TestValidateRejections(t *testing.T) {
cases := map[string]string{
- "bad method": `*TRACE`,
- "no origin": `GET`,
- "no method": `*`,
- "negative age": `*GET-1`,
+ "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))