feat: bucket cors implementation

Closes #1003

**Changes Introduced:**

1. **S3 Bucket CORS Actions**

   * Implemented the following S3 bucket CORS APIs:

     * `PutBucketCors` – Configure CORS rules for a bucket.
     * `GetBucketCors` – Retrieve the current CORS configuration for a bucket.
     * `DeleteBucketCors` – Remove CORS configuration from a bucket.

2. **CORS Preflight Handling**

   * Added an `OPTIONS` endpoint to handle browser preflight requests.
   * The endpoint evaluates incoming requests against bucket CORS rules and returns the appropriate `Access-Control-*` headers.

3. **CORS Middleware**

   * Implemented middleware that:

     * Checks if a bucket has CORS configured.
     * Detects the `Origin` header in the request.
     * Adds the necessary `Access-Control-*` headers to the response when the request matches the bucket CORS configuration.
This commit is contained in:
niksis02
2025-08-20 20:45:09 +04:00
parent 794d01a0ae
commit 09031a30e5
19 changed files with 2654 additions and 26 deletions
+65
View File
@@ -527,6 +527,44 @@ func TestDeleteBucketPolicy(s *S3Conf) {
DeleteBucketPolicy_success(s)
}
func TestPutBucketCors(s *S3Conf) {
PutBucketCors_non_existing_bucket(s)
PutBucketCors_empty_cors_rules(s)
PutBucketCors_invalid_method(s)
PutBucketCors_invalid_header(s)
PutBucketCors_invalid_content_md5(s)
PutBucketCors_incorrect_content_md5(s)
PutBucketCors_success(s)
}
func TestGetBucketCors(s *S3Conf) {
GetBucketCors_non_existing_bucket(s)
GetBucketCors_no_such_bucket_cors(s)
GetBucketCors_success(s)
}
func TestDeleteBucketCors(s *S3Conf) {
DeleteBucketCors_non_existing_bucket(s)
DeleteBucketCors_success(s)
}
func TestPreflightOPTIONSEndpoint(s *S3Conf) {
PreflightOPTIONS_non_existing_bucket(s)
PreflightOPTIONS_missing_origin(s)
PreflightOPTIONS_invalid_request_method(s)
PreflightOPTIONS_invalid_request_headers(s)
PreflightOPTIONS_unset_bucket_cors(s)
PreflightOPTIONS_access_forbidden(s)
PreflightOPTIONS_access_granted(s)
}
func TestCORSMiddleware(s *S3Conf) {
CORSMiddleware_invalid_method(s)
CORSMiddleware_invalid_headers(s)
CORSMiddleware_access_forbidden(s)
CORSMiddleware_access_granted(s)
}
func TestPutObjectLockConfiguration(s *S3Conf) {
PutObjectLockConfiguration_non_existing_bucket(s)
PutObjectLockConfiguration_empty_config(s)
@@ -660,6 +698,10 @@ func TestFullFlow(s *S3Conf) {
TestPutBucketPolicy(s)
TestGetBucketPolicy(s)
TestDeleteBucketPolicy(s)
TestPutBucketCors(s)
TestGetBucketCors(s)
TestDeleteBucketCors(s)
TestPreflightOPTIONSEndpoint(s)
TestPutObjectLockConfiguration(s)
TestGetObjectLockConfiguration(s)
TestPutObjectRetention(s)
@@ -1272,6 +1314,29 @@ func GetIntTests() IntTests {
"DeleteBucketPolicy_non_existing_bucket": DeleteBucketPolicy_non_existing_bucket,
"DeleteBucketPolicy_remove_before_setting": DeleteBucketPolicy_remove_before_setting,
"DeleteBucketPolicy_success": DeleteBucketPolicy_success,
"PutBucketCors_non_existing_bucket": PutBucketCors_non_existing_bucket,
"PutBucketCors_empty_cors_rules": PutBucketCors_empty_cors_rules,
"PutBucketCors_invalid_method": PutBucketCors_invalid_method,
"PutBucketCors_invalid_header": PutBucketCors_invalid_header,
"PutBucketCors_invalid_content_md5": PutBucketCors_invalid_content_md5,
"PutBucketCors_incorrect_content_md5": PutBucketCors_incorrect_content_md5,
"GetBucketCors_non_existing_bucket": GetBucketCors_non_existing_bucket,
"GetBucketCors_no_such_bucket_cors": GetBucketCors_no_such_bucket_cors,
"GetBucketCors_success": GetBucketCors_success,
"DeleteBucketCors_non_existing_bucket": DeleteBucketCors_non_existing_bucket,
"DeleteBucketCors_success": DeleteBucketCors_success,
"PutBucketCors_success": PutBucketCors_success,
"PreflightOPTIONS_non_existing_bucket": PreflightOPTIONS_non_existing_bucket,
"PreflightOPTIONS_missing_origin": PreflightOPTIONS_missing_origin,
"PreflightOPTIONS_invalid_request_method": PreflightOPTIONS_invalid_request_method,
"PreflightOPTIONS_invalid_request_headers": PreflightOPTIONS_invalid_request_headers,
"PreflightOPTIONS_unset_bucket_cors": PreflightOPTIONS_unset_bucket_cors,
"PreflightOPTIONS_access_forbidden": PreflightOPTIONS_access_forbidden,
"PreflightOPTIONS_access_granted": PreflightOPTIONS_access_granted,
"CORSMiddleware_invalid_method": CORSMiddleware_invalid_method,
"CORSMiddleware_invalid_headers": CORSMiddleware_invalid_headers,
"CORSMiddleware_access_forbidden": CORSMiddleware_access_forbidden,
"CORSMiddleware_access_granted": CORSMiddleware_access_granted,
"PutObjectLockConfiguration_non_existing_bucket": PutObjectLockConfiguration_non_existing_bucket,
"PutObjectLockConfiguration_empty_config": PutObjectLockConfiguration_empty_config,
"PutObjectLockConfiguration_not_enabled_on_bucket_creation": PutObjectLockConfiguration_not_enabled_on_bucket_creation,
+791 -5
View File
@@ -13512,6 +13512,794 @@ func DeleteBucketPolicy_success(s *S3Conf) error {
})
}
// Bucket CORS tests
func PutBucketCors_non_existing_bucket(s *S3Conf) error {
testName := "PutBucketCors_non_existing_bucket"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: getPtr("non-existing-bucket"),
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://origin.com"},
AllowedMethods: []string{http.MethodGet},
},
},
},
})
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket))
})
}
func PutBucketCors_empty_cors_rules(s *S3Conf) error {
testName := "PutBucketCors_empty_cors_rules"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{},
},
})
return checkApiErr(err, s3err.GetAPIError(s3err.ErrMalformedXML))
})
}
func PutBucketCors_invalid_method(s *S3Conf) error {
testName := "PutBucketCors_invalid_method"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
for _, test := range []struct {
invalidMethod string
allowedMethods []string
}{
{"get", []string{"get"}},
{"put", []string{"put"}},
{"post", []string{"post"}},
{"head", []string{"head"}},
{"delete", []string{"delete"}},
{http.MethodPatch, []string{http.MethodGet, http.MethodPatch}},
{http.MethodOptions, []string{http.MethodPost, http.MethodOptions}},
{"invalid_method", []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodDelete, "invalid_method"}},
} {
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://origin.com"},
AllowedMethods: test.allowedMethods,
AllowedHeaders: []string{"X-Amz-Date"},
ExposeHeaders: []string{"Authorization"},
},
},
},
})
if err := checkApiErr(err, s3err.GetUnsopportedCORSMethodErr(test.invalidMethod)); err != nil {
return err
}
}
return nil
})
}
func PutBucketCors_invalid_header(s *S3Conf) error {
testName := "PutBucketCors_invalid_header"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
for _, test := range []struct {
invalidHeader string
headers []string
}{
{"invalid header", []string{"X-Amz-Date", "X-Amz-Content-Sha256", "invalid header"}},
{"X-Custom:Header", []string{"Authorization", "X-Custom:Header"}},
{"X(Custom)", []string{"Content-Length", "X(Custom)"}},
{"Bad/Header", []string{"Content-Encoding", "Bad/Header"}},
{"X[Key]", []string{"Date", "X[Key]"}},
{"Bad=Name", []string{"X-Amz-Custome-Header", "Bad=Name"}},
{`X"Quote"`, []string{`X"Quote"`}},
} {
// first check for allowed headers
cfg := &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://origin.com"},
AllowedMethods: []string{http.MethodPost},
AllowedHeaders: test.headers,
ExposeHeaders: []string{"Authorization"},
},
},
}
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: cfg,
})
if err := checkApiErr(err, s3err.GetInvalidCORSHeaderErr(test.invalidHeader)); err != nil {
return err
}
// second check for expose headers
cfg.CORSRules[0].AllowedHeaders = []string{"X-Amz-Date"} // set to any valid header
cfg.CORSRules[0].ExposeHeaders = test.headers
err = putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: cfg,
})
if err := checkApiErr(err, s3err.GetInvalidCORSHeaderErr(test.invalidHeader)); err != nil {
return err
}
}
return nil
})
}
// TODO: report a bug for this case
func PutBucketCors_invalid_content_md5(s *S3Conf) error {
testName := "PutBucketCors_invalid_content_md5"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
return nil
})
}
func PutBucketCors_incorrect_content_md5(s *S3Conf) error {
testName := "PutBucketCors_incorrect_content_md5"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
return nil
})
}
func PutBucketCors_success(s *S3Conf) error {
testName := "PutBucketCors_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
maxAgePositive, maxAgeNegative := int32(3000), int32(-100)
return putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://origin.com"},
AllowedMethods: []string{http.MethodPost, http.MethodPut},
AllowedHeaders: []string{"X-Amz-Date"},
ExposeHeaders: []string{"Authorization"},
// weirdely negative max age seconds are also considered valid
MaxAgeSeconds: &maxAgeNegative,
},
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{http.MethodDelete, http.MethodGet, http.MethodHead},
AllowedHeaders: []string{"Content-Type", "Content-Encoding", "Content-MD5"},
ExposeHeaders: []string{"Authorization", "X-Amz-Date", "X-Amz-Conten-Sha256"},
ID: getPtr("id"),
MaxAgeSeconds: &maxAgePositive,
},
{
AllowedOrigins: []string{"http://example.com", "https://something.net", "http://*origin.com"},
AllowedMethods: []string{http.MethodGet},
},
},
},
})
})
}
func GetBucketCors_non_existing_bucket(s *S3Conf) error {
testName := "GetBucketCors_non_existing_bucket"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.GetBucketCors(ctx, &s3.GetBucketCorsInput{
Bucket: getPtr("non-existing-bucket"),
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket))
})
}
func GetBucketCors_no_such_bucket_cors(s *S3Conf) error {
testName := "GetBucketCors_no_such_bucket_cors"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.GetBucketCors(ctx, &s3.GetBucketCorsInput{
Bucket: &bucket,
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration))
})
}
func GetBucketCors_success(s *S3Conf) error {
testName := "GetBucketCors_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
cfg := &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://origin.com", "helloworld.net"},
AllowedMethods: []string{http.MethodPost, http.MethodPut, http.MethodHead},
AllowedHeaders: []string{"X-Amz-Date", "X-Amz-Meta-Something"},
ExposeHeaders: []string{"Authorization", "Content-Disposition"},
MaxAgeSeconds: getPtr(int32(125)),
},
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{http.MethodDelete, http.MethodGet, http.MethodHead},
AllowedHeaders: []string{"Content-*"},
ExposeHeaders: []string{"Authorization", "X-Amz-Date", "X-Amz-Conten-Sha256"},
ID: getPtr("my_extra_unique_id"),
MaxAgeSeconds: getPtr(int32(-200)),
},
},
}
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: cfg,
})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.GetBucketCors(ctx, &s3.GetBucketCorsInput{
Bucket: &bucket,
})
cancel()
if err != nil {
return err
}
return compareCorsConfig(cfg.CORSRules, res.CORSRules)
})
}
func DeleteBucketCors_non_existing_bucket(s *S3Conf) error {
testName := "DeleteBucketCors_non_existing_bucket"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.DeleteBucketCors(ctx, &s3.DeleteBucketCorsInput{
Bucket: getPtr("non-existing-bucket"),
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket))
})
}
func DeleteBucketCors_success(s *S3Conf) error {
testName := "DeleteBucketCors_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
deletebucketcors := func() error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.DeleteBucketCors(ctx, &s3.DeleteBucketCorsInput{
Bucket: &bucket,
})
cancel()
return err
}
// should not return error when deleting unset bucket CORS
err := deletebucketcors()
if err != nil {
return err
}
err = putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://origin.com"},
AllowedMethods: []string{http.MethodPost},
AllowedHeaders: []string{"X-Amz-Meta-Header"},
ExposeHeaders: []string{"Content-Disposition"},
MaxAgeSeconds: getPtr(int32(5000)),
},
},
},
})
if err != nil {
return err
}
err = deletebucketcors()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.GetBucketCors(ctx, &s3.GetBucketCorsInput{
Bucket: &bucket,
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration))
})
}
func PreflightOPTIONS_non_existing_bucket(s *S3Conf) error {
testName := "PreflightOPTIONS_non_existing_bucket"
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
res, err := makeOPTIONSRequest(s, "non-existing-bucket", "http://localhost:7070", http.MethodPost, "X-Amz-Date")
if err != nil {
return err
}
return checkApiErr(res.err, s3err.GetAPIError(s3err.ErrNoSuchBucket))
})
}
func PreflightOPTIONS_missing_origin(s *S3Conf) error {
testName := "PreflightOPTIONS_missing_origin"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
res, err := makeOPTIONSRequest(s, bucket, "", http.MethodGet, "X-Custom-Header")
if err != nil {
return err
}
return checkApiErr(res.err, s3err.GetAPIError(s3err.ErrMissingCORSOrigin))
})
}
func PreflightOPTIONS_invalid_request_method(s *S3Conf) error {
testName := "PreflightOPTIONS_invalid_request_method"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
for _, method := range []string{
// should be case sensitive, all with capital letters
"get", "Get", "GEt", "geT",
"post", "Post", "POSt", "posT",
"put", "Put", "pUt", "puT",
"head", "Head", "HEAd", "heAD",
// unsupported methods
"PATCH", "CONNECT", "OPTIONS",
// nonsense strings
"something", "invalid_method", "method",
} {
res, err := makeOPTIONSRequest(s, bucket, "www.my-origin.com", method, "X-Custom-Header")
if err != nil {
return err
}
if err := checkApiErr(res.err, s3err.GetInvalidCORSMethodErr(method)); err != nil {
return err
}
}
return nil
})
}
func PreflightOPTIONS_invalid_request_headers(s *S3Conf) error {
testName := "PreflightOPTIONS_invalid_request_headers"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
for _, test := range []struct {
invalidHeader string
headers string
}{
{"invalid header", "X-Amz-Date,X-Amz-Content-Sha256,invalid header"}, // invalid 'space' in header name
{"X-Custom:Header", "Authorization,X-Custom:Header"}, // invalid char :
{"X(Custom)", "Content-Length,X(Custom)"}, // invalid char ()
{" Bad/Header", "Content-Encoding, Bad/Header"}, // extra 'space', invalid char /
{"X[Key]", "Date,X[Key]"}, // invalid char '[]'
{"Bad=Name", "X-Amz-Custome-Header,Bad=Name"}, // invalid char =
{`X"Quote"`, `X"Quote"`}, // invalid quote "
{"NonAsciiŁ", "Content-Length,NonAsciiŁ"}, // non-ASCII character
{"Emoji😀", "X-Emoji,Emoji😀"}, // emoji invalid
{"bad@char", "Accept-Encoding,bad@char"}, // @ is invalid
{"tab\tchar", "tab\tchar,X-Something-Valid"}, // invalid encodign \t
} {
res, err := makeOPTIONSRequest(s, bucket, "www.my-origin.com", http.MethodGet, test.headers)
if err != nil {
return err
}
if err := checkApiErr(res.err, s3err.GetInvalidCORSRequestHeaderErr(test.invalidHeader)); err != nil {
return err
}
}
return nil
})
}
func PreflightOPTIONS_unset_bucket_cors(s *S3Conf) error {
testName := "PreflightOPTIONS_unset_bucket_cors"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
res, err := makeOPTIONSRequest(s, bucket, "http://example.com", http.MethodPost, "X-Amz-Date,Date")
if err != nil {
return err
}
return checkApiErr(res.err, s3err.GetAPIError(s3err.ErrCORSIsNotEnabled))
})
}
func PreflightOPTIONS_access_forbidden(s *S3Conf) error {
testName := "PreflightOPTIONS_access_forbidden"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://example.com", "https://example.com"},
AllowedMethods: []string{http.MethodGet},
AllowedHeaders: []string{"X-Amz-Date", "X-Amz-Content-Sha256"},
},
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{http.MethodHead},
},
{
AllowedOrigins: []string{"http://origin*"},
AllowedMethods: []string{http.MethodPost},
AllowedHeaders: []string{"Authorization"},
},
{
AllowedOrigins: []string{"http://something.com"},
AllowedMethods: []string{http.MethodPut},
AllowedHeaders: []string{"X-Amz-*"},
},
},
},
})
if err != nil {
return err
}
for _, test := range []struct {
origin string
method string
headers string
}{
// origin deson't match
{"http://non-matching-origin.net", http.MethodGet, "X-Amz-Date"},
// method doesn't match
{"http://example.com", http.MethodPut, "X-Amz-Content-Sha256"},
// header doesn't match
{"http://example.com", http.MethodGet, "X-Amz-Expected-Bucket-Owner"},
// extra header
{"http://example.com", http.MethodGet, "X-Amz-Date,X-Amz-Content-Sha256,Extra-Header"},
// extra header (2nd rule)
{"https://any-origin.com", http.MethodHead, "X-Amz-Extra-Header"},
// origin match, method not (2nd rule)
{"https://any-origin.com", http.MethodPost, ""},
// third rule: headers doesn't match
{"https://origin.com", http.MethodPost, "Content-Length"},
// third rule: extra header
{"https://origin.com", http.MethodPost, "Authorization,Content-Disposition"},
// third rule: origin doesn't match
{"https://www.origin.com", http.MethodPost, "Authorization"},
// forth rule: header doesn't match the wildcard
{"https://something.com", http.MethodPut, "Authorization"},
{"https://something.com", http.MethodPut, "X-Amz"},
{"https://something.com", http.MethodPut, "X-Amz-Date,Content-Length"},
} {
res, err := makeOPTIONSRequest(s, bucket, test.origin, test.method, test.headers)
if err != nil {
return err
}
if err := checkApiErr(res.err, s3err.GetAPIError(s3err.ErrCORSForbidden)); err != nil {
return err
}
}
return nil
})
}
func PreflightOPTIONS_access_granted(s *S3Conf) error {
testName := "PreflightOPTIONS_access_granted"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://example.com", "https://example.com"},
AllowedMethods: []string{http.MethodGet, http.MethodHead},
AllowedHeaders: []string{"X-Amz-Date", "X-Amz-Content-Sha256"},
ExposeHeaders: []string{"Content-Type", "Content-Length"},
MaxAgeSeconds: getPtr(int32(100)),
},
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{http.MethodHead},
AllowedHeaders: []string{"X-Amz-Meta-Something"},
},
{
AllowedOrigins: []string{"something.net"},
AllowedMethods: []string{http.MethodPost, http.MethodPut},
AllowedHeaders: []string{"Authorization"},
ExposeHeaders: []string{"Content-Disposition", "Content-Encoding"},
MaxAgeSeconds: getPtr(int32(3000)),
ID: getPtr("unique_id"),
},
{
AllowedOrigins: []string{"http://www*"},
AllowedMethods: []string{http.MethodGet},
AllowedHeaders: []string{"x-amz-server-side-encryption"},
ExposeHeaders: []string{"X-Amz-Expected-Bucket-Owner"},
MaxAgeSeconds: getPtr(int32(5000)),
},
{
AllowedOrigins: []string{"http://uniquie-origin.net"},
AllowedMethods: []string{http.MethodPost, http.MethodPut},
AllowedHeaders: []string{"X-Amz-*-Suffix"},
ExposeHeaders: []string{"Authorization", "Content-Type"},
MaxAgeSeconds: getPtr(int32(2000)),
},
},
},
})
if err != nil {
return err
}
varyHdr := "Origin, Access-Control-Request-Headers, Access-Control-Request-Method"
for _, test := range []struct {
origin string
method string
headers string
result PreflightResult
}{
// first rule matches
{"http://example.com", http.MethodGet, "X-Amz-Date", PreflightResult{"http://example.com", "GET, HEAD", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
{"http://example.com", http.MethodGet, "X-Amz-Content-Sha256", PreflightResult{"http://example.com", "GET, HEAD", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
{"http://example.com", http.MethodHead, "", PreflightResult{"http://example.com", "GET, HEAD", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
{"https://example.com", http.MethodGet, "X-Amz-Date,X-Amz-Content-Sha256", PreflightResult{"https://example.com", "GET, HEAD", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
// second rule matches: origin is a wildcard
{"http://anything.com", http.MethodHead, "X-Amz-Meta-Something", PreflightResult{"*", "HEAD", "", "", "false", varyHdr, nil}},
{"hello.com", http.MethodHead, "", PreflightResult{"*", "HEAD", "", "", "false", varyHdr, nil}},
// third rule matches
{"something.net", http.MethodPut, "Authorization", PreflightResult{"something.net", "POST, PUT", "Content-Disposition, Content-Encoding", "3000", "true", varyHdr, nil}},
{"something.net", http.MethodPost, "", PreflightResult{"something.net", "POST, PUT", "Content-Disposition, Content-Encoding", "3000", "true", varyHdr, nil}},
// forth rule matches: origin contains wildcard
{"http://www.hello.world.com", http.MethodGet, "", PreflightResult{"http://www.hello.world.com", "GET", "X-Amz-Expected-Bucket-Owner", "5000", "true", varyHdr, nil}},
{"http://www.example.com", http.MethodGet, "x-amz-server-side-encryption", PreflightResult{"http://www.example.com", "GET", "X-Amz-Expected-Bucket-Owner", "5000", "true", varyHdr, nil}},
// fifth rule matches: allowed headers contains wildcard
{"http://uniquie-origin.net", http.MethodPost, "X-Amz-anything-Suffix", PreflightResult{"http://uniquie-origin.net", "POST, PUT", "Authorization, Content-Type", "2000", "true", varyHdr, nil}},
{"http://uniquie-origin.net", http.MethodPut, "X-Amz-yyy-xxx-Suffix", PreflightResult{"http://uniquie-origin.net", "POST, PUT", "Authorization, Content-Type", "2000", "true", varyHdr, nil}},
} {
err := testOPTIONSEdnpoint(s, bucket, test.origin, test.method, test.headers, &test.result)
if err != nil {
return err
}
}
return nil
})
}
func CORSMiddleware_invalid_method(s *S3Conf) error {
testName := "CORSMiddleware_invalid_method"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://www.example.com"},
AllowedMethods: []string{http.MethodPut},
},
},
},
})
if err != nil {
return err
}
// create a PutObject signed request
req, err := createSignedReq(http.MethodPut, s.endpoint, bucket+"/my-obj", s.awsID, s.awsSecret, "s3", s.awsRegion, nil, time.Now(), map[string]string{
"Origin": "http://www.example.com",
"Access-Control-Request-Method": "invalid_method",
})
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
result, err := extractCORSHeaders(resp)
if err != nil {
return err
}
return checkApiErr(result.err, s3err.GetInvalidCORSMethodErr("invalid_method"))
})
}
func CORSMiddleware_invalid_headers(s *S3Conf) error {
testName := "CORSMiddleware_invalid_headers"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://www.example.com"},
AllowedMethods: []string{http.MethodPut},
},
},
},
})
if err != nil {
return err
}
// create a PutObject signed request
req, err := createSignedReq(http.MethodPut, s.endpoint, bucket+"/my-obj", s.awsID, s.awsSecret, "s3", s.awsRegion, nil, time.Now(), map[string]string{
"Origin": "http://www.example.com",
"Access-Control-Request-Headers": "invalid header",
})
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
result, err := extractCORSHeaders(resp)
if err != nil {
return err
}
return checkApiErr(result.err, s3err.GetInvalidCORSRequestHeaderErr("invalid header"))
})
}
func CORSMiddleware_access_forbidden(s *S3Conf) error {
testName := "CORSMiddleware_access_forbidden"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://example.com", "https://example.com"},
AllowedMethods: []string{http.MethodGet},
AllowedHeaders: []string{"X-Amz-Date", "X-Amz-Content-Sha256"},
},
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{http.MethodHead},
},
},
},
})
if err != nil {
return err
}
for _, test := range []struct {
origin string
method string
headers string
}{
// origin deson't match
{"http://non-matching-origin.net", http.MethodGet, "X-Amz-Date"},
// method doesn't match
{"http://example.com", http.MethodPut, "X-Amz-Content-Sha256"},
// header doesn't match
{"http://example.com", http.MethodGet, "X-Amz-Expected-Bucket-Owner"},
// extra header
{"http://example.com", http.MethodGet, "X-Amz-Date,X-Amz-Content-Sha256,Extra-Header"},
// extra header (2nd rule)
{"https://any-origin.com", http.MethodHead, "X-Amz-Extra-Header"},
// origin match, method not (2nd rule)
{"https://any-origin.com", http.MethodPost, ""},
} {
req, err := createSignedReq(http.MethodPut, s.endpoint, bucket+"/my-obj", s.awsID, s.awsSecret, "s3", s.awsRegion, nil, time.Now(), map[string]string{
"Origin": test.origin,
"Access-Control-Request-Headers": test.headers,
"Access-Control-Request-Method": test.method,
})
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
res, err := extractCORSHeaders(resp)
if err != nil {
return err
}
// no error expected, all the headers should be empty
if err := comparePreflightResult(&PreflightResult{}, res); err != nil {
return err
}
}
return nil
})
}
func CORSMiddleware_access_granted(s *S3Conf) error {
testName := "CORSMiddleware_access_granted"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
Bucket: &bucket,
CORSConfiguration: &types.CORSConfiguration{
CORSRules: []types.CORSRule{
{
AllowedOrigins: []string{"http://example.com", "https://example.com"},
AllowedMethods: []string{http.MethodGet, http.MethodHead},
AllowedHeaders: []string{"X-Amz-Date", "X-Amz-Content-Sha256"},
ExposeHeaders: []string{"Content-Type", "Content-Length"},
MaxAgeSeconds: getPtr(int32(100)),
},
{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{http.MethodHead},
AllowedHeaders: []string{"X-Amz-Meta-Something"},
},
{
AllowedOrigins: []string{"something.net"},
AllowedMethods: []string{http.MethodPost, http.MethodPut},
AllowedHeaders: []string{"Authorization"},
ExposeHeaders: []string{"Content-Disposition", "Content-Encoding"},
MaxAgeSeconds: getPtr(int32(3000)),
ID: getPtr("unique_id"),
},
},
},
})
if err != nil {
return err
}
varyHdr := "Origin, Access-Control-Request-Headers, Access-Control-Request-Method"
for _, test := range []struct {
origin string
method string
headers string
result PreflightResult
}{
// first rule matches
{"http://example.com", http.MethodGet, "X-Amz-Date", PreflightResult{"http://example.com", "GET, HEAD", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
{"http://example.com", http.MethodGet, "X-Amz-Content-Sha256", PreflightResult{"http://example.com", "GET, HEAD", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
{"http://example.com", http.MethodHead, "", PreflightResult{"http://example.com", "GET, HEAD", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
{"https://example.com", http.MethodGet, "X-Amz-Date,X-Amz-Content-Sha256", PreflightResult{"https://example.com", "GET, HEAD", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
// second rule matches
{"http://anything.com", http.MethodHead, "X-Amz-Meta-Something", PreflightResult{"*", "HEAD", "", "", "false", varyHdr, nil}},
{"hello.com", http.MethodHead, "", PreflightResult{"*", "HEAD", "", "", "false", varyHdr, nil}},
// third rule matches
{"something.net", http.MethodPut, "Authorization", PreflightResult{"something.net", "POST, PUT", "Content-Disposition, Content-Encoding", "3000", "true", varyHdr, nil}},
{"something.net", http.MethodPost, "", PreflightResult{"something.net", "POST, PUT", "Content-Disposition, Content-Encoding", "3000", "true", varyHdr, nil}},
} {
req, err := createSignedReq(http.MethodPut, s.endpoint, bucket+"/my-obj", s.awsID, s.awsSecret, "s3", s.awsRegion, nil, time.Now(), map[string]string{
"Origin": test.origin,
"Access-Control-Request-Headers": test.headers,
"Access-Control-Request-Method": test.method,
})
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
res, err := extractCORSHeaders(resp)
if err != nil {
return err
}
if err := comparePreflightResult(&test.result, res); err != nil {
return err
}
}
return nil
})
}
// Object lock tests
func PutObjectLockConfiguration_non_existing_bucket(s *S3Conf) error {
testName := "PutObjectLockConfiguration_non_existing_bucket"
@@ -16571,7 +17359,7 @@ func PublicBucket_public_bucket_policy(s *S3Conf) error {
})
return err
},
ExpectedErr: s3err.GetAPIError(s3err.ErrNotImplemented),
ExpectedErr: nil,
},
{
Action: "GetBucketCors",
@@ -16579,7 +17367,7 @@ func PublicBucket_public_bucket_policy(s *S3Conf) error {
_, err := s3client.GetBucketCors(ctx, &s3.GetBucketCorsInput{Bucket: &bucket})
return err
},
ExpectedErr: s3err.GetAPIError(s3err.ErrNotImplemented),
ExpectedErr: nil,
},
{
Action: "DeleteBucketCors",
@@ -16587,7 +17375,7 @@ func PublicBucket_public_bucket_policy(s *S3Conf) error {
_, err := s3client.DeleteBucketCors(ctx, &s3.DeleteBucketCorsInput{Bucket: &bucket})
return err
},
ExpectedErr: s3err.GetAPIError(s3err.ErrNotImplemented),
ExpectedErr: nil,
},
{
Action: "CreateMultipartUpload",
@@ -21321,7 +22109,6 @@ func Versioning_AccessControl_GetObjectVersion(s *S3Conf) error {
}
doc := genPolicyDoc("Allow", fmt.Sprintf(`"%s"`, testuser1.access), `"s3:GetObject"`, fmt.Sprintf(`"arn:aws:s3:::%s/*"`, bucket))
fmt.Println(doc)
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
Bucket: &bucket,
@@ -21391,7 +22178,6 @@ func Versioning_AccessControl_HeadObjectVersion(s *S3Conf) error {
}
doc := genPolicyDoc("Allow", fmt.Sprintf(`"%s"`, testuser1.access), `"s3:GetObject"`, fmt.Sprintf(`"arn:aws:s3:::%s/*"`, bucket))
fmt.Println(doc)
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
Bucket: &bucket,
+165 -1
View File
@@ -703,7 +703,7 @@ func getString(str *string) string {
return *str
}
func getPtr(str string) *string {
func getPtr[T any](str T) *T {
return &str
}
@@ -1629,3 +1629,167 @@ func getObject_zero_len_with_range_helper(testName, obj string, s *S3Conf) error
return nil
})
}
func getInt32(ptr *int32) int32 {
if ptr == nil {
return 0
}
return *ptr
}
func putBucketCors(client *s3.Client, input *s3.PutBucketCorsInput) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := client.PutBucketCors(ctx, input)
cancel()
return err
}
func compareCorsConfig(expected, got []types.CORSRule) error {
if expected == nil && got == nil {
return nil
}
if got == nil {
return errors.New("nil CORS config")
}
if len(expected) != len(got) {
return fmt.Errorf("expected CORS rules length to be %v, instead got %v", len(expected), len(got))
}
for i, r := range expected {
rule := got[i]
if !slices.Equal(r.AllowedOrigins, rule.AllowedOrigins) {
return fmt.Errorf("expected the allowed origins to be %v, instead got %v", r.AllowedOrigins, rule.AllowedOrigins)
}
if !slices.Equal(r.AllowedMethods, rule.AllowedMethods) {
return fmt.Errorf("expected the allowed methods to be %v, instead got %v", r.AllowedMethods, rule.AllowedMethods)
}
if !slices.Equal(r.AllowedHeaders, rule.AllowedHeaders) {
return fmt.Errorf("expected the allowed headers to be %v, instead got %v", r.AllowedHeaders, rule.AllowedHeaders)
}
if !slices.Equal(r.ExposeHeaders, rule.ExposeHeaders) {
return fmt.Errorf("expected the allowed origins to be %v, instead got %v", r.ExposeHeaders, rule.ExposeHeaders)
}
if getInt32(r.MaxAgeSeconds) != getInt32(rule.MaxAgeSeconds) {
return fmt.Errorf("expected the max age seconds to be %v, instead got %v", getInt32(r.MaxAgeSeconds), getInt32(rule.MaxAgeSeconds))
}
if getString(r.ID) != getString(rule.ID) {
return fmt.Errorf("expected ID to be %v, instead got %v", getString(r.ID), getString(rule.ID))
}
}
return nil
}
type PreflightResult struct {
Origin string
Methods string
ExposeHeaders string
MaxAge string
AllowCredentials string
Vary string
err error
}
func extractCORSHeaders(resp *http.Response) (*PreflightResult, error) {
if resp.StatusCode >= 400 {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
var errResp smithy.GenericAPIError
err = xml.Unmarshal(body, &errResp)
if err != nil {
return nil, fmt.Errorf("unmarshal respone body: %w", err)
}
return &PreflightResult{
err: &errResp,
}, nil
}
return &PreflightResult{
Origin: resp.Header.Get("Access-Control-Allow-Origin"),
Methods: resp.Header.Get("Access-Control-Allow-Methods"),
ExposeHeaders: resp.Header.Get("Access-Control-Expose-Headers"),
MaxAge: resp.Header.Get("Access-Control-Max-Age"),
AllowCredentials: resp.Header.Get("Access-Control-Allow-Credentials"),
Vary: resp.Header.Get("Vary"),
}, nil
}
func makeOPTIONSRequest(s *S3Conf, bucket, origin, method string, headers string) (*PreflightResult, error) {
req, err := http.NewRequest(http.MethodOptions, fmt.Sprintf("%s/%s/object", s.endpoint, bucket), nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Add("Origin", origin)
req.Header.Add("Access-Control-Request-Method", method)
req.Header.Add("Access-Control-Request-Headers", headers)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
return extractCORSHeaders(resp)
}
func comparePreflightResult(expected, got *PreflightResult) error {
if expected == nil {
return fmt.Errorf("nil expected preflight request result")
}
if got == nil {
return fmt.Errorf("expected the preflights result to be %v, instead got nil", *expected)
}
if expected.err != nil {
if got.err == nil {
return fmt.Errorf("expected %w error, instaed got nil", expected.err)
}
apiErr, ok := expected.err.(s3err.APIError)
if !ok {
return fmt.Errorf("expected s3err.APIError, instead got %w", expected.err)
}
return checkApiErr(got.err, apiErr)
}
if got.err != nil {
return fmt.Errorf("expected no error, instaed got %w", got.err)
}
if expected.Origin != got.Origin {
return fmt.Errorf("expected the origin to be %v, instead got %v", expected.Origin, got.Origin)
}
if expected.Methods != got.Methods {
return fmt.Errorf("expected the allowed methods to be %v, instead got %v", expected.Methods, got.Methods)
}
if expected.ExposeHeaders != got.ExposeHeaders {
return fmt.Errorf("expected the expose headers to be %v, instead got %v", expected.ExposeHeaders, got.ExposeHeaders)
}
if expected.MaxAge != got.MaxAge {
return fmt.Errorf("expected the max age to be %v, instead got %v", expected.MaxAge, got.MaxAge)
}
if expected.AllowCredentials != got.AllowCredentials {
return fmt.Errorf("expected the allow credentials to be %v, instead got %v", expected.AllowCredentials, got.AllowCredentials)
}
if expected.Vary != got.Vary {
return fmt.Errorf("expected the Vary header to be %v, instead got %v", expected.Vary, got.Vary)
}
return nil
}
func testOPTIONSEdnpoint(s *S3Conf, bucket, origin, method string, headers string, expected *PreflightResult) error {
result, err := makeOPTIONSRequest(s, bucket, origin, method, headers)
if err != nil {
return err
}
return comparePreflightResult(expected, result)
}