Merge pull request #71 from h5vx/feature/per-bucket-cors

feat: per-bucket CORS configuration with S3 ?cors enforcement\n\nRelease hardening and site-replication convergence follow-up: #75.
This commit is contained in:
Feng Ruohang
2026-08-28 10:34:52 +08:00
committed by GitHub
16 changed files with 1269 additions and 102 deletions
@@ -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",
@@ -3127,6 +3131,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",
@@ -4032,6 +4038,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",
@@ -5873,6 +5880,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",
+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)
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)
}
+88 -6
View File
@@ -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)
})
}
+198
View File
@@ -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 <http://www.gnu.org/licenses/>.
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)
}
+132
View File
@@ -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 <http://www.gnu.org/licenses/>.
package cmd
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/minio/minio/internal/auth"
)
const testCORSDoc = `<CORSConfiguration><CORSRule><AllowedOrigin>http://example.com</AllowedOrigin><AllowedMethod>GET</AllowedMethod><AllowedMethod>PUT</AllowedMethod><ExposeHeader>ETag</ExposeHeader><MaxAgeSeconds>3000</MaxAgeSeconds></CORSRule></CORSConfiguration>`
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("<bad>")), bytes.NewReader([]byte("<bad>")), 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)
}
}
+93
View File
@@ -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 <http://www.gnu.org/licenses/>.
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)
}
}
+31
View File
@@ -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)
+19
View File
@@ -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(`<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>`)
)
// 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
}
+55 -5
View File
@@ -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
}
+41
View File
@@ -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 <http://www.gnu.org/licenses/>.
package cmd
import "testing"
func TestBucketMetadataCorsRoundTrip(t *testing.T) {
meta := newBucketMetadata("test-cors")
meta.CorsConfigXML = []byte(`<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>`)
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")
}
}
-90
View File
@@ -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)
}
+163 -1
View File
@@ -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]
+92
View File
@@ -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 = `<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)
}
}
}
+14
View File
@@ -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", "")
}
}
}
+186
View File
@@ -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 <http://www.gnu.org/licenses/>.
// 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 <ID> (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 <CORSConfiguration> document.
type Config struct {
XMLName xml.Name `xml:"CORSConfiguration"`
CORSRules []Rule `xml:"CORSRule"`
}
// Rule is a single <CORSRule>.
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
}
+131
View File
@@ -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 <http://www.gnu.org/licenses/>.
package cors
import (
"strings"
"testing"
)
const sampleCORS = `<CORSConfiguration>
<CORSRule>
<ID>rule1</ID>
<AllowedOrigin>http://www.example.com</AllowedOrigin>
<AllowedOrigin>https://*.example.org</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedMethod>PUT</AllowedMethod>
<AllowedHeader>x-amz-*</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
<MaxAgeSeconds>3000</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>`
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": `<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))
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 = `<CORSConfiguration>
<CORSRule>
<ID>A-restrictive</ID>
<AllowedOrigin>https://app.example.com</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedHeader>x-amz-date</AllowedHeader>
</CORSRule>
<CORSRule>
<ID>B-permissive</ID>
<AllowedOrigin>https://app.example.com</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>`
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)
}
}