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))