mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 08:24:17 +00:00
359 lines
10 KiB
Go
359 lines
10 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/v3"
|
|
"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)
|
|
}
|
|
req.Host = "localhost"
|
|
|
|
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.Host = "localhost"
|
|
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.Host = "localhost"
|
|
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_PutObjectLockConfiguration_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?object-lock=", nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Host = "localhost"
|
|
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.Host = "localhost"
|
|
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.Host = "localhost"
|
|
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.Host = "localhost"
|
|
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.Host = "localhost"
|
|
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")
|
|
}
|
|
}
|