diff --git a/buildscripts/rebrand-guard/compat-baseline.json b/buildscripts/rebrand-guard/compat-baseline.json index ccacdc50200e7..556c85ea9013b 100644 --- a/buildscripts/rebrand-guard/compat-baseline.json +++ b/buildscripts/rebrand-guard/compat-baseline.json @@ -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", @@ -3126,6 +3130,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", @@ -4031,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", @@ -5872,6 +5879,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", diff --git a/cmd/admin-handlers-site-replication.go b/cmd/admin-handlers-site-replication.go index bda0939554858..ef74c96763979 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 188dd854fdf3d..00048f15cca1f 100644 --- a/cmd/api-router.go +++ b/cmd/api-router.go @@ -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) + }) } diff --git a/cmd/bucket-cors-handlers.go b/cmd/bucket-cors-handlers.go new file mode 100644 index 0000000000000..9f66471a0671f --- /dev/null +++ b/cmd/bucket-cors-handlers.go @@ -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 . + +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) +} diff --git a/cmd/bucket-cors-handlers_test.go b/cmd/bucket-cors-handlers_test.go new file mode 100644 index 0000000000000..f442c2b79f989 --- /dev/null +++ b/cmd/bucket-cors-handlers_test.go @@ -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 . + +package cmd + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/minio/minio/internal/auth" +) + +const testCORSDoc = `http://example.comGETPUTETag3000` + +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("")), bytes.NewReader([]byte("")), 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) + } +} diff --git a/cmd/bucket-cors-middleware_test.go b/cmd/bucket-cors-middleware_test.go new file mode 100644 index 0000000000000..d294ed811d027 --- /dev/null +++ b/cmd/bucket-cors-middleware_test.go @@ -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 . + +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) + } +} diff --git a/cmd/bucket-metadata-sys.go b/cmd/bucket-metadata-sys.go index 20be4ffd3e36a..cb442ca5eaad4 100644 --- a/cmd/bucket-metadata-sys.go +++ b/cmd/bucket-metadata-sys.go @@ -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) diff --git a/cmd/bucket-metadata.go b/cmd/bucket-metadata.go index e78118175e58b..556510b61fd3e 100644 --- a/cmd/bucket-metadata.go +++ b/cmd/bucket-metadata.go @@ -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(`Enabled`) ) +// 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 } diff --git a/cmd/bucket-metadata_gen.go b/cmd/bucket-metadata_gen.go index 0407b66ea8db8..b074b3e1302b0 100644 --- a/cmd/bucket-metadata_gen.go +++ b/cmd/bucket-metadata_gen.go @@ -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 } diff --git a/cmd/bucket-metadata_test.go b/cmd/bucket-metadata_test.go new file mode 100644 index 0000000000000..70447bcb28092 --- /dev/null +++ b/cmd/bucket-metadata_test.go @@ -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 . + +package cmd + +import "testing" + +func TestBucketMetadataCorsRoundTrip(t *testing.T) { + meta := newBucketMetadata("test-cors") + meta.CorsConfigXML = []byte(`*GET`) + 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") + } +} diff --git a/cmd/dummy-handlers.go b/cmd/dummy-handlers.go index 685b792564a55..1781c228f4cf4 100644 --- a/cmd/dummy-handlers.go +++ b/cmd/dummy-handlers.go @@ -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) -} diff --git a/cmd/site-replication.go b/cmd/site-replication.go index a166ae2adddf9..76c880a692fac 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 397bb9f99228b..6f4064b04fba6 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/cmd/test-utils_test.go b/cmd/test-utils_test.go index 0f903625c3244..6d0e7bf64d906 100644 --- a/cmd/test-utils_test.go +++ b/cmd/test-utils_test.go @@ -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", "") } } } diff --git a/internal/bucket/cors/cors.go b/internal/bucket/cors/cors.go new file mode 100644 index 0000000000000..daf3db202bac7 --- /dev/null +++ b/internal/bucket/cors/cors.go @@ -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 . + +// 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 (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 document. +type Config struct { + XMLName xml.Name `xml:"CORSConfiguration"` + CORSRules []Rule `xml:"CORSRule"` +} + +// Rule is a single . +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 +} diff --git a/internal/bucket/cors/cors_test.go b/internal/bucket/cors/cors_test.go new file mode 100644 index 0000000000000..dd6ce995ef004 --- /dev/null +++ b/internal/bucket/cors/cors_test.go @@ -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 . + +package cors + +import ( + "strings" + "testing" +) + +const sampleCORS = ` + + rule1 + http://www.example.com + https://*.example.org + GET + PUT + x-amz-* + ETag + 3000 + +` + +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": `*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)) + 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 = ` + + A-restrictive + https://app.example.com + GET + x-amz-date + + + B-permissive + https://app.example.com + GET + * + +` + + 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) + } +}