mirror of
https://github.com/versity/versitygw.git
synced 2026-08-21 14:46:19 +00:00
Closes #1847 This PR introduces a global optional gateway CLI flag `--disable-acl` (`VGW_DISABLE_ACL`) to disable ACL handling. When this flag is enabled, the gateway ignores all ACL-related headers, particularly in `CreateBucket`, `PutObject`, `CopyObject`, and `CreateMultipartUpload`. `GetBucketAcl` behavior is unchanged simply returning the bucket ACL config. There's no change in object ACL actions(`PutObjectACL`, `GetObjectACL`). They return a`NotImplemented` error as before. A new custom error is added for PutBucketAcl calls when ACLs are disabled at the gateway level. Its HTTP status code and error code match AWS S3’s behavior, with only a slightly different error message. In the access-control checker, ACL evaluation is fully bypassed. If ACLs are disabled only the bucket owner gets access to the bucket and all grantee checks are ignored. The PR also includes minor refactoring of the S3 API server and router. The growing list of parameters passed to the router’s Init method has been consolidated into fields within the router struct, initialized during router construction. Parameters not needed by the S3 server are no longer stored in the server configuration and are instead forwarded directly to the router.
323 lines
9.1 KiB
Go
323 lines
9.1 KiB
Go
// Copyright 2026 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 s3api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/versity/versitygw/auth"
|
|
"github.com/versity/versitygw/backend"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
type backendWithCorsOnly struct {
|
|
backend.BackendUnsupported
|
|
}
|
|
|
|
func (b backendWithCorsOnly) GetBucketCors(ctx context.Context, bucket string) ([]byte, error) {
|
|
return nil, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)
|
|
}
|
|
|
|
type backendWithBucketCors struct {
|
|
backend.BackendUnsupported
|
|
corsConfig []byte
|
|
}
|
|
|
|
func (b backendWithBucketCors) GetBucketCors(ctx context.Context, bucket string) ([]byte, error) {
|
|
if b.corsConfig != nil {
|
|
return b.corsConfig, nil
|
|
}
|
|
return nil, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)
|
|
}
|
|
|
|
func (b backendWithBucketCors) GetBucketAcl(ctx context.Context, input *s3.GetBucketAclInput) ([]byte, error) {
|
|
// Return a minimal ACL in JSON format
|
|
acl := auth.ACL{
|
|
Owner: "test",
|
|
}
|
|
return json.Marshal(acl)
|
|
}
|
|
|
|
func TestS3ApiRouter_ListBuckets_DefaultCORSAllowOrigin(t *testing.T) {
|
|
origin := "https://example.com"
|
|
|
|
app := fiber.New()
|
|
(&S3ApiRouter{
|
|
app: app,
|
|
be: backend.BackendUnsupported{},
|
|
iam: &auth.IAMServiceInternal{},
|
|
region: "us-east-1",
|
|
corsAllowOrigin: origin,
|
|
}).Init()
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/", nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
|
t.Fatalf("expected Access-Control-Allow-Origin %q, got %q", origin, got)
|
|
}
|
|
if got := resp.Header.Get("Access-Control-Expose-Headers"); got == "" {
|
|
t.Fatalf("expected Access-Control-Expose-Headers to be set")
|
|
}
|
|
}
|
|
|
|
func TestS3ApiRouter_ListBuckets_OptionsPreflight_DefaultCORS(t *testing.T) {
|
|
origin := "https://example.com"
|
|
|
|
app := fiber.New()
|
|
(&S3ApiRouter{
|
|
app: app,
|
|
be: backend.BackendUnsupported{},
|
|
iam: &auth.IAMServiceInternal{},
|
|
region: "us-east-1",
|
|
corsAllowOrigin: origin,
|
|
}).Init()
|
|
|
|
req, err := http.NewRequest(http.MethodOptions, "/", nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Origin", "https://client.example")
|
|
req.Header.Set("Access-Control-Request-Method", "GET")
|
|
req.Header.Set("Access-Control-Request-Headers", "authorization")
|
|
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("expected status %d, got %d", http.StatusNoContent, resp.StatusCode)
|
|
}
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
|
t.Fatalf("expected Access-Control-Allow-Origin %q, got %q", origin, got)
|
|
}
|
|
}
|
|
|
|
func TestS3ApiRouter_PutBucketTagging_ErrorStillIncludesFallbackCORS(t *testing.T) {
|
|
origin := "http://127.0.0.1:9090"
|
|
|
|
app := fiber.New()
|
|
(&S3ApiRouter{
|
|
app: app,
|
|
be: backendWithCorsOnly{},
|
|
iam: &auth.IAMServiceInternal{},
|
|
region: "us-east-1",
|
|
corsAllowOrigin: origin,
|
|
}).Init()
|
|
|
|
req, err := http.NewRequest(http.MethodPut, "/testing?tagging", nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Origin", origin)
|
|
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
|
t.Fatalf("expected Access-Control-Allow-Origin %q, got %q", origin, got)
|
|
}
|
|
}
|
|
|
|
func TestS3ApiRouter_PutObjectTagging_ErrorStillIncludesFallbackCORS(t *testing.T) {
|
|
origin := "http://127.0.0.1:9090"
|
|
|
|
app := fiber.New()
|
|
(&S3ApiRouter{
|
|
app: app,
|
|
be: backendWithCorsOnly{},
|
|
iam: &auth.IAMServiceInternal{},
|
|
region: "us-east-1",
|
|
corsAllowOrigin: origin,
|
|
}).Init()
|
|
|
|
req, err := http.NewRequest(http.MethodPut, "/testing/myobj?tagging", nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Origin", origin)
|
|
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
|
t.Fatalf("expected Access-Control-Allow-Origin %q, got %q", origin, got)
|
|
}
|
|
}
|
|
|
|
func TestS3ApiRouter_CopyObject_ErrorStillIncludesFallbackCORS(t *testing.T) {
|
|
origin := "http://127.0.0.1:9090"
|
|
|
|
app := fiber.New()
|
|
(&S3ApiRouter{
|
|
app: app,
|
|
be: backendWithCorsOnly{},
|
|
iam: &auth.IAMServiceInternal{},
|
|
region: "us-east-1",
|
|
corsAllowOrigin: origin,
|
|
}).Init()
|
|
|
|
req, err := http.NewRequest(http.MethodPut, "/testing/myobj", nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Origin", origin)
|
|
req.Header.Set("X-Amz-Copy-Source", "srcbucket/srckey")
|
|
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
|
t.Fatalf("expected Access-Control-Allow-Origin %q, got %q", origin, got)
|
|
}
|
|
}
|
|
|
|
func TestS3ApiRouter_PutObject_ErrorStillIncludesFallbackCORS(t *testing.T) {
|
|
origin := "http://127.0.0.1:9090"
|
|
|
|
app := fiber.New()
|
|
(&S3ApiRouter{
|
|
app: app,
|
|
be: backendWithCorsOnly{},
|
|
iam: &auth.IAMServiceInternal{},
|
|
region: "us-east-1",
|
|
corsAllowOrigin: origin,
|
|
}).Init()
|
|
|
|
req, err := http.NewRequest(http.MethodPut, "/testing/myobj", nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Origin", origin)
|
|
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
|
t.Fatalf("expected Access-Control-Allow-Origin %q, got %q", origin, got)
|
|
}
|
|
}
|
|
|
|
func TestS3ApiRouter_OptionsWithBucketCORS_NoDuplicateHeaders(t *testing.T) {
|
|
// This test reproduces the issue from GitHub issue #1819
|
|
// where CORS headers were duplicated in OPTIONS responses
|
|
bucketOrigin := "http://localhost:3333"
|
|
fallbackOrigin := "https://fallback.example.com"
|
|
|
|
// CORS configuration matching the issue reproduction steps
|
|
corsConfig := []byte(`<CORSConfiguration>
|
|
<CORSRule>
|
|
<AllowedOrigin>http://localhost:3333</AllowedOrigin>
|
|
<AllowedMethod>GET</AllowedMethod>
|
|
<AllowedMethod>HEAD</AllowedMethod>
|
|
<AllowedMethod>PUT</AllowedMethod>
|
|
<AllowedMethod>POST</AllowedMethod>
|
|
<AllowedHeader>content-length</AllowedHeader>
|
|
<AllowedHeader>content-type</AllowedHeader>
|
|
</CORSRule>
|
|
</CORSConfiguration>`)
|
|
|
|
app := fiber.New()
|
|
(&S3ApiRouter{
|
|
app: app,
|
|
be: backendWithBucketCors{corsConfig: corsConfig},
|
|
iam: &auth.IAMServiceInternal{},
|
|
region: "us-east-1",
|
|
corsAllowOrigin: fallbackOrigin,
|
|
}).Init()
|
|
|
|
req, err := http.NewRequest(http.MethodOptions, "/xyz/upload/test.txt", nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Origin", bucketOrigin)
|
|
req.Header.Set("Access-Control-Request-Method", "PUT")
|
|
req.Header.Set("Access-Control-Request-Headers", "content-type")
|
|
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
|
|
// Verify the response has the correct status
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d", http.StatusOK, resp.StatusCode)
|
|
}
|
|
|
|
// Check that headers are not duplicated
|
|
headers := resp.Header
|
|
|
|
// Helper function to count header occurrences
|
|
countHeader := func(headerName string) int {
|
|
values := headers.Values(headerName)
|
|
return len(values)
|
|
}
|
|
|
|
// Verify each CORS header appears exactly once
|
|
corsHeaders := []string{
|
|
"Access-Control-Allow-Origin",
|
|
"Access-Control-Allow-Methods",
|
|
"Access-Control-Allow-Headers",
|
|
"Access-Control-Allow-Credentials",
|
|
"Vary",
|
|
}
|
|
|
|
for _, header := range corsHeaders {
|
|
count := countHeader(header)
|
|
if count > 1 {
|
|
t.Errorf("Header %q appears %d times (expected 1). Values: %v",
|
|
header, count, headers.Values(header))
|
|
}
|
|
}
|
|
|
|
// Verify the bucket CORS values are used (not the fallback)
|
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != bucketOrigin {
|
|
t.Errorf("expected Access-Control-Allow-Origin %q (from bucket CORS), got %q", bucketOrigin, got)
|
|
}
|
|
|
|
// Verify the requested method is in the allowed methods
|
|
allowedMethods := resp.Header.Get("Access-Control-Allow-Methods")
|
|
if allowedMethods == "" {
|
|
t.Error("Access-Control-Allow-Methods header is empty")
|
|
}
|
|
|
|
// Verify the requested header is in the allowed headers
|
|
allowedHeaders := resp.Header.Get("Access-Control-Allow-Headers")
|
|
if allowedHeaders == "" {
|
|
t.Error("Access-Control-Allow-Headers header is empty")
|
|
}
|
|
}
|