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
+16 -10
View File
@@ -134,7 +134,7 @@ var _ backend.Backend = &BackendMock{}
// PutBucketAclFunc: func(contextMoqParam context.Context, bucket string, data []byte) error {
// panic("mock out the PutBucketAcl method")
// },
// PutBucketCorsFunc: func(contextMoqParam context.Context, bytes []byte) error {
// PutBucketCorsFunc: func(contextMoqParam context.Context, bucket string, cors []byte) error {
// panic("mock out the PutBucketCors method")
// },
// PutBucketOwnershipControlsFunc: func(contextMoqParam context.Context, bucket string, ownership types.ObjectOwnership) error {
@@ -304,7 +304,7 @@ type BackendMock struct {
PutBucketAclFunc func(contextMoqParam context.Context, bucket string, data []byte) error
// PutBucketCorsFunc mocks the PutBucketCors method.
PutBucketCorsFunc func(contextMoqParam context.Context, bytes []byte) error
PutBucketCorsFunc func(contextMoqParam context.Context, bucket string, cors []byte) error
// PutBucketOwnershipControlsFunc mocks the PutBucketOwnershipControls method.
PutBucketOwnershipControlsFunc func(contextMoqParam context.Context, bucket string, ownership types.ObjectOwnership) error
@@ -635,8 +635,10 @@ type BackendMock struct {
PutBucketCors []struct {
// ContextMoqParam is the contextMoqParam argument value.
ContextMoqParam context.Context
// Bytes is the bytes argument value.
Bytes []byte
// Bucket is the bucket argument value.
Bucket string
// Cors is the cors argument value.
Cors []byte
}
// PutBucketOwnershipControls holds details about calls to the PutBucketOwnershipControls method.
PutBucketOwnershipControls []struct {
@@ -2192,21 +2194,23 @@ func (mock *BackendMock) PutBucketAclCalls() []struct {
}
// PutBucketCors calls PutBucketCorsFunc.
func (mock *BackendMock) PutBucketCors(contextMoqParam context.Context, bytes []byte) error {
func (mock *BackendMock) PutBucketCors(contextMoqParam context.Context, bucket string, cors []byte) error {
if mock.PutBucketCorsFunc == nil {
panic("BackendMock.PutBucketCorsFunc: method is nil but Backend.PutBucketCors was just called")
}
callInfo := struct {
ContextMoqParam context.Context
Bytes []byte
Bucket string
Cors []byte
}{
ContextMoqParam: contextMoqParam,
Bytes: bytes,
Bucket: bucket,
Cors: cors,
}
mock.lockPutBucketCors.Lock()
mock.calls.PutBucketCors = append(mock.calls.PutBucketCors, callInfo)
mock.lockPutBucketCors.Unlock()
return mock.PutBucketCorsFunc(contextMoqParam, bytes)
return mock.PutBucketCorsFunc(contextMoqParam, bucket, cors)
}
// PutBucketCorsCalls gets all the calls that were made to PutBucketCors.
@@ -2215,11 +2219,13 @@ func (mock *BackendMock) PutBucketCors(contextMoqParam context.Context, bytes []
// len(mockedBackend.PutBucketCorsCalls())
func (mock *BackendMock) PutBucketCorsCalls() []struct {
ContextMoqParam context.Context
Bytes []byte
Bucket string
Cors []byte
} {
var calls []struct {
ContextMoqParam context.Context
Bytes []byte
Bucket string
Cors []byte
}
mock.lockPutBucketCors.RLock()
calls = mock.calls.PutBucketCors
+10 -1
View File
@@ -187,8 +187,17 @@ func (c S3ApiController) GetBucketCors(ctx *fiber.Ctx) (*Response, error) {
}
data, err := c.be.GetBucketCors(ctx.Context(), bucket)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
output, err := auth.ParseCORSOutput(data)
return &Response{
Data: data,
Data: output,
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
+36 -4
View File
@@ -17,7 +17,10 @@ package controllers
import (
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"net/http"
"testing"
"github.com/aws/aws-sdk-go-v2/service/s3"
@@ -313,6 +316,20 @@ func TestS3ApiController_GetBucketVersioning(t *testing.T) {
}
func TestS3ApiController_GetBucketCors(t *testing.T) {
cors := &auth.CORSConfiguration{
Rules: []auth.CORSRule{
{
AllowedOrigins: []string{"origin"},
AllowedMethods: []auth.CORSHTTPMethod{http.MethodPut},
AllowedHeaders: []auth.CORSHeader{"X-Amz-Date"},
},
},
}
beRes, err := xml.Marshal(cors)
assert.NoError(t, err)
var nilResp *auth.CORSConfiguration
tests := []struct {
name string
input testInput
@@ -341,7 +358,6 @@ func TestS3ApiController_GetBucketCors(t *testing.T) {
},
output: testOutput{
response: &Response{
Data: []byte{},
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
@@ -350,14 +366,30 @@ func TestS3ApiController_GetBucketCors(t *testing.T) {
},
},
{
name: "successful response",
name: "invalid data from backend",
input: testInput{
locals: defaultLocals,
beRes: []byte("mock_cors_resp"),
beRes: []byte("invalid_data"),
},
output: testOutput{
response: &Response{
Data: []byte("mock_cors_resp"),
Data: nilResp,
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: errors.New("failed to parse cors config:"),
},
},
{
name: "successful response",
input: testInput{
locals: defaultLocals,
beRes: beRes,
},
output: testOutput{
response: &Response{
Data: cors,
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
+56 -1
View File
@@ -15,6 +15,7 @@
package controllers
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
@@ -247,7 +248,61 @@ func (c S3ApiController) PutBucketCors(ctx *fiber.Ctx) (*Response, error) {
}, err
}
err = c.be.PutBucketCors(ctx.Context(), []byte{})
body := ctx.Body()
var corsConfig auth.CORSConfiguration
err = xml.Unmarshal(body, &corsConfig)
if err != nil {
debuglogger.Logf("invalid CORS request body: %v", err)
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, s3err.GetAPIError(s3err.ErrMalformedXML)
}
// validate the CORS configuration rules
err = corsConfig.Validate()
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
algo, checksusms, err := utils.ParseChecksumHeadersAndSdkAlgo(ctx)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
if algo != "" {
rdr, err := utils.NewHashReader(bytes.NewReader(body), checksusms[algo], utils.HashType(strings.ToLower(string(algo))))
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
// Pass the same body to avoid data duplication
_, err = rdr.Read(body)
if err != nil {
debuglogger.Logf("failed to read hash calculation data: %v", err)
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
}
err = c.be.PutBucketCors(ctx.Context(), bucket, body)
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
+68 -2
View File
@@ -463,6 +463,26 @@ func TestS3ApiController_PutObjectLockConfiguration(t *testing.T) {
}
func TestS3ApiController_PutBucketCors(t *testing.T) {
validBody, err := xml.Marshal(auth.CORSConfiguration{
Rules: []auth.CORSRule{
{
AllowedOrigins: []string{"*"},
AllowedMethods: []auth.CORSHTTPMethod{http.MethodPost},
},
},
})
assert.NoError(t, err)
invalidCors, err := xml.Marshal(auth.CORSConfiguration{
Rules: []auth.CORSRule{
{
AllowedOrigins: []string{"origin"},
AllowedMethods: []auth.CORSHTTPMethod{"invalid_method"},
},
},
})
assert.NoError(t, err)
tests := []struct {
name string
input testInput
@@ -482,11 +502,54 @@ func TestS3ApiController_PutBucketCors(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid request body",
input: testInput{
locals: defaultLocals,
body: []byte("invalid_body"),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetAPIError(s3err.ErrMalformedXML),
},
},
{
name: "invalid cors config",
input: testInput{
locals: defaultLocals,
body: invalidCors,
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetUnsopportedCORSMethodErr("invalid_method"),
},
},
{
name: "invalid checksum algo",
input: testInput{
locals: defaultLocals,
body: validBody,
headers: map[string]string{
"X-Amz-Sdk-Checksum-Algorithm": "invalid_algo",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{BucketOwner: "root"},
},
err: s3err.GetAPIError(s3err.ErrInvalidChecksumAlgorithm),
},
},
{
name: "backend error",
input: testInput{
locals: defaultLocals,
beErr: s3err.GetAPIError(s3err.ErrNotImplemented),
body: validBody,
},
output: testOutput{
response: &Response{
@@ -499,6 +562,7 @@ func TestS3ApiController_PutBucketCors(t *testing.T) {
name: "success",
input: testInput{
locals: defaultLocals,
body: validBody,
},
output: testOutput{
response: &Response{
@@ -513,7 +577,7 @@ func TestS3ApiController_PutBucketCors(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
be := &BackendMock{
PutBucketCorsFunc: func(contextMoqParam context.Context, bytes []byte) error {
PutBucketCorsFunc: func(contextMoqParam context.Context, bucket string, cors []byte) error {
return tt.input.beErr
},
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
@@ -526,7 +590,9 @@ func TestS3ApiController_PutBucketCors(t *testing.T) {
}
testController(t, ctrl.PutBucketCors, tt.output.response, tt.output.err, ctxInputs{
locals: tt.input.locals,
locals: tt.input.locals,
body: tt.input.body,
headers: tt.input.headers,
})
})
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2023 Versity Software
// This file is 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 controllers
import (
"errors"
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/s3api/debuglogger"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3err"
)
func (s S3ApiController) CORSOptions(ctx *fiber.Ctx) (*Response, error) {
bucket := ctx.Params("bucket")
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
// get headers
origin := ctx.Get("Origin")
method := auth.CORSHTTPMethod(ctx.Get("Access-Control-Request-Method"))
headers := ctx.Get("Access-Control-Request-Headers")
// Origin is required
if origin == "" {
debuglogger.Logf("origin is missing: %v", origin)
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, s3err.GetAPIError(s3err.ErrMissingCORSOrigin)
}
// check if allowed method is valid
if !method.IsValid() {
debuglogger.Logf("invalid cors method: %s", method)
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, s3err.GetInvalidCORSMethodErr(method.String())
}
// parse and validate headers
parsedHeaders, err := auth.ParseCORSHeaders(headers)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
cors, err := s.be.GetBucketCors(ctx.Context(), bucket)
if err != nil {
debuglogger.Logf("failed to get bucket cors: %v", err)
if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)) {
err = s3err.GetAPIError(s3err.ErrCORSIsNotEnabled)
debuglogger.Logf("bucket cors is not set: %v", err)
}
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
corsConfig, err := auth.ParseCORSOutput(cors)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
allowConfig, err := corsConfig.IsAllowed(origin, method, parsedHeaders)
if err != nil {
debuglogger.Logf("cors access forbidden: %v", err)
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
return &Response{
Headers: map[string]*string{
"Access-Control-Allow-Origin": &allowConfig.Origin,
"Access-Control-Allow-Methods": &allowConfig.Methods,
"Access-Control-Expose-Headers": &allowConfig.ExposedHeaders,
"Access-Control-Allow-Credentials": &allowConfig.AllowCredentials,
"Access-Control-Max-Age": utils.ConvertPtrToStringPtr(allowConfig.MaxAge),
"Vary": &middlewares.VaryHdr,
},
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, nil
}