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
+16
View File
@@ -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 <ID> (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")
}
+7 -4
View File
@@ -53,10 +53,13 @@ func TestParseAndValidate(t *testing.T) {
func TestValidateRejections(t *testing.T) {
cases := map[string]string{
"bad method": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>TRACE</AllowedMethod></CORSRule></CORSConfiguration>`,
"no origin": `<CORSConfiguration><CORSRule><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>`,
"no method": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin></CORSRule></CORSConfiguration>`,
"negative age": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod><MaxAgeSeconds>-1</MaxAgeSeconds></CORSRule></CORSConfiguration>`,
"bad method": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>TRACE</AllowedMethod></CORSRule></CORSConfiguration>`,
"no origin": `<CORSConfiguration><CORSRule><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>`,
"no method": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin></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 {
c, err := ParseBucketCorsConfig(strings.NewReader(doc))