feat: replicate per-bucket CORS across sites and harden the protocol path

Site replication emitted SRBucketMetaTypeCorsConfig on PutBucketCors, but
the peer receive/apply, initial-sync, status, and heal paths did not carry
the CORS metadata. Replicated sites could therefore diverge on CORS config
even though the originating request succeeded.

Complete every site-replication path for CORS, mirroring the SSEConfig
pattern:
  - peer apply: PeerBucketCorsConfigHandler + item.Cors handling in
    PeerBucketMetadataUpdateHandler, with an updatedAt staleness guard
  - initial sync: push existing CorsConfigXML via BucketMetaHook
  - status: parse per-site CorsConfig, count/compare, surface
    CorsCfgMismatch/HasCorsCfgSet/ReplicatedCorsConfig, and include CORS in
    the bucket-stats aggregation filter
  - heal: healCORSMetadata, including nil -> delete propagation

Also harden the request/config path:
  - PutBucketCors validates the supplied Content-MD5/checksum via
    validateLengthAndChecksum
  - CORS validation rejects more than one wildcard per AllowedOrigin/
    AllowedHeader and enforces the 255-char rule ID limit
  - preflight responses Vary on Origin, Access-Control-Request-Method, and
    Access-Control-Request-Headers

Add focused tests for the CORS SR transport round-trip, the metadata
equality helper, and the new validation constraints.

Signed-off-by: h5vx <h5v@protonmail.com>
This commit is contained in:
h5vx
2026-08-27 12:46:47 +05:00
parent c5bc57b7a3
commit 13e6458d90
8 changed files with 293 additions and 5 deletions
@@ -4037,6 +4037,7 @@
"cmd:cmd:method:SiteReplicationSys.Netperf", "cmd:cmd:method:SiteReplicationSys.Netperf",
"cmd:cmd:method:SiteReplicationSys.PeerAddPolicyHandler", "cmd:cmd:method:SiteReplicationSys.PeerAddPolicyHandler",
"cmd:cmd:method:SiteReplicationSys.PeerBucketConfigureReplHandler", "cmd:cmd:method:SiteReplicationSys.PeerBucketConfigureReplHandler",
"cmd:cmd:method:SiteReplicationSys.PeerBucketCorsConfigHandler",
"cmd:cmd:method:SiteReplicationSys.PeerBucketDeleteHandler", "cmd:cmd:method:SiteReplicationSys.PeerBucketDeleteHandler",
"cmd:cmd:method:SiteReplicationSys.PeerBucketLCConfigHandler", "cmd:cmd:method:SiteReplicationSys.PeerBucketLCConfigHandler",
"cmd:cmd:method:SiteReplicationSys.PeerBucketMakeWithVersioningHandler", "cmd:cmd:method:SiteReplicationSys.PeerBucketMakeWithVersioningHandler",
+2
View File
@@ -258,6 +258,8 @@ func (a adminAPIHandlers) SRPeerReplicateBucketItem(w http.ResponseWriter, r *ht
err = globalSiteReplicationSys.PeerBucketObjectLockConfigHandler(ctx, item.Bucket, item.ObjectLockConfig, item.UpdatedAt) err = globalSiteReplicationSys.PeerBucketObjectLockConfigHandler(ctx, item.Bucket, item.ObjectLockConfig, item.UpdatedAt)
case madmin.SRBucketMetaTypeSSEConfig: case madmin.SRBucketMetaTypeSSEConfig:
err = globalSiteReplicationSys.PeerBucketSSEConfigHandler(ctx, item.Bucket, item.SSEConfig, item.UpdatedAt) 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: case madmin.SRBucketMetaLCConfig:
err = globalSiteReplicationSys.PeerBucketLCConfigHandler(ctx, item.Bucket, item.ExpiryLCConfig, item.UpdatedAt) err = globalSiteReplicationSys.PeerBucketLCConfigHandler(ctx, item.Bucket, item.ExpiryLCConfig, item.UpdatedAt)
} }
+4
View File
@@ -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-Max-Age", strconv.Itoa(rule.MaxAgeSeconds))
} }
h.Set("Access-Control-Allow-Credentials", "true") 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", "Origin")
h.Add("Vary", "Access-Control-Request-Method")
h.Add("Vary", "Access-Control-Request-Headers")
writeResponse(w, http.StatusOK, nil, mimeNone) writeResponse(w, http.StatusOK, nil, mimeNone)
return true return true
} }
+8
View File
@@ -69,6 +69,14 @@ func (api objectAPIHandlers) PutBucketCorsHandler(w http.ResponseWriter, r *http
return 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)) corsBytes, err := io.ReadAll(io.LimitReader(r.Body, r.ContentLength))
if err != nil { if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
+163 -1
View File
@@ -1632,6 +1632,15 @@ func (c *SiteReplicationSys) PeerBucketMetadataUpdateHandler(ctx context.Context
meta.QuotaConfigUpdatedAt = item.UpdatedAt 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) return globalBucketMetadataSys.save(ctx, meta)
} }
@@ -1749,6 +1758,35 @@ func (c *SiteReplicationSys) PeerBucketSSEConfigHandler(ctx context.Context, buc
return nil 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. // PeerBucketQuotaConfigHandler - copies/deletes policy to local cluster.
func (c *SiteReplicationSys) PeerBucketQuotaConfigHandler(ctx context.Context, bucket string, quota *madmin.BucketQuota, updatedAt time.Time) error { 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. // 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 // Replicate existing bucket quotas settings
quotaConfigJSON, tm := meta.QuotaConfigJSON, meta.QuotaConfigUpdatedAt quotaConfigJSON, tm := meta.QuotaConfigJSON, meta.QuotaConfigUpdatedAt
if len(quotaConfigJSON) > 0 { if len(quotaConfigJSON) > 0 {
@@ -2720,6 +2773,7 @@ func (c *SiteReplicationSys) SiteReplicationStatus(ctx context.Context, objAPI O
st.VersioningConfigMismatch || st.VersioningConfigMismatch ||
st.OLockConfigMismatch || st.OLockConfigMismatch ||
st.SSEConfigMismatch || st.SSEConfigMismatch ||
st.CorsCfgMismatch ||
st.PolicyMismatch || st.PolicyMismatch ||
st.ReplicationCfgMismatch || st.ReplicationCfgMismatch ||
st.QuotaCfgMismatch || st.QuotaCfgMismatch ||
@@ -3144,8 +3198,9 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
replCfgs := make([]*sreplication.Config, numSites) replCfgs := make([]*sreplication.Config, numSites)
quotaCfgs := make([]*madmin.BucketQuota, numSites) quotaCfgs := make([]*madmin.BucketQuota, numSites)
sseCfgSet := set.NewStringSet() sseCfgSet := set.NewStringSet()
corsCfgSet := set.NewStringSet()
versionCfgSet := set.NewStringSet() versionCfgSet := set.NewStringSet()
var tagCount, olockCfgCount, sseCfgCount, versionCfgCount int var tagCount, olockCfgCount, sseCfgCount, corsCfgCount, versionCfgCount int
for i, s := range slc { for i, s := range slc {
if s.ReplicationConfig != nil { if s.ReplicationConfig != nil {
cfgBytes, err := base64.StdEncoding.DecodeString(*s.ReplicationConfig) cfgBytes, err := base64.StdEncoding.DecodeString(*s.ReplicationConfig)
@@ -3216,6 +3271,16 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
sseCfgSet.Add(string(configData)) 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] ss, ok := info.StatsSummary[s.DeploymentID]
if !ok { if !ok {
ss = madmin.SRSiteSummary{} ss = madmin.SRSiteSummary{}
@@ -3234,6 +3299,9 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
if sseCfgCount > 0 { if sseCfgCount > 0 {
ss.TotalSSEConfigCount++ ss.TotalSSEConfigCount++
} }
if corsCfgCount > 0 {
ss.TotalCorsConfigCount++
}
if versionCfgCount > 0 { if versionCfgCount > 0 {
ss.TotalVersioningConfigCount++ ss.TotalVersioningConfigCount++
} }
@@ -3245,6 +3313,7 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
tagMismatch := !isReplicated(tagCount, numSites, tagSet) tagMismatch := !isReplicated(tagCount, numSites, tagSet)
olockCfgMismatch := !isReplicated(olockCfgCount, numSites, olockConfigSet) olockCfgMismatch := !isReplicated(olockCfgCount, numSites, olockConfigSet)
sseCfgMismatch := !isReplicated(sseCfgCount, numSites, sseCfgSet) sseCfgMismatch := !isReplicated(sseCfgCount, numSites, sseCfgSet)
corsCfgMismatch := !isReplicated(corsCfgCount, numSites, corsCfgSet)
versionCfgMismatch := !isReplicated(versionCfgCount, numSites, versionCfgSet) versionCfgMismatch := !isReplicated(versionCfgCount, numSites, versionCfgSet)
policyMismatch := !isBktPolicyReplicated(numSites, policies) policyMismatch := !isBktPolicyReplicated(numSites, policies)
replCfgMismatch := !isBktReplCfgReplicated(numSites, replCfgs) replCfgMismatch := !isBktReplCfgReplicated(numSites, replCfgs)
@@ -3267,6 +3336,7 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
TagMismatch: tagMismatch, TagMismatch: tagMismatch,
OLockConfigMismatch: olockCfgMismatch, OLockConfigMismatch: olockCfgMismatch,
SSEConfigMismatch: sseCfgMismatch, SSEConfigMismatch: sseCfgMismatch,
CorsCfgMismatch: corsCfgMismatch,
VersioningConfigMismatch: versionCfgMismatch, VersioningConfigMismatch: versionCfgMismatch,
PolicyMismatch: policyMismatch, PolicyMismatch: policyMismatch,
ReplicationCfgMismatch: replCfgMismatch, ReplicationCfgMismatch: replCfgMismatch,
@@ -3277,6 +3347,7 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
HasPolicySet: s.Policy != nil, HasPolicySet: s.Policy != nil,
HasQuotaCfgSet: quotaCfgSet, HasQuotaCfgSet: quotaCfgSet,
HasSSECfgSet: s.SSEConfig != nil, HasSSECfgSet: s.SSEConfig != nil,
HasCorsCfgSet: s.CorsConfig != nil,
} }
var m srBucketMetaInfo var m srBucketMetaInfo
if len(bucketStats[s.Bucket]) > dIdx { if len(bucketStats[s.Bucket]) > dIdx {
@@ -3299,6 +3370,9 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
if !sseCfgMismatch && sseCfgCount == numSites { if !sseCfgMismatch && sseCfgCount == numSites {
sum.ReplicatedSSEConfig++ sum.ReplicatedSSEConfig++
} }
if !corsCfgMismatch && corsCfgCount == numSites {
sum.ReplicatedCorsConfig++
}
if !policyMismatch && len(policies) == numSites { if !policyMismatch && len(policies) == numSites {
sum.ReplicatedBucketPolicies++ sum.ReplicatedBucketPolicies++
} }
@@ -3709,6 +3783,12 @@ func (c *SiteReplicationSys) SiteReplicationMetaInfo(ctx context.Context, objAPI
bms.SSEConfigUpdatedAt = meta.EncryptionConfigUpdatedAt 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 { if len(meta.ReplicationConfigXML) > 0 {
rcfgXMLStr := base64.StdEncoding.EncodeToString(meta.ReplicationConfigXML) rcfgXMLStr := base64.StdEncoding.EncodeToString(meta.ReplicationConfigXML)
bms.ReplicationConfig = &rcfgXMLStr bms.ReplicationConfig = &rcfgXMLStr
@@ -4459,6 +4539,7 @@ func (c *SiteReplicationSys) healBuckets(ctx context.Context, objAPI ObjectLayer
c.healVersioningMetadata(ctx, objAPI, bucket, info) c.healVersioningMetadata(ctx, objAPI, bucket, info)
c.healOLockConfigMetadata(ctx, objAPI, bucket, info) c.healOLockConfigMetadata(ctx, objAPI, bucket, info)
c.healSSEMetadata(ctx, objAPI, bucket, info) c.healSSEMetadata(ctx, objAPI, bucket, info)
c.healCORSMetadata(ctx, objAPI, bucket, info)
c.healBucketReplicationConfig(ctx, objAPI, bucket, info, &opts) c.healBucketReplicationConfig(ctx, objAPI, bucket, info, &opts)
c.healBucketPolicies(ctx, objAPI, bucket, info) c.healBucketPolicies(ctx, objAPI, bucket, info)
c.healTagMetadata(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 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 { func (c *SiteReplicationSys) healOLockConfigMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
bs := info.BucketStats[bucket] bs := info.BucketStats[bucket]
+92
View File
@@ -18,7 +18,10 @@
package cmd package cmd
import ( import (
"encoding/base64"
"encoding/json"
"testing" "testing"
"time"
"github.com/minio/madmin-go/v3" "github.com/minio/madmin-go/v3"
"github.com/minio/minio-go/v7/pkg/set" "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 = `<CORSConfiguration><CORSRule><AllowedOrigin>https://app.example.com</AllowedOrigin><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>`
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)
}
}
}
+16
View File
@@ -31,6 +31,9 @@ import (
// maxCORSRules is the maximum number of rules allowed per bucket (AWS S3 limit). // maxCORSRules is the maximum number of rules allowed per bucket (AWS S3 limit).
const maxCORSRules = 100 const maxCORSRules = 100
// maxCORSRuleIDLen is the maximum length of a CORSRule <ID> (AWS S3 limit).
const maxCORSRuleIDLen = 255
// supportedMethods are the HTTP methods permitted in an AllowedMethod element. // supportedMethods are the HTTP methods permitted in an AllowedMethod element.
var supportedMethods = map[string]bool{ var supportedMethods = map[string]bool{
"GET": true, "GET": true,
@@ -74,17 +77,30 @@ func (c *Config) Validate() error {
return errors.New("CORSConfiguration exceeds the maximum number of rules") return errors.New("CORSConfiguration exceeds the maximum number of rules")
} }
for _, r := range c.CORSRules { 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 { if len(r.AllowedOrigins) == 0 {
return errors.New("CORSRule must contain at least one AllowedOrigin") return errors.New("CORSRule must contain at least one AllowedOrigin")
} }
if len(r.AllowedMethods) == 0 { if len(r.AllowedMethods) == 0 {
return errors.New("CORSRule must contain at least one AllowedMethod") 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 { for _, m := range r.AllowedMethods {
if !supportedMethods[strings.ToUpper(m)] { if !supportedMethods[strings.ToUpper(m)] {
return errors.New("unsupported method in CORSRule: " + 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 { if r.MaxAgeSeconds < 0 {
return errors.New("MaxAgeSeconds must not be negative") return errors.New("MaxAgeSeconds must not be negative")
} }
+7 -4
View File
@@ -53,10 +53,13 @@ func TestParseAndValidate(t *testing.T) {
func TestValidateRejections(t *testing.T) { func TestValidateRejections(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"bad method": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>TRACE</AllowedMethod></CORSRule></CORSConfiguration>`, "bad method": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>TRACE</AllowedMethod></CORSRule></CORSConfiguration>`,
"no origin": `<CORSConfiguration><CORSRule><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>`, "no origin": `<CORSConfiguration><CORSRule><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>`,
"no method": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin></CORSRule></CORSConfiguration>`, "no method": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin></CORSRule></CORSConfiguration>`,
"negative age": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod><MaxAgeSeconds>-1</MaxAgeSeconds></CORSRule></CORSConfiguration>`, "negative age": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod><MaxAgeSeconds>-1</MaxAgeSeconds></CORSRule></CORSConfiguration>`,
"multi wildcard origin": `<CORSConfiguration><CORSRule><AllowedOrigin>https://*.*.example.com</AllowedOrigin><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>`,
"multi wildcard header": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod><AllowedHeader>x-*-*</AllowedHeader></CORSRule></CORSConfiguration>`,
"overlong id": `<CORSConfiguration><CORSRule><ID>` + strings.Repeat("a", 256) + `</ID><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>`,
} }
for name, doc := range cases { for name, doc := range cases {
c, err := ParseBucketCorsConfig(strings.NewReader(doc)) c, err := ParseBucketCorsConfig(strings.NewReader(doc))