From d80dc8b8264d3391e255f1a1af529bdbe0d0e9cd Mon Sep 17 00:00:00 2001 From: Mark Theunissen Date: Wed, 31 Jul 2024 16:35:48 +1000 Subject: [PATCH] API methods for bucket-level CORS configuration settings (#1987) --- api-bucket-cors.go | 136 ++++++ examples/s3/putbucketcors.go | 76 +++ functional_tests.go | 860 +++++++++++++++++++++++++++++++++- pkg/cors/cors.go | 91 ++++ pkg/cors/cors_test.go | 43 ++ pkg/cors/testdata/example.xml | 2 + s3-error.go | 1 + 7 files changed, 1195 insertions(+), 14 deletions(-) create mode 100644 api-bucket-cors.go create mode 100644 examples/s3/putbucketcors.go create mode 100644 pkg/cors/cors.go create mode 100644 pkg/cors/cors_test.go create mode 100644 pkg/cors/testdata/example.xml diff --git a/api-bucket-cors.go b/api-bucket-cors.go new file mode 100644 index 000000000..8bf537f73 --- /dev/null +++ b/api-bucket-cors.go @@ -0,0 +1,136 @@ +/* + * MinIO Go Library for Amazon S3 Compatible Cloud Storage + * Copyright 2024 MinIO, Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package minio + +import ( + "bytes" + "context" + "net/http" + "net/url" + + "github.com/minio/minio-go/v7/pkg/cors" + "github.com/minio/minio-go/v7/pkg/s3utils" +) + +// SetBucketCors sets the cors configuration for the bucket +func (c *Client) SetBucketCors(ctx context.Context, bucketName string, corsConfig *cors.Config) error { + if err := s3utils.CheckValidBucketName(bucketName); err != nil { + return err + } + + if corsConfig == nil { + return c.removeBucketCors(ctx, bucketName) + } + + return c.putBucketCors(ctx, bucketName, corsConfig) +} + +func (c *Client) putBucketCors(ctx context.Context, bucketName string, corsConfig *cors.Config) error { + urlValues := make(url.Values) + urlValues.Set("cors", "") + + corsStr, err := corsConfig.ToXML() + if err != nil { + return err + } + + reqMetadata := requestMetadata{ + bucketName: bucketName, + queryValues: urlValues, + contentBody: bytes.NewReader(corsStr), + contentLength: int64(len(corsStr)), + contentMD5Base64: sumMD5Base64([]byte(corsStr)), + } + + resp, err := c.executeMethod(ctx, http.MethodPut, reqMetadata) + defer closeResponse(resp) + if err != nil { + return err + } + if resp != nil { + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return httpRespToErrorResponse(resp, bucketName, "") + } + } + return nil +} + +func (c *Client) removeBucketCors(ctx context.Context, bucketName string) error { + urlValues := make(url.Values) + urlValues.Set("cors", "") + + resp, err := c.executeMethod(ctx, http.MethodDelete, requestMetadata{ + bucketName: bucketName, + queryValues: urlValues, + contentSHA256Hex: emptySHA256Hex, + }) + defer closeResponse(resp) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusNoContent { + return httpRespToErrorResponse(resp, bucketName, "") + } + + return nil +} + +// GetBucketCors returns the current cors +func (c *Client) GetBucketCors(ctx context.Context, bucketName string) (*cors.Config, error) { + if err := s3utils.CheckValidBucketName(bucketName); err != nil { + return nil, err + } + bucketCors, err := c.getBucketCors(ctx, bucketName) + if err != nil { + errResponse := ToErrorResponse(err) + if errResponse.Code == "NoSuchCORSConfiguration" { + return nil, nil + } + return nil, err + } + return bucketCors, nil +} + +func (c *Client) getBucketCors(ctx context.Context, bucketName string) (*cors.Config, error) { + urlValues := make(url.Values) + urlValues.Set("cors", "") + + resp, err := c.executeMethod(ctx, http.MethodGet, requestMetadata{ + bucketName: bucketName, + queryValues: urlValues, + contentSHA256Hex: emptySHA256Hex, // TODO: needed? copied over from other example, but not spec'd in API. + }) + + defer closeResponse(resp) + if err != nil { + return nil, err + } + + if resp != nil { + if resp.StatusCode != http.StatusOK { + return nil, httpRespToErrorResponse(resp, bucketName, "") + } + } + + corsConfig, err := cors.ParseBucketCorsConfig(resp.Body) + if err != nil { + return nil, err + } + + return corsConfig, nil +} diff --git a/examples/s3/putbucketcors.go b/examples/s3/putbucketcors.go new file mode 100644 index 000000000..2dfa549d8 --- /dev/null +++ b/examples/s3/putbucketcors.go @@ -0,0 +1,76 @@ +//go:build example +// +build example + +/* + * MinIO Go Library for Amazon S3 Compatible Cloud Storage + * Copyright 2015-2024 MinIO, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "context" + "fmt" + "log" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/cors" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +func main() { + // Note: YOUR-ACCESSKEYID, YOUR-SECRETACCESSKEY, my-bucketname and my-prefixname + // are dummy values, please replace them with original values. + + // Requests are always secure (HTTPS) by default. Set secure=false to enable insecure (HTTP) access. + // This boolean value is the last argument for New(). + + // New returns an Amazon S3 compatible client object. API compatibility (v2 or v4) is automatically + // determined based on the Endpoint value. + s3Client, err := minio.New("s3.amazonaws.com", &minio.Options{ + Creds: credentials.NewStaticV4("YOUR-ACCESSKEYID", "YOUR-SECRETACCESSKEY", ""), + Secure: true, + }) + if err != nil { + log.Fatalln(err) + } + bucket := "my-bucket-name" + + corsRules := []cors.Rule{ + { + AllowedHeader: []string{"*"}, + AllowedMethod: []string{"GET", "PUT"}, + AllowedOrigin: []string{"https://example.com"}, + }, + } + corsConfig := cors.NewConfig(corsRules) + + err = s3Client.SetBucketCors(context.Background(), bucket, corsConfig) + if err != nil { + log.Fatalln(fmt.Errorf("Error setting bucket cors: %v", err)) + } + + retCors, err := s3Client.GetBucketCors(context.Background(), bucket) + if err != nil { + log.Fatalln(fmt.Errorf("Error getting bucket cors: %v", err)) + } + + fmt.Printf("Returned Bucket CORS configuration: %+v\n", retCors) + + err = s3Client.SetBucketCors(context.Background(), bucket, nil) + if err != nil { + log.Fatalln(fmt.Errorf("Error removing bucket cors: %v", err)) + } +} diff --git a/functional_tests.go b/functional_tests.go index 405396f91..871034bc7 100644 --- a/functional_tests.go +++ b/functional_tests.go @@ -52,6 +52,7 @@ import ( "github.com/google/uuid" "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/cors" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/minio/minio-go/v7/pkg/encrypt" "github.com/minio/minio-go/v7/pkg/notification" @@ -2972,7 +2973,6 @@ func testGetObjectAttributes() { ContentType: v.ContentType, SendContentMd5: v.SendContentMd5, }) - if err != nil { logError(testName, function, args, startTime, "", "PutObject failed", err) return @@ -7212,7 +7212,6 @@ func testFunctional() { "bucketName": bucketName, } exists, err = c.BucketExists(context.Background(), bucketName) - if err != nil { logError(testName, function, args, startTime, "", "BucketExists failed", err) return @@ -7275,7 +7274,6 @@ func testFunctional() { "bucketPolicy": writeOnlyPolicy, } err = c.SetBucketPolicy(context.Background(), bucketName, writeOnlyPolicy) - if err != nil { logError(testName, function, args, startTime, "", "SetBucketPolicy failed", err) return @@ -7304,7 +7302,6 @@ func testFunctional() { "bucketPolicy": readWritePolicy, } err = c.SetBucketPolicy(context.Background(), bucketName, readWritePolicy) - if err != nil { logError(testName, function, args, startTime, "", "SetBucketPolicy failed", err) return @@ -7481,7 +7478,6 @@ func testFunctional() { "fileName": fileName + "-f", } err = c.FGetObject(context.Background(), bucketName, objectName, fileName+"-f", minio.GetObjectOptions{}) - if err != nil { logError(testName, function, args, startTime, "", "FGetObject failed", err) return @@ -7613,7 +7609,6 @@ func testFunctional() { "reqParams": reqParams, } presignedGetURL, err = c.PresignedGetObject(context.Background(), bucketName, objectName, 3600*time.Second, reqParams) - if err != nil { logError(testName, function, args, startTime, "", "PresignedGetObject failed", err) return @@ -7770,14 +7765,12 @@ func testFunctional() { "objectName": objectName, } err = c.RemoveObject(context.Background(), bucketName, objectName, minio.RemoveObjectOptions{}) - if err != nil { logError(testName, function, args, startTime, "", "RemoveObject failed", err) return } args["objectName"] = objectName + "-f" err = c.RemoveObject(context.Background(), bucketName, objectName+"-f", minio.RemoveObjectOptions{}) - if err != nil { logError(testName, function, args, startTime, "", "RemoveObject failed", err) return @@ -7785,7 +7778,6 @@ func testFunctional() { args["objectName"] = objectName + "-nolength" err = c.RemoveObject(context.Background(), bucketName, objectName+"-nolength", minio.RemoveObjectOptions{}) - if err != nil { logError(testName, function, args, startTime, "", "RemoveObject failed", err) return @@ -7793,7 +7785,6 @@ func testFunctional() { args["objectName"] = objectName + "-presigned" err = c.RemoveObject(context.Background(), bucketName, objectName+"-presigned", minio.RemoveObjectOptions{}) - if err != nil { logError(testName, function, args, startTime, "", "RemoveObject failed", err) return @@ -7801,7 +7792,6 @@ func testFunctional() { args["objectName"] = objectName + "-presign-custom" err = c.RemoveObject(context.Background(), bucketName, objectName+"-presign-custom", minio.RemoveObjectOptions{}) - if err != nil { logError(testName, function, args, startTime, "", "RemoveObject failed", err) return @@ -7813,7 +7803,6 @@ func testFunctional() { "bucketName": bucketName, } err = c.RemoveBucket(context.Background(), bucketName) - if err != nil { logError(testName, function, args, startTime, "", "RemoveBucket failed", err) return @@ -12281,7 +12270,6 @@ func testFunctionalV2() { "bucketPolicy": readWritePolicy, } err = c.SetBucketPolicy(context.Background(), bucketName, readWritePolicy) - if err != nil { logError(testName, function, args, startTime, "", "SetBucketPolicy failed", err) return @@ -13012,7 +13000,6 @@ func testGetObjectACLContext() { ContentType: "binary/octet-stream", UserMetadata: metaData, }) - if err != nil { logError(testName, function, args, startTime, "", "PutObject failed", err) return @@ -13491,6 +13478,849 @@ func testListObjects() { logSuccess(testName, function, args, startTime) } +// testCors is runnable against S3 itself. +// Just provide the env var MINIO_GO_TEST_BUCKET_CORS with bucket that is public and WILL BE DELETED. +// Recreate this manually each time. Minio-go SDK does not support calling +// SetPublicBucket (put-public-access-block) on S3, otherwise we could script the whole thing. +func testCors() { + ctx := context.Background() + startTime := time.Now() + testName := getFuncName() + function := "SetBucketCors(bucketName, cors)" + args := map[string]interface{}{ + "bucketName": "", + "cors": "", + } + + // Instantiate new minio client object + c, err := minio.New(os.Getenv(serverEndpoint), + &minio.Options{ + Creds: credentials.NewStaticV4(os.Getenv(accessKey), os.Getenv(secretKey), ""), + Transport: createHTTPTransport(), + Secure: mustParseBool(os.Getenv(enableHTTPS)), + }) + if err != nil { + logFailure(testName, function, args, startTime, "", "MinIO client object creation failed", err) + return + } + + // Enable tracing, write to stderr. + // c.TraceOn(os.Stderr) + + // Set user agent. + c.SetAppInfo("MinIO-go-FunctionalTest", appVersion) + + // Create or reuse a bucket that will get cors settings applied to it and deleted when done + bucketName := os.Getenv("MINIO_GO_TEST_BUCKET_CORS") + if bucketName == "" { + bucketName = randString(60, rand.NewSource(time.Now().UnixNano()), "minio-go-test-") + err = c.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{Region: "us-east-1"}) + if err != nil { + logFailure(testName, function, args, startTime, "", "MakeBucket failed", err) + return + } + } + args["bucketName"] = bucketName + defer cleanupBucket(bucketName, c) + + publicPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:*"],"Resource":["arn:aws:s3:::` + bucketName + `", "arn:aws:s3:::` + bucketName + `/*"]}]}` + err = c.SetBucketPolicy(ctx, bucketName, publicPolicy) + if err != nil { + logFailure(testName, function, args, startTime, "", "SetBucketPolicy failed", err) + return + } + + // Upload an object for testing. + objectContents := `some-text-file-contents` + reader := strings.NewReader(objectContents) + bufSize := int64(len(objectContents)) + + objectName := randString(60, rand.NewSource(time.Now().UnixNano()), "") + args["objectName"] = objectName + + _, err = c.PutObject(ctx, bucketName, objectName, reader, int64(bufSize), minio.PutObjectOptions{ContentType: "binary/octet-stream"}) + if err != nil { + logFailure(testName, function, args, startTime, "", "PutObject call failed", err) + return + } + bucketURL := c.EndpointURL().String() + "/" + bucketName + "/" + objectURL := bucketURL + objectName + + transport, err := minio.DefaultTransport(mustParseBool(os.Getenv(enableHTTPS))) + if err != nil { + logFailure(testName, function, args, startTime, "", "DefaultTransport failed", err) + return + } + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: transport, + } + + errStrAccessForbidden := `AccessForbiddenCORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Request-Headers are not whitelisted` + testCases := []struct { + name string + + // Cors rules to apply + applyCorsRules []cors.Rule + + // Outbound request info + method string + url string + headers map[string]string + + // Wanted response + wantStatus int + wantHeaders map[string]string + wantBodyContains string + }{ + { + name: "apply bucket rules", + applyCorsRules: []cors.Rule{ + { + AllowedOrigin: []string{"https"}, // S3 documents 'https' origin, but it does not actually work, see test below. + AllowedMethod: []string{"PUT"}, + AllowedHeader: []string{"*"}, + }, + { + AllowedOrigin: []string{"http://www.example1.com"}, + AllowedMethod: []string{"PUT"}, + AllowedHeader: []string{"*"}, + ExposeHeader: []string{"x-amz-server-side-encryption", "x-amz-request-id"}, + MaxAgeSeconds: 3600, + }, + { + AllowedOrigin: []string{"http://www.example2.com"}, + AllowedMethod: []string{"POST"}, + AllowedHeader: []string{"X-My-Special-Header"}, + ExposeHeader: []string{"X-AMZ-Request-ID"}, + }, + { + AllowedOrigin: []string{"http://www.example3.com"}, + AllowedMethod: []string{"PUT"}, + AllowedHeader: []string{"X-Example-3-Special-Header"}, + MaxAgeSeconds: 10, + }, + { + AllowedOrigin: []string{"*"}, + AllowedMethod: []string{"GET"}, + AllowedHeader: []string{"*"}, + ExposeHeader: []string{"x-amz-request-id", "X-AMZ-server-side-encryption"}, + MaxAgeSeconds: 3600, + }, + { + AllowedOrigin: []string{"http://multiplemethodstest.com"}, + AllowedMethod: []string{"POST", "PUT", "DELETE"}, + AllowedHeader: []string{"x-abc-*", "x-def-*"}, + }, + { + AllowedOrigin: []string{"http://UPPERCASEEXAMPLE.com"}, + AllowedMethod: []string{"DELETE"}, + }, + { + AllowedOrigin: []string{"https://*"}, + AllowedMethod: []string{"DELETE"}, + AllowedHeader: []string{"x-abc-*", "x-def-*"}, + }, + }, + }, + { + name: "preflight to object url matches example1 rule", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example1.com", + "Access-Control-Request-Method": "PUT", + "Access-Control-Request-Headers": "x-another-header,x-could-be-anything", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Origin": "http://www.example1.com", + "Access-Control-Allow-Methods": "PUT", + "Access-Control-Allow-Headers": "x-another-header,x-could-be-anything", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "3600", + "Content-Length": "0", + // S3 additionally sets the following headers here, MinIO follows fetch spec and does not: + // "Access-Control-Expose-Headers": "", + }, + }, + { + name: "preflight to bucket url matches example1 rule", + method: http.MethodOptions, + url: bucketURL, + headers: map[string]string{ + "Origin": "http://www.example1.com", + "Access-Control-Request-Method": "PUT", + "Access-Control-Request-Headers": "x-another-header,x-could-be-anything", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Origin": "http://www.example1.com", + "Access-Control-Allow-Methods": "PUT", + "Access-Control-Allow-Headers": "x-another-header,x-could-be-anything", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "3600", + "Content-Length": "0", + }, + }, + { + name: "preflight matches example2 rule with header given", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example2.com", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "X-My-Special-Header", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Origin": "http://www.example2.com", + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Headers": "x-my-special-header", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "", + "Content-Length": "0", + }, + }, + { + name: "preflight matches example2 rule with no header given", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example2.com", + "Access-Control-Request-Method": "POST", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Origin": "http://www.example2.com", + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Headers": "", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "", + "Content-Length": "0", + }, + }, + { + name: "preflight matches wildcard origin rule", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.couldbeanything.com", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "x-custom-header,x-other-custom-header", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Headers": "x-custom-header,x-other-custom-header", + "Access-Control-Allow-Credentials": "", + "Access-Control-Max-Age": "3600", + "Content-Length": "0", + }, + }, + { + name: "preflight does not match any rule", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.couldbeanything.com", + "Access-Control-Request-Method": "DELETE", + }, + wantStatus: http.StatusForbidden, + wantBodyContains: errStrAccessForbidden, + }, + { + name: "preflight does not match example1 rule because of method", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example1.com", + "Access-Control-Request-Method": "POST", + }, + wantStatus: http.StatusForbidden, + wantBodyContains: errStrAccessForbidden, + }, + { + name: "s3 processes cors rules even when request is not preflight if cors headers present test get", + method: http.MethodGet, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example1.com", + "Access-Control-Request-Headers": "x-another-header,x-could-be-anything", + "Access-Control-Request-Method": "PUT", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "http://www.example1.com", + "Access-Control-Expose-Headers": "x-amz-server-side-encryption,x-amz-request-id", + // S3 additionally sets the following headers here, MinIO follows fetch spec and does not: + // "Access-Control-Allow-Headers": "x-another-header,x-could-be-anything", + // "Access-Control-Allow-Methods": "PUT", + // "Access-Control-Max-Age": "3600", + }, + }, + { + name: "s3 processes cors rules even when request is not preflight if cors headers present test put", + method: http.MethodPut, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example1.com", + "Access-Control-Request-Method": "GET", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "", + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "x-amz-request-id,x-amz-server-side-encryption", + // S3 additionally sets the following headers here, MinIO follows fetch spec and does not: + // "Access-Control-Allow-Headers": "x-another-header,x-could-be-anything", + // "Access-Control-Allow-Methods": "PUT", + // "Access-Control-Max-Age": "3600", + }, + }, + { + name: "s3 processes cors rules even when request is not preflight but there is no rule match", + method: http.MethodGet, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example1.com", + "Access-Control-Request-Headers": "x-another-header,x-could-be-anything", + "Access-Control-Request-Method": "DELETE", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Methods": "", + "Access-Control-Allow-Origin": "", + "Access-Control-Allow-Headers": "", + "Access-Control-Allow-Credentials": "", + "Access-Control-Expose-Headers": "", + "Access-Control-Max-Age": "", + }, + }, + { + name: "get request matches wildcard origin rule and returns cors headers", + method: http.MethodGet, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example1.com", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "", + "Access-Control-Expose-Headers": "x-amz-request-id,X-AMZ-server-side-encryption", + // S3 returns the following headers, MinIO follows fetch spec and does not: + // "Access-Control-Max-Age": "3600", + // "Access-Control-Allow-Methods": "GET", + }, + }, + { + name: "head request does not match rule and returns no cors headers", + method: http.MethodHead, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.nomatchingdomainfound.com", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "", + "Access-Control-Allow-Methods": "", + "Access-Control-Allow-Origin": "", + "Access-Control-Allow-Headers": "", + "Access-Control-Expose-Headers": "", + "Access-Control-Max-Age": "", + }, + }, + { + name: "put request with origin does not match rule and returns no cors headers", + method: http.MethodPut, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.nomatchingdomainfound.com", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "", + "Access-Control-Allow-Methods": "", + "Access-Control-Allow-Origin": "", + "Access-Control-Allow-Headers": "", + "Access-Control-Expose-Headers": "", + "Access-Control-Max-Age": "", + }, + }, + { + name: "put request with no origin does not match rule and returns no cors headers", + method: http.MethodPut, + url: objectURL, + headers: map[string]string{}, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "", + "Access-Control-Allow-Methods": "", + "Access-Control-Allow-Origin": "", + "Access-Control-Allow-Headers": "", + "Access-Control-Expose-Headers": "", + "Access-Control-Max-Age": "", + }, + }, + { + name: "preflight for delete request with wildcard origin does not match", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.notsecureexample.com", + "Access-Control-Request-Method": "DELETE", + }, + wantStatus: http.StatusForbidden, + wantBodyContains: errStrAccessForbidden, + }, + { + name: "preflight for delete request with wildcard https origin matches secureexample", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "https://www.secureexample.com", + "Access-Control-Request-Method": "DELETE", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "DELETE", + "Access-Control-Allow-Origin": "https://www.secureexample.com", + "Access-Control-Allow-Headers": "", + "Access-Control-Expose-Headers": "", + "Access-Control-Max-Age": "", + }, + }, + { + name: "preflight for delete request matches secureexample with wildcard https origin and request headers", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "https://www.secureexample.com", + "Access-Control-Request-Method": "DELETE", + "Access-Control-Request-Headers": "x-abc-1,x-abc-second,x-def-1", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "DELETE", + "Access-Control-Allow-Origin": "https://www.secureexample.com", + "Access-Control-Allow-Headers": "x-abc-1,x-abc-second,x-def-1", + "Access-Control-Expose-Headers": "", + "Access-Control-Max-Age": "", + }, + }, + { + name: "preflight for delete request matches secureexample rejected because request header does not match", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "https://www.secureexample.com", + "Access-Control-Request-Method": "DELETE", + "Access-Control-Request-Headers": "x-abc-1,x-abc-second,x-def-1,x-does-not-match", + }, + wantStatus: http.StatusForbidden, + wantBodyContains: errStrAccessForbidden, + }, + { + name: "preflight with https origin is documented by s3 as matching but it does not match", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "https://www.securebutdoesnotmatch.com", + "Access-Control-Request-Method": "PUT", + }, + wantStatus: http.StatusForbidden, + wantBodyContains: errStrAccessForbidden, + }, + { + name: "put no origin no match returns no cors headers", + method: http.MethodPut, + url: objectURL, + headers: map[string]string{}, + wantStatus: http.StatusOK, + + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "", + "Access-Control-Allow-Methods": "", + "Access-Control-Allow-Origin": "", + "Access-Control-Allow-Headers": "", + "Access-Control-Expose-Headers": "", + "Access-Control-Max-Age": "", + }, + }, + { + name: "put with origin match example1 returns cors headers", + method: http.MethodPut, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example1.com", + }, + wantStatus: http.StatusOK, + + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "http://www.example1.com", + "Access-Control-Allow-Headers": "", + "Access-Control-Expose-Headers": "x-amz-server-side-encryption,x-amz-request-id", + // S3 returns the following headers, MinIO follows fetch spec and does not: + // "Access-Control-Max-Age": "3600", + // "Access-Control-Allow-Methods": "PUT", + }, + }, + { + name: "put with origin and header match example1 returns cors headers", + method: http.MethodPut, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example1.com", + "x-could-be-anything": "myvalue", + }, + wantStatus: http.StatusOK, + + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "http://www.example1.com", + "Access-Control-Allow-Headers": "", + "Access-Control-Expose-Headers": "x-amz-server-side-encryption,x-amz-request-id", + // S3 returns the following headers, MinIO follows fetch spec and does not: + // "Access-Control-Max-Age": "3600", + // "Access-Control-Allow-Methods": "PUT", + }, + }, + { + name: "put no match found returns no cors headers", + method: http.MethodPut, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.unmatchingdomain.com", + }, + wantStatus: http.StatusOK, + + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "", + "Access-Control-Allow-Methods": "", + "Access-Control-Allow-Origin": "", + "Access-Control-Allow-Headers": "", + "Access-Control-Expose-Headers": "", + "Access-Control-Max-Age": "", + }, + }, + { + name: "put with origin match example3 returns cors headers", + method: http.MethodPut, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example3.com", + "X-My-Special-Header": "myvalue", + }, + wantStatus: http.StatusOK, + + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "http://www.example3.com", + "Access-Control-Allow-Headers": "", + "Access-Control-Expose-Headers": "", + // S3 returns the following headers, MinIO follows fetch spec and does not: + // "Access-Control-Max-Age": "10", + // "Access-Control-Allow-Methods": "PUT", + }, + }, + { + name: "preflight matches example1 rule headers case is incorrect", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example1.com", + "Access-Control-Request-Method": "PUT", + // Fetch standard guarantees that these are sent lowercase, here we test what happens when they are not. + "Access-Control-Request-Headers": "X-Another-Header,X-Could-Be-Anything", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Origin": "http://www.example1.com", + "Access-Control-Allow-Methods": "PUT", + "Access-Control-Allow-Headers": "x-another-header,x-could-be-anything", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "3600", + "Content-Length": "0", + // S3 returns the following headers, MinIO follows fetch spec and does not: + // "Access-Control-Expose-Headers": "x-amz-server-side-encryption,x-amz-request-id", + }, + }, + { + name: "preflight matches example1 rule headers are not sorted", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.example1.com", + "Access-Control-Request-Method": "PUT", + // Fetch standard guarantees that these are sorted, test what happens when they are not. + "Access-Control-Request-Headers": "a-customer-header,b-should-be-last", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Origin": "http://www.example1.com", + "Access-Control-Allow-Methods": "PUT", + "Access-Control-Allow-Headers": "a-customer-header,b-should-be-last", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "3600", + "Content-Length": "0", + // S3 returns the following headers, MinIO follows fetch spec and does not: + // "Access-Control-Expose-Headers": "x-amz-server-side-encryption,x-amz-request-id", + }, + }, + { + name: "preflight with case sensitivity in origin matches uppercase", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://UPPERCASEEXAMPLE.com", + "Access-Control-Request-Method": "DELETE", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "DELETE", + "Access-Control-Allow-Origin": "http://UPPERCASEEXAMPLE.com", + "Access-Control-Allow-Headers": "", + "Access-Control-Expose-Headers": "", + "Access-Control-Max-Age": "", + }, + }, + { + name: "preflight with case sensitivity in origin does not match when lowercase", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://uppercaseexample.com", + "Access-Control-Request-Method": "DELETE", + }, + wantStatus: http.StatusForbidden, + wantBodyContains: errStrAccessForbidden, + }, + { + name: "preflight match upper case with unknown header but no header restrictions", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://UPPERCASEEXAMPLE.com", + "Access-Control-Request-Method": "DELETE", + "Access-Control-Request-Headers": "x-unknown-1", + }, + wantStatus: http.StatusForbidden, + wantBodyContains: errStrAccessForbidden, + }, + { + name: "preflight for delete request matches multiplemethodstest.com origin and request headers", + method: http.MethodOptions, + url: objectURL, + headers: map[string]string{ + "Origin": "http://multiplemethodstest.com", + "Access-Control-Request-Method": "DELETE", + "Access-Control-Request-Headers": "x-abc-1", + }, + wantStatus: http.StatusOK, + wantHeaders: map[string]string{ + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "http://multiplemethodstest.com", + "Access-Control-Allow-Headers": "x-abc-1", + "Access-Control-Expose-Headers": "", + "Access-Control-Max-Age": "", + // S3 returns POST, PUT, DELETE here, MinIO does not as spec does not require it. + // "Access-Control-Allow-Methods": "DELETE", + }, + }, + { + name: "delete request goes ahead because cors is only for browsers and does not block on the server side", + method: http.MethodDelete, + url: objectURL, + headers: map[string]string{ + "Origin": "http://www.justrandom.com", + }, + wantStatus: http.StatusNoContent, + }, + } + + for i, test := range testCases { + testName := fmt.Sprintf("%s_%d_%s", testName, i+1, strings.ReplaceAll(test.name, " ", "_")) + + // Apply the CORS rules + if test.applyCorsRules != nil { + corsConfig := &cors.Config{ + CORSRules: test.applyCorsRules, + } + err = c.SetBucketCors(ctx, bucketName, corsConfig) + if err != nil { + logFailure(testName, function, args, startTime, "", "SetBucketCors failed to apply", err) + return + } + } + + // Make request + if test.method != "" && test.url != "" { + req, err := http.NewRequestWithContext(ctx, test.method, test.url, nil) + if err != nil { + logFailure(testName, function, args, startTime, "", "HTTP request creation failed", err) + return + } + req.Header.Set("User-Agent", "MinIO-go-FunctionalTest/"+appVersion) + + for k, v := range test.headers { + req.Header.Set(k, v) + } + resp, err := httpClient.Do(req) + if err != nil { + logFailure(testName, function, args, startTime, "", "HTTP request failed", err) + return + } + defer resp.Body.Close() + + // Check returned status code + if resp.StatusCode != test.wantStatus { + errStr := fmt.Sprintf(" incorrect status code in response, want: %d, got: %d", test.wantStatus, resp.StatusCode) + logFailure(testName, function, args, startTime, "", errStr, nil) + return + } + + // Check returned body + if test.wantBodyContains != "" { + body, err := io.ReadAll(resp.Body) + if err != nil { + logFailure(testName, function, args, startTime, "", "Failed to read response body", err) + return + } + if !strings.Contains(string(body), test.wantBodyContains) { + errStr := fmt.Sprintf(" incorrect body in response, want: %s, in got: %s", test.wantBodyContains, string(body)) + logFailure(testName, function, args, startTime, "", errStr, nil) + return + } + } + + // Check returned response headers + for k, v := range test.wantHeaders { + gotVal := resp.Header.Get(k) + if k == "Access-Control-Expose-Headers" { + // MinIO returns this in canonical form, S3 does not. + gotVal = strings.ToLower(gotVal) + v = strings.ToLower(v) + } + // Remove all spaces, S3 adds spaces after CSV values in headers, MinIO does not. + gotVal = strings.ReplaceAll(gotVal, " ", "") + if gotVal != v { + errStr := fmt.Sprintf(" incorrect header in response, want: %s: '%s', got: '%s'", k, v, gotVal) + logFailure(testName, function, args, startTime, "", errStr, nil) + return + } + } + } + logSuccess(testName, function, args, startTime) + } + logSuccess(testName, function, args, startTime) +} + +func testCorsSetGetDelete() { + ctx := context.Background() + startTime := time.Now() + testName := getFuncName() + function := "SetBucketCors(bucketName, cors)" + args := map[string]interface{}{ + "bucketName": "", + "cors": "", + } + + // Instantiate new minio client object + c, err := minio.New(os.Getenv(serverEndpoint), + &minio.Options{ + Creds: credentials.NewStaticV4(os.Getenv(accessKey), os.Getenv(secretKey), ""), + Transport: createHTTPTransport(), + Secure: mustParseBool(os.Getenv(enableHTTPS)), + }) + if err != nil { + logFailure(testName, function, args, startTime, "", "MinIO client object creation failed", err) + return + } + + // Enable tracing, write to stderr. + // c.TraceOn(os.Stderr) + + // Set user agent. + c.SetAppInfo("MinIO-go-FunctionalTest", appVersion) + + // Generate a new random bucket name. + bucketName := randString(60, rand.NewSource(time.Now().UnixNano()), "minio-go-test-") + args["bucketName"] = bucketName + + // Make a new bucket. + err = c.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{Region: "us-east-1"}) + if err != nil { + logFailure(testName, function, args, startTime, "", "MakeBucket failed", err) + return + } + defer cleanupBucket(bucketName, c) + + // Set the CORS rules on the new bucket + corsRules := []cors.Rule{ + { + AllowedOrigin: []string{"http://www.example1.com"}, + AllowedMethod: []string{"PUT"}, + AllowedHeader: []string{"*"}, + }, + { + AllowedOrigin: []string{"http://www.example2.com"}, + AllowedMethod: []string{"POST"}, + AllowedHeader: []string{"X-My-Special-Header"}, + }, + { + AllowedOrigin: []string{"*"}, + AllowedMethod: []string{"GET"}, + AllowedHeader: []string{"*"}, + }, + } + corsConfig := cors.NewConfig(corsRules) + err = c.SetBucketCors(ctx, bucketName, corsConfig) + if err != nil { + logFailure(testName, function, args, startTime, "", "SetBucketCors failed to apply", err) + return + } + + // Get the rules and check they match what we set + gotCorsConfig, err := c.GetBucketCors(ctx, bucketName) + if err != nil { + logFailure(testName, function, args, startTime, "", "GetBucketCors failed", err) + return + } + if !reflect.DeepEqual(corsConfig, gotCorsConfig) { + msg := fmt.Sprintf("GetBucketCors returned unexpected rules, expected: %+v, got: %+v", corsConfig, gotCorsConfig) + logFailure(testName, function, args, startTime, "", msg, nil) + return + } + + // Delete the rules + err = c.SetBucketCors(ctx, bucketName, nil) + if err != nil { + logFailure(testName, function, args, startTime, "", "SetBucketCors failed to delete", err) + return + } + + // Get the rules and check they are now empty + gotCorsConfig, err = c.GetBucketCors(ctx, bucketName) + if err != nil { + logFailure(testName, function, args, startTime, "", "GetBucketCors failed", err) + return + } + if gotCorsConfig != nil { + logFailure(testName, function, args, startTime, "", "GetBucketCors returned unexpected rules", nil) + return + } + + logSuccess(testName, function, args, startTime) +} + // Test deleting multiple objects with object retention set in Governance mode func testRemoveObjects() { // initialize logging params @@ -13899,6 +14729,8 @@ func main() { // execute tests if isFullMode() { + testCorsSetGetDelete() + testCors() testListMultipartUpload() testGetObjectAttributes() testGetObjectAttributesErrorCases() diff --git a/pkg/cors/cors.go b/pkg/cors/cors.go new file mode 100644 index 000000000..e71864ee9 --- /dev/null +++ b/pkg/cors/cors.go @@ -0,0 +1,91 @@ +/* + * MinIO Go Library for Amazon S3 Compatible Cloud Storage + * Copyright 2015-2024 MinIO, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cors + +import ( + "encoding/xml" + "fmt" + "io" + "strings" + + "github.com/dustin/go-humanize" +) + +const defaultXMLNS = "http://s3.amazonaws.com/doc/2006-03-01/" + +// Config is the container for a CORS configuration for a bucket. +type Config struct { + XMLNS string `xml:"xmlns,attr,omitempty"` + XMLName xml.Name `xml:"CORSConfiguration"` + CORSRules []Rule `xml:"CORSRule"` +} + +// Rule is a single rule in a CORS configuration. +type Rule struct { + AllowedHeader []string `xml:"AllowedHeader,omitempty"` + AllowedMethod []string `xml:"AllowedMethod,omitempty"` + AllowedOrigin []string `xml:"AllowedOrigin,omitempty"` + ExposeHeader []string `xml:"ExposeHeader,omitempty"` + ID string `xml:"ID,omitempty"` + MaxAgeSeconds int `xml:"MaxAgeSeconds,omitempty"` +} + +// NewConfig creates a new CORS configuration with the given rules. +func NewConfig(rules []Rule) *Config { + return &Config{ + XMLNS: defaultXMLNS, + XMLName: xml.Name{ + Local: "CORSConfiguration", + Space: defaultXMLNS, + }, + CORSRules: rules, + } +} + +// ParseBucketCorsConfig parses a CORS configuration in XML from an io.Reader. +func ParseBucketCorsConfig(reader io.Reader) (*Config, error) { + var c Config + + // Max size of cors document is 64KiB according to https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html + // This limiter is just for safety so has a max of 128KiB + err := xml.NewDecoder(io.LimitReader(reader, 128*humanize.KiByte)).Decode(&c) + if err != nil { + return nil, fmt.Errorf("decoding xml: %w", err) + } + if c.XMLNS == "" { + c.XMLNS = defaultXMLNS + } + for i, rule := range c.CORSRules { + for j, method := range rule.AllowedMethod { + c.CORSRules[i].AllowedMethod[j] = strings.ToUpper(method) + } + } + return &c, nil +} + +// ToXML marshals the CORS configuration to XML. +func (c Config) ToXML() ([]byte, error) { + if c.XMLNS == "" { + c.XMLNS = defaultXMLNS + } + data, err := xml.Marshal(&c) + if err != nil { + return nil, fmt.Errorf("marshaling xml: %w", err) + } + return append([]byte(xml.Header), data...), nil +} diff --git a/pkg/cors/cors_test.go b/pkg/cors/cors_test.go new file mode 100644 index 000000000..453a98897 --- /dev/null +++ b/pkg/cors/cors_test.go @@ -0,0 +1,43 @@ +/* + * MinIO Go Library for Amazon S3 Compatible Cloud Storage + * Copyright 2015-2024 MinIO, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cors + +import ( + "bytes" + "os" + "testing" +) + +func TestCORSXMLMarshal(t *testing.T) { + fileContents, err := os.ReadFile("testdata/example.xml") + if err != nil { + t.Fatal(err) + } + c, err := ParseBucketCorsConfig(bytes.NewReader(fileContents)) + if err != nil { + t.Fatal(err) + } + remarshalled, err := c.ToXML() + if err != nil { + t.Fatal(err) + } + trimmedFileContents := bytes.TrimSpace(fileContents) + if !bytes.Equal(trimmedFileContents, remarshalled) { + t.Errorf("got: %s, want: %s", string(remarshalled), string(trimmedFileContents)) + } +} diff --git a/pkg/cors/testdata/example.xml b/pkg/cors/testdata/example.xml new file mode 100644 index 000000000..fb2f33aa8 --- /dev/null +++ b/pkg/cors/testdata/example.xml @@ -0,0 +1,2 @@ + +*PUTPOSTDELETEhttp://www.example1.com*PUTPOSTDELETEhttp://www.example2.*GET*x-amz-id-26000POSThttps://www.example3.com diff --git a/s3-error.go b/s3-error.go index f365157ee..f7fad19f6 100644 --- a/s3-error.go +++ b/s3-error.go @@ -57,5 +57,6 @@ var s3ErrorResponseMap = map[string]string{ "BucketAlreadyOwnedByYou": "Your previous request to create the named bucket succeeded and you already own it.", "InvalidDuration": "Duration provided in the request is invalid.", "XAmzContentSHA256Mismatch": "The provided 'x-amz-content-sha256' header does not match what was computed.", + "NoSuchCORSConfiguration": "The specified bucket does not have a CORS configuration.", // Add new API errors here. }