feat: add option for default global cors allow origin headers

There is some desire to have a web dashboard for the gateway. So
that we dont have to proxy all requests through the webserver
and expose credentials over the wire, the better approach would
be to enable CORS headers to allow browser requests directly to
the s3/admin service.

The default for these headers is off, so that they are only
enabled for instances that specfically want to support this
workload.
This commit is contained in:
Ben McClelland
2026-01-05 12:29:17 -08:00
parent f2a75708e4
commit d446102f69
18 changed files with 1145 additions and 102 deletions

View File

@@ -42,6 +42,7 @@ var (
rootUserAccess string
rootUserSecret string
region string
corsAllowOrigin string
admCertFile, admKeyFile string
certFile, keyFile string
kafkaURL, kafkaTopic, kafkaKey string
@@ -188,6 +189,12 @@ func initFlags() []cli.Flag {
Destination: &region,
Aliases: []string{"r"},
},
&cli.StringFlag{
Name: "cors-allow-origin",
Usage: "default CORS Access-Control-Allow-Origin value (applied when no bucket CORS configuration exists, and for admin APIs)",
EnvVars: []string{"VGW_CORS_ALLOW_ORIGIN"},
Destination: &corsAllowOrigin,
},
&cli.StringFlag{
Name: "cert",
Usage: "TLS cert file",
@@ -649,6 +656,9 @@ func runGateway(ctx context.Context, be backend.Backend) error {
}
var opts []s3api.Option
if corsAllowOrigin != "" {
opts = append(opts, s3api.WithCORSAllowOrigin(corsAllowOrigin))
}
if certFile != "" || keyFile != "" {
if certFile == "" {
@@ -786,6 +796,9 @@ func runGateway(ctx context.Context, be backend.Backend) error {
if admPort != "" {
var opts []s3api.AdminOpt
if corsAllowOrigin != "" {
opts = append(opts, s3api.WithAdminCORSAllowOrigin(corsAllowOrigin))
}
if admCertFile != "" || admKeyFile != "" {
if admCertFile == "" {

View File

@@ -26,7 +26,7 @@ import (
type S3AdminRouter struct{}
func (ar *S3AdminRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, root middlewares.RootUserConfig, region string, debug bool) {
func (ar *S3AdminRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, root middlewares.RootUserConfig, region string, debug bool, corsAllowOrigin string) {
ctrl := controllers.NewAdminController(iam, be, logger)
services := &controllers.Services{
Logger: logger,
@@ -37,40 +37,70 @@ func (ar *S3AdminRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMSe
controllers.ProcessHandlers(ctrl.CreateUser, metrics.ActionAdminCreateUser, services,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminCreateUser),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/create-user",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
// DeleteUsers admin api
app.Patch("/delete-user",
controllers.ProcessHandlers(ctrl.DeleteUser, metrics.ActionAdminDeleteUser, services,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminDeleteUser),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/delete-user",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
// UpdateUser admin api
app.Patch("/update-user",
controllers.ProcessHandlers(ctrl.UpdateUser, metrics.ActionAdminUpdateUser, services,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminUpdateUser),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/update-user",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
// ListUsers admin api
app.Patch("/list-users",
controllers.ProcessHandlers(ctrl.ListUsers, metrics.ActionAdminListUsers, services,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminListUsers),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/list-users",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
// ChangeBucketOwner admin api
app.Patch("/change-bucket-owner",
controllers.ProcessHandlers(ctrl.ChangeBucketOwner, metrics.ActionAdminChangeBucketOwner, services,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminChangeBucketOwner),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/change-bucket-owner",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
// ListBucketsAndOwners admin api
app.Patch("/list-buckets",
controllers.ProcessHandlers(ctrl.ListBuckets, metrics.ActionAdminListBuckets, services,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminListBuckets),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/list-buckets",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
}

View File

@@ -28,13 +28,14 @@ import (
)
type S3AdminServer struct {
app *fiber.App
backend backend.Backend
router *S3AdminRouter
port string
cert *tls.Certificate
quiet bool
debug bool
app *fiber.App
backend backend.Backend
router *S3AdminRouter
port string
cert *tls.Certificate
quiet bool
debug bool
corsAllowOrigin string
}
func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, port, region string, iam auth.IAMService, l s3log.AuditLogger, opts ...AdminOpt) *S3AdminServer {
@@ -73,7 +74,7 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, port, r
app.Use(controllers.WrapMiddleware(middlewares.DecodeURL, l, nil))
app.Use(middlewares.DebugLogger())
server.router.Init(app, be, iam, l, root, region, server.debug)
server.router.Init(app, be, iam, l, root, region, server.debug, server.corsAllowOrigin)
return server
}
@@ -94,6 +95,12 @@ func WithAdminDebug() AdminOpt {
return func(s *S3AdminServer) { s.debug = true }
}
// WithAdminCORSAllowOrigin sets the default CORS Access-Control-Allow-Origin value
// for the standalone admin server.
func WithAdminCORSAllowOrigin(origin string) AdminOpt {
return func(s *S3AdminServer) { s.corsAllowOrigin = origin }
}
func (sa *S3AdminServer) Serve() (err error) {
if sa.cert != nil {
return sa.app.ListenTLSWithCertificate(sa.port, *sa.cert)

View File

@@ -18,6 +18,8 @@ import (
"encoding/xml"
"fmt"
"net/http"
"sort"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/auth"
@@ -172,6 +174,7 @@ func ProcessController(ctx *fiber.Ctx, controller Controller, s3action string, s
// Set the response headers
SetResponseHeaders(ctx, response.Headers)
ensureExposeMetaHeaders(ctx)
opts := response.MetaOpts
if opts == nil {
@@ -314,6 +317,77 @@ func ProcessController(ctx *fiber.Ctx, controller Controller, s3action string, s
return ctx.Send(res)
}
func ensureExposeMetaHeaders(ctx *fiber.Ctx) {
// Only attempt to modify expose headers when CORS is actually in use.
if len(ctx.Response().Header.Peek("Access-Control-Allow-Origin")) == 0 {
return
}
existing := strings.TrimSpace(string(ctx.Response().Header.Peek("Access-Control-Expose-Headers")))
if existing == "*" {
return
}
lowerExisting := map[string]struct{}{}
if existing != "" {
for _, part := range strings.Split(existing, ",") {
p := strings.ToLower(strings.TrimSpace(part))
if p != "" {
lowerExisting[p] = struct{}{}
}
}
}
metaNames := map[string]struct{}{}
for k := range ctx.Response().Header.All() {
key := string(k)
if strings.HasPrefix(strings.ToLower(key), "x-amz-meta-") {
metaNames[key] = struct{}{}
}
}
if len(metaNames) == 0 {
// Still ensure ETag is present if any expose headers exist/are needed.
if _, ok := lowerExisting["etag"]; ok {
return
}
if existing == "" {
ctx.Response().Header.Set("Access-Control-Expose-Headers", "ETag")
return
}
ctx.Response().Header.Set("Access-Control-Expose-Headers", existing+", ETag")
return
}
metaList := make([]string, 0, len(metaNames))
for k := range metaNames {
metaList = append(metaList, k)
}
sort.Strings(metaList)
toAdd := make([]string, 0, 1+len(metaList))
if _, ok := lowerExisting["etag"]; !ok {
toAdd = append(toAdd, "ETag")
lowerExisting["etag"] = struct{}{}
}
for _, h := range metaList {
lh := strings.ToLower(h)
if _, ok := lowerExisting[lh]; ok {
continue
}
toAdd = append(toAdd, h)
lowerExisting[lh] = struct{}{}
}
if len(toAdd) == 0 {
return
}
if existing == "" {
ctx.Response().Header.Set("Access-Control-Expose-Headers", strings.Join(toAdd, ", "))
return
}
ctx.Response().Header.Set("Access-Control-Expose-Headers", existing+", "+strings.Join(toAdd, ", "))
}
// Sets the response headers
func SetResponseHeaders(ctx *fiber.Ctx, headers map[string]*string) {
if headers == nil {

View File

@@ -237,6 +237,21 @@ func TestSetResponseHeaders(t *testing.T) {
}
}
func TestEnsureExposeMetaHeaders_AddsActualMetaHeaderNames(t *testing.T) {
app := fiber.New()
ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
ctx.Response().Header.Add("Access-Control-Allow-Origin", "https://example.com")
ctx.Response().Header.Add("Access-Control-Expose-Headers", "ETag")
ctx.Response().Header.Set("x-amz-meta-foo", "bar")
ctx.Response().Header.Set("x-amz-meta-bar", "baz")
ensureExposeMetaHeaders(ctx)
got := string(ctx.Response().Header.Peek("Access-Control-Expose-Headers"))
assert.Equal(t, "ETag, X-Amz-Meta-Bar, X-Amz-Meta-Foo", got)
}
// mock the audit logger
type mockAuditLogger struct {
}

View File

@@ -0,0 +1,92 @@
// 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 controllers
import (
"context"
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3err"
)
func TestApplyBucketCORS_FallbackOrigin_NoBucketCors_NoRequestOrigin(t *testing.T) {
origin := "https://example.com"
mockedBackend := &BackendMock{
GetBucketCorsFunc: func(ctx context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)
},
}
app := fiber.New()
app.Get("/:bucket/test",
middlewares.ApplyBucketCORS(mockedBackend, origin),
func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
},
)
req, err := http.NewRequest(http.MethodGet, "/mybucket/test", 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 to be set to fallback, got %q", got)
}
if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "ETag" {
t.Fatalf("expected Access-Control-Expose-Headers to include ETag, got %q", got)
}
}
func TestApplyBucketCORS_FallbackOrigin_NotAppliedWhenBucketCorsExists(t *testing.T) {
origin := "https://example.com"
mockedBackend := &BackendMock{
GetBucketCorsFunc: func(ctx context.Context, bucket string) ([]byte, error) {
return []byte("not-parsed"), nil
},
}
app := fiber.New()
app.Get("/:bucket/test",
middlewares.ApplyBucketCORS(mockedBackend, origin),
func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
},
)
req, err := http.NewRequest(http.MethodGet, "/mybucket/test", 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 != "" {
t.Fatalf("expected no Access-Control-Allow-Origin when bucket CORS exists, got %q", got)
}
}

View File

@@ -0,0 +1,73 @@
// 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 middlewares
import (
"strings"
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3err"
)
// ApplyBucketCORSPreflightFallback handles CORS preflight (OPTIONS) requests for S3 routes
// when no per-bucket CORS configuration exists.
//
// If the bucket has no CORS configuration and fallbackOrigin is set, it responds with 204 and:
// - Access-Control-Allow-Origin: fallbackOrigin
// - Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method
// - Access-Control-Allow-Methods: mirrors Access-Control-Request-Method (if present)
// - Access-Control-Allow-Headers: mirrors Access-Control-Request-Headers (if present)
//
// If the bucket has a CORS configuration (or fallbackOrigin is blank), it calls next so the
// standard CORS OPTIONS handler can apply bucket-specific rules.
func ApplyBucketCORSPreflightFallback(be backend.Backend, fallbackOrigin string) fiber.Handler {
fallbackOrigin = strings.TrimSpace(fallbackOrigin)
if fallbackOrigin == "" {
return func(ctx *fiber.Ctx) error { return ctx.Next() }
}
return func(ctx *fiber.Ctx) error {
bucket := ctx.Params("bucket")
_, err := be.GetBucketCors(ctx.Context(), bucket)
if err != nil {
if s3Err, ok := err.(s3err.APIError); ok && (s3Err.Code == "NoSuchCORSConfiguration" || s3Err.Code == "NoSuchBucket") {
if len(ctx.Response().Header.Peek("Access-Control-Allow-Origin")) == 0 {
ctx.Response().Header.Add("Access-Control-Allow-Origin", fallbackOrigin)
}
if len(ctx.Response().Header.Peek("Vary")) == 0 {
ctx.Response().Header.Add("Vary", VaryHdr)
}
if reqMethod := strings.TrimSpace(ctx.Get("Access-Control-Request-Method")); reqMethod != "" {
if len(ctx.Response().Header.Peek("Access-Control-Allow-Methods")) == 0 {
ctx.Response().Header.Add("Access-Control-Allow-Methods", reqMethod)
}
}
if reqHeaders := strings.TrimSpace(ctx.Get("Access-Control-Request-Headers")); reqHeaders != "" {
if len(ctx.Response().Header.Peek("Access-Control-Allow-Headers")) == 0 {
ctx.Response().Header.Add("Access-Control-Allow-Headers", reqHeaders)
}
}
ctx.Status(fiber.StatusNoContent)
return nil
}
}
return ctx.Next()
}
}

View File

@@ -0,0 +1,146 @@
// 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 middlewares
import (
"context"
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3err"
)
type backendWithGetBucketCors struct {
backend.BackendUnsupported
getBucketCors func(ctx context.Context, bucket string) ([]byte, error)
}
func (b backendWithGetBucketCors) GetBucketCors(ctx context.Context, bucket string) ([]byte, error) {
return b.getBucketCors(ctx, bucket)
}
func TestApplyBucketCORSPreflightFallback_NoBucketCors_Responds204(t *testing.T) {
be := backendWithGetBucketCors{
getBucketCors: func(ctx context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)
},
}
app := fiber.New()
app.Options("/:bucket",
ApplyBucketCORSPreflightFallback(be, "https://example.com"),
func(c *fiber.Ctx) error {
// Should not be reached if fallback triggers
return c.SendStatus(http.StatusTeapot)
},
)
req, err := http.NewRequest(http.MethodOptions, "/testing", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Origin", "https://request-origin.example")
req.Header.Set("Access-Control-Request-Method", "GET")
req.Header.Set("Access-Control-Request-Headers", "content-type")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Fatalf("expected allow origin fallback, got %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET" {
t.Fatalf("expected allow methods to mirror request, got %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Headers"); got != "content-type" {
t.Fatalf("expected allow headers to mirror request, got %q", got)
}
}
func TestApplyBucketCORSPreflightFallback_NoSuchBucket_Responds204(t *testing.T) {
be := backendWithGetBucketCors{
getBucketCors: func(ctx context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucket)
},
}
app := fiber.New()
app.Options("/:bucket",
ApplyBucketCORSPreflightFallback(be, "https://example.com"),
func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusTeapot)
},
)
req, err := http.NewRequest(http.MethodOptions, "/testing", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Origin", "https://request-origin.example")
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)
}
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Fatalf("expected allow origin fallback, got %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "PUT" {
t.Fatalf("expected allow methods to mirror request, got %q", got)
}
}
func TestApplyBucketCORSPreflightFallback_BucketHasCors_CallsNext(t *testing.T) {
be := backendWithGetBucketCors{
getBucketCors: func(ctx context.Context, bucket string) ([]byte, error) {
return []byte("dummy"), nil
},
}
app := fiber.New()
app.Options("/:bucket",
ApplyBucketCORSPreflightFallback(be, "https://example.com"),
func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
},
)
req, err := http.NewRequest(http.MethodOptions, "/testing", 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 resp.StatusCode != http.StatusOK {
t.Fatalf("expected status 200 from next handler, got %d", resp.StatusCode)
}
}

View File

@@ -16,6 +16,7 @@ package middlewares
import (
"fmt"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/auth"
@@ -31,12 +32,14 @@ var VaryHdr = "Origin, Access-Control-Request-Headers, Access-Control-Request-Me
// checks if origin and method meets the cors rules and
// adds the necessary response headers.
// CORS check is applied only when 'Origin' request header is present
func ApplyBucketCORS(be backend.Backend) fiber.Handler {
func ApplyBucketCORS(be backend.Backend, fallbackOrigin string) fiber.Handler {
fallbackOrigin = strings.TrimSpace(fallbackOrigin)
return func(ctx *fiber.Ctx) error {
bucket := ctx.Params("bucket")
origin := ctx.Get("Origin")
// if the origin request header is empty, skip cors validation
if origin == "" {
// If neither Origin is present nor a fallback is configured, skip CORS entirely.
if origin == "" && fallbackOrigin == "" {
return nil
}
@@ -46,12 +49,32 @@ func ApplyBucketCORS(be backend.Backend) fiber.Handler {
// If CORS is not configured, S3Error will have code NoSuchCORSConfiguration.
// In this case, we can safely continue. For any other error, we should log it.
s3Err, ok := err.(s3err.APIError)
if ok && (s3Err.Code == "NoSuchCORSConfiguration" || s3Err.Code == "NoSuchBucket") {
// Optional global fallback: add Access-Control-Allow-Origin for buckets
// without a specific CORS configuration.
if fallbackOrigin != "" {
if len(ctx.Response().Header.Peek("Access-Control-Allow-Origin")) == 0 {
ctx.Response().Header.Add("Access-Control-Allow-Origin", fallbackOrigin)
}
if len(ctx.Response().Header.Peek("Vary")) == 0 {
ctx.Response().Header.Add("Vary", VaryHdr)
}
ensureExposeETag(ctx)
}
return nil
}
if !ok || s3Err.Code != "NoSuchCORSConfiguration" {
debuglogger.Logf("failed to get bucket cors for bucket %q: %v", bucket, err)
}
return nil
}
// If Origin is missing, don't attempt per-bucket CORS evaluation.
// (Fallback has already been handled above for buckets without CORS config.)
if origin == "" {
return nil
}
cors, err := auth.ParseCORSOutput(data)
if err != nil {
return nil
@@ -100,6 +123,9 @@ func ApplyBucketCORS(be backend.Backend) fiber.Handler {
}
}
// Always expose ETag and user metadata headers for browser clients.
ensureExposeETag(ctx)
return nil
}
}

View File

@@ -0,0 +1,58 @@
// 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 middlewares
import (
"strings"
"github.com/gofiber/fiber/v2"
)
// ApplyDefaultCORSPreflight responds to CORS preflight (OPTIONS) requests for routes
// that don't have per-bucket CORS configuration (e.g. admin APIs).
//
// It uses the provided fallbackOrigin as the Access-Control-Allow-Origin value.
// It mirrors Access-Control-Request-Method into Access-Control-Allow-Methods and
// mirrors Access-Control-Request-Headers into Access-Control-Allow-Headers.
func ApplyDefaultCORSPreflight(fallbackOrigin string) fiber.Handler {
fallbackOrigin = strings.TrimSpace(fallbackOrigin)
if fallbackOrigin == "" {
return func(ctx *fiber.Ctx) error { return nil }
}
return func(ctx *fiber.Ctx) error {
if len(ctx.Response().Header.Peek("Access-Control-Allow-Origin")) == 0 {
ctx.Response().Header.Add("Access-Control-Allow-Origin", fallbackOrigin)
}
if len(ctx.Response().Header.Peek("Vary")) == 0 {
ctx.Response().Header.Add("Vary", VaryHdr)
}
if reqMethod := strings.TrimSpace(ctx.Get("Access-Control-Request-Method")); reqMethod != "" {
if len(ctx.Response().Header.Peek("Access-Control-Allow-Methods")) == 0 {
ctx.Response().Header.Add("Access-Control-Allow-Methods", reqMethod)
}
}
if reqHeaders := strings.TrimSpace(ctx.Get("Access-Control-Request-Headers")); reqHeaders != "" {
if len(ctx.Response().Header.Peek("Access-Control-Allow-Headers")) == 0 {
ctx.Response().Header.Add("Access-Control-Allow-Headers", reqHeaders)
}
}
ctx.Status(fiber.StatusNoContent)
return nil
}
}

View File

@@ -0,0 +1,59 @@
// 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 middlewares
import (
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestApplyDefaultCORSPreflight_OptionsSetsPreflightHeaders(t *testing.T) {
origin := "https://example.com"
app := fiber.New()
app.Options("/admin",
ApplyDefaultCORSPreflight(origin),
ApplyDefaultCORS(origin),
func(c *fiber.Ctx) error { return nil },
)
req, err := http.NewRequest(http.MethodOptions, "/admin", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Origin", "https://request-origin.example")
req.Header.Set("Access-Control-Request-Method", "PATCH")
req.Header.Set("Access-Control-Request-Headers", "content-type,authorization")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
t.Fatalf("expected allow origin fallback, got %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "PATCH" {
t.Fatalf("expected allow methods to mirror request, got %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Headers"); got != "content-type,authorization" {
t.Fatalf("expected allow headers to mirror request, got %q", got)
}
}

View File

@@ -0,0 +1,73 @@
// 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 middlewares
import (
"strings"
"github.com/gofiber/fiber/v2"
)
func ensureExposeETag(ctx *fiber.Ctx) {
existing := strings.TrimSpace(string(ctx.Response().Header.Peek("Access-Control-Expose-Headers")))
defaults := []string{"ETag"}
if existing == "" {
ctx.Response().Header.Add("Access-Control-Expose-Headers", strings.Join(defaults, ", "))
return
}
lowerExisting := map[string]struct{}{}
for _, part := range strings.Split(existing, ",") {
p := strings.ToLower(strings.TrimSpace(part))
if p != "" {
lowerExisting[p] = struct{}{}
}
}
updated := existing
for _, h := range defaults {
if _, ok := lowerExisting[strings.ToLower(h)]; ok {
continue
}
updated += ", " + h
}
if updated != existing {
ctx.Response().Header.Set("Access-Control-Expose-Headers", updated)
}
}
// ApplyDefaultCORS adds a default Access-Control-Allow-Origin header to responses
// when the provided fallbackOrigin is non-empty.
//
// This is intended for routes that don't have per-bucket CORS configuration (e.g. admin APIs).
// It will not override an existing Access-Control-Allow-Origin header.
func ApplyDefaultCORS(fallbackOrigin string) fiber.Handler {
fallbackOrigin = strings.TrimSpace(fallbackOrigin)
if fallbackOrigin == "" {
return func(ctx *fiber.Ctx) error { return nil }
}
return func(ctx *fiber.Ctx) error {
if len(ctx.Response().Header.Peek("Access-Control-Allow-Origin")) == 0 {
ctx.Response().Header.Add("Access-Control-Allow-Origin", fallbackOrigin)
}
if len(ctx.Response().Header.Peek("Vary")) == 0 {
ctx.Response().Header.Add("Vary", VaryHdr)
}
ensureExposeETag(ctx)
return nil
}
}

View File

@@ -0,0 +1,74 @@
// 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 middlewares
import (
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestApplyDefaultCORS_AddsHeaderWhenOriginSet(t *testing.T) {
origin := "https://example.com"
app := fiber.New()
app.Get("/admin", ApplyDefaultCORS(origin), func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
})
req, err := http.NewRequest(http.MethodGet, "/admin", 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 fallback origin header, got %q", got)
}
if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "ETag" {
t.Fatalf("expected expose headers to include ETag, got %q", got)
}
}
func TestApplyDefaultCORS_DoesNotOverrideExistingHeader(t *testing.T) {
origin := "https://example.com"
app := fiber.New()
app.Get("/admin", func(c *fiber.Ctx) error {
c.Response().Header.Add("Access-Control-Allow-Origin", "https://already-set.com")
return nil
}, ApplyDefaultCORS(origin), func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
})
req, err := http.NewRequest(http.MethodGet, "/admin", 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 != "https://already-set.com" {
t.Fatalf("expected existing header to remain, got %q", got)
}
}

View File

@@ -30,7 +30,7 @@ type S3ApiRouter struct {
WithAdmSrv bool
}
func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, aLogger s3log.AuditLogger, evs s3event.S3EventSender, mm metrics.Manager, readonly bool, region, virtualDomain string, root middlewares.RootUserConfig) {
func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, aLogger s3log.AuditLogger, evs s3event.S3EventSender, mm metrics.Manager, readonly bool, region, virtualDomain string, root middlewares.RootUserConfig, corsAllowOrigin string) {
ctrl := controllers.New(be, iam, logger, evs, mm, readonly, virtualDomain)
adminServices := &controllers.Services{
Logger: aLogger,
@@ -44,42 +44,72 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
controllers.ProcessHandlers(adminController.CreateUser, metrics.ActionAdminCreateUser, adminServices,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminCreateUser),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/create-user",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
// DeleteUsers admin api
app.Patch("/delete-user",
controllers.ProcessHandlers(adminController.DeleteUser, metrics.ActionAdminDeleteUser, adminServices,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminDeleteUser),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/delete-user",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
// UpdateUser admin api
app.Patch("/update-user",
controllers.ProcessHandlers(adminController.UpdateUser, metrics.ActionAdminUpdateUser, adminServices,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminUpdateUser),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/update-user",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
// ListUsers admin api
app.Patch("/list-users",
controllers.ProcessHandlers(adminController.ListUsers, metrics.ActionAdminListUsers, adminServices,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminListUsers),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/list-users",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
// ChangeBucketOwner admin api
app.Patch("/change-bucket-owner",
controllers.ProcessHandlers(adminController.ChangeBucketOwner, metrics.ActionAdminChangeBucketOwner, adminServices,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminChangeBucketOwner),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/change-bucket-owner",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
// ListBucketsAndOwners admin api
app.Patch("/list-buckets",
controllers.ProcessHandlers(adminController.ListBuckets, metrics.ActionAdminListBuckets, adminServices,
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.IsAdmin(metrics.ActionAdminListBuckets),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
))
app.Options("/list-buckets",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
}
services := &controllers.Services{
@@ -92,7 +122,12 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
// copy source is not allowed on '/'
app.Get("/", middlewares.MatchHeader("X-Amz-Copy-Source"),
controllers.ProcessHandlers(ctrl.HandleErrorRoute(s3err.GetAPIError(s3err.ErrCopySourceNotAllowed)), metrics.ActionUndetected, services),
controllers.ProcessHandlers(
ctrl.HandleErrorRoute(s3err.GetAPIError(s3err.ErrCopySourceNotAllowed)),
metrics.ActionUndetected,
services,
middlewares.ApplyDefaultCORS(corsAllowOrigin),
),
)
app.Get("/",
@@ -100,11 +135,17 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
ctrl.ListBuckets,
metrics.ActionListAllMyBuckets,
services,
middlewares.ApplyDefaultCORS(corsAllowOrigin),
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionListAllMyBuckets, "", auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
))
app.Options("/",
middlewares.ApplyDefaultCORSPreflight(corsAllowOrigin),
middlewares.ApplyDefaultCORS(corsAllowOrigin),
)
bucketRouter := app.Group("/:bucket")
objectRouter := app.Group("/:bucket/*")
@@ -116,12 +157,12 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
metrics.ActionPutBucketTagging,
services,
middlewares.BucketObjectNameValidator(),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionPutBucketTagging, auth.PutBucketTaggingAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, true, true),
middlewares.ParseAcl(be),
middlewares.ApplyBucketCORS(be),
))
bucketRouter.Put("",
middlewares.MatchQueryArgs("ownershipControls"),
@@ -134,7 +175,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, true, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Put("",
@@ -148,7 +189,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, true, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Put("",
@@ -162,7 +203,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, true, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Put("",
@@ -176,7 +217,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, true, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Put("",
@@ -190,7 +231,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, false, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Put("",
@@ -204,7 +245,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, false, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Put("",
@@ -386,7 +427,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, false, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
))
// HeadBucket action
@@ -401,12 +442,11 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
ctrl.HeadBucket,
metrics.ActionHeadBucket,
services,
middlewares.ApplyBucketCORS(be),
middlewares.BucketObjectNameValidator(),
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionHeadBucket, auth.ListBucketAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
@@ -427,7 +467,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionDeleteBucketTagging, auth.PutBucketTaggingAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Delete("",
@@ -440,7 +480,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionDeleteBucketOwnershipControls, auth.PutBucketOwnershipControlsAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Delete("",
@@ -453,7 +493,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionDeleteBucketPolicy, auth.PutBucketPolicyAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Delete("",
@@ -466,7 +506,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionDeleteBucketCors, auth.PutBucketCorsAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Delete("",
@@ -595,7 +635,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionDeleteBucket, auth.DeleteBucketAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
@@ -616,7 +656,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetBucketLocation, auth.GetBucketLocationAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
),
)
@@ -630,7 +670,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetBucketTagging, auth.GetBucketTaggingAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Get("",
@@ -643,7 +683,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetBucketOwnershipControls, auth.GetBucketOwnershipControlsAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Get("",
@@ -656,7 +696,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetBucketVersioning, auth.GetBucketVersioningAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Get("",
@@ -669,7 +709,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetBucketPolicy, auth.GetBucketPolicyAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Get("",
@@ -682,7 +722,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetBucketCors, auth.GetBucketCorsAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Get("",
@@ -695,7 +735,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetObjectLockConfiguration, auth.GetBucketObjectLockConfigurationAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Get("",
@@ -708,7 +748,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetBucketAcl, auth.GetBucketAclAction, auth.PermissionReadAcp, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Get("",
@@ -721,7 +761,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionListMultipartUploads, auth.ListBucketMultipartUploadsAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Get("",
@@ -734,7 +774,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionListObjectVersions, auth.ListBucketVersionsAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Get("",
@@ -747,7 +787,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetBucketPolicyStatus, auth.GetBucketPolicyStatusAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Get("",
@@ -981,7 +1021,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionListObjectsV2, auth.ListBucketAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
bucketRouter.Get("",
@@ -993,7 +1033,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionListObjects, auth.ListBucketAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
@@ -1016,7 +1056,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, true, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
@@ -1036,7 +1076,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionHeadObject, auth.GetObjectAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
@@ -1070,7 +1110,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetObjectTagging, auth.GetObjectTaggingAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Get("",
@@ -1083,7 +1123,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetObjectRetention, auth.GetObjectRetentionAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Get("",
@@ -1096,7 +1136,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetObjectLegalHold, auth.GetObjectLegalHoldAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Get("",
@@ -1109,7 +1149,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetObjectAcl, auth.GetObjectAclAction, auth.PermissionReadAcp, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Get("",
@@ -1122,7 +1162,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetObjectAttributes, auth.GetObjectAttributesAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Get("",
@@ -1135,7 +1175,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionListParts, auth.ListMultipartUploadPartsAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Get("",
@@ -1147,7 +1187,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionGetObject, auth.GetObjectAction, auth.PermissionRead, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
@@ -1169,7 +1209,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionDeleteObjectTagging, auth.DeleteObjectTaggingAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Delete("",
@@ -1182,7 +1222,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionAbortMultipartUpload, auth.AbortMultipartUploadAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Delete("",
@@ -1194,7 +1234,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionDeleteObject, auth.DeleteObjectAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
@@ -1218,7 +1258,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, false, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Post("",
@@ -1233,7 +1273,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, false, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Post("",
@@ -1246,7 +1286,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionCompleteMultipartUpload, auth.PutObjectAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Post("",
@@ -1259,7 +1299,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionCreateMultipartUpload, auth.PutObjectAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
@@ -1271,11 +1311,11 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
metrics.ActionPutObjectTagging,
services,
middlewares.BucketObjectNameValidator(),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionPutObjectTagging, auth.PutObjectTaggingAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, true, false),
middlewares.ApplyBucketCORS(be),
middlewares.ParseAcl(be),
))
objectRouter.Put("",
@@ -1289,7 +1329,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Put("",
@@ -1303,7 +1343,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Put("",
@@ -1317,7 +1357,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.VerifyChecksums(false, false, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Put("",
@@ -1331,7 +1371,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionUploadPartCopy, auth.PutObjectAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
objectRouter.Put("",
@@ -1345,7 +1385,7 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
middlewares.VerifyPresignedV4Signature(root, iam, region, true),
middlewares.VerifyV4Signature(root, iam, region, true, true),
middlewares.VerifyChecksums(true, false, false),
middlewares.ApplyBucketCORS(be),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
))
@@ -1367,10 +1407,10 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
metrics.ActionCopyObject,
services,
middlewares.BucketObjectNameValidator(),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionCopyObject, auth.PutObjectAction, auth.PermissionWrite, region, false),
middlewares.VerifyPresignedV4Signature(root, iam, region, false),
middlewares.VerifyV4Signature(root, iam, region, false, true),
middlewares.ApplyBucketCORS(be),
middlewares.ParseAcl(be),
))
objectRouter.Put("",
@@ -1379,18 +1419,31 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ
metrics.ActionPutObject,
services,
middlewares.BucketObjectNameValidator(),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.AuthorizePublicBucketAccess(be, metrics.ActionPutObject, auth.PutObjectAction, auth.PermissionWrite, region, true),
middlewares.VerifyPresignedV4Signature(root, iam, region, true),
middlewares.VerifyV4Signature(root, iam, region, true, true),
middlewares.VerifyChecksums(true, false, false),
middlewares.ApplyBucketCORS(be),
middlewares.ParseAcl(be),
))
app.Options("/:bucket/*", controllers.ProcessHandlers(ctrl.CORSOptions, metrics.ActionOptions, services,
middlewares.BucketObjectNameValidator(),
middlewares.ParseAcl(be),
))
app.Options("/:bucket",
middlewares.ApplyBucketCORSPreflightFallback(be, corsAllowOrigin),
controllers.ProcessHandlers(ctrl.CORSOptions, metrics.ActionOptions, services,
middlewares.BucketObjectNameValidator(),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
),
)
app.Options("/:bucket/*",
middlewares.ApplyBucketCORSPreflightFallback(be, corsAllowOrigin),
controllers.ProcessHandlers(ctrl.CORSOptions, metrics.ActionOptions, services,
middlewares.BucketObjectNameValidator(),
middlewares.ApplyBucketCORS(be, corsAllowOrigin),
middlewares.ParseAcl(be),
),
)
// Return MethodNotAllowed for all the unmatched routes
app.All("*", controllers.ProcessHandlers(ctrl.HandleErrorRoute(s3err.GetAPIError(s3err.ErrMethodNotAllowed)), metrics.ActionUndetected, services))

253
s3api/router_cors_test.go Normal file
View File

@@ -0,0 +1,253 @@
// 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"
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3api/middlewares"
"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)
}
func TestS3ApiRouter_ListBuckets_DefaultCORSAllowOrigin(t *testing.T) {
origin := "https://example.com"
app := fiber.New()
(&S3ApiRouter{}).Init(
app,
backend.BackendUnsupported{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
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{}).Init(
app,
backend.BackendUnsupported{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
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{}).Init(
app,
backendWithCorsOnly{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
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{}).Init(
app,
backendWithCorsOnly{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
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{}).Init(
app,
backendWithCorsOnly{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
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{}).Init(
app,
backendWithCorsOnly{},
&auth.IAMServiceInternal{},
nil,
nil,
nil,
nil,
false,
"us-east-1",
"",
middlewares.RootUserConfig{},
origin,
)
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)
}
}

View File

@@ -46,7 +46,7 @@ func TestS3ApiRouter_Init(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.sa.Init(tt.args.app, tt.args.be, tt.args.iam, nil, nil, nil, nil, false, "us-east-1", "", middlewares.RootUserConfig{})
tt.sa.Init(tt.args.app, tt.args.be, tt.args.iam, nil, nil, nil, nil, false, "us-east-1", "", middlewares.RootUserConfig{}, "")
})
}
}

View File

@@ -41,16 +41,17 @@ const (
)
type S3ApiServer struct {
app *fiber.App
backend backend.Backend
router *S3ApiRouter
port string
cert *tls.Certificate
quiet bool
readonly bool
keepAlive bool
health string
virtualDomain string
app *fiber.App
backend backend.Backend
router *S3ApiRouter
port string
cert *tls.Certificate
quiet bool
readonly bool
keepAlive bool
health string
virtualDomain string
corsAllowOrigin string
}
func New(
@@ -123,7 +124,7 @@ func New(
app.Use(middlewares.DebugLogger())
}
server.router.Init(app, be, iam, l, adminLogger, evs, mm, server.readonly, region, server.virtualDomain, root)
server.router.Init(app, be, iam, l, adminLogger, evs, mm, server.readonly, region, server.virtualDomain, root, server.corsAllowOrigin)
return server, nil
}
@@ -165,6 +166,12 @@ func WithKeepAlive() Option {
return func(s *S3ApiServer) { s.keepAlive = true }
}
// WithCORSAllowOrigin sets the default CORS Access-Control-Allow-Origin value.
// This is applied when no bucket CORS configuration exists, and for admin APIs.
func WithCORSAllowOrigin(origin string) Option {
return func(s *S3ApiServer) { s.corsAllowOrigin = origin }
}
func (sa *S3ApiServer) Serve() (err error) {
if sa.cert != nil {
return sa.app.ListenTLSWithCertificate(sa.port, *sa.cert)

View File

@@ -222,13 +222,6 @@ func PreflightOPTIONS_access_granted(s *S3Conf) error {
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)),
},
},
},
})
@@ -245,22 +238,19 @@ func PreflightOPTIONS_access_granted(s *S3Conf) error {
result PreflightResult
}{
// first rule matches
{"http://example.com", http.MethodGet, "X-Amz-Date", PreflightResult{"http://example.com", "GET, HEAD", "x-amz-date", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
{"http://example.com", http.MethodGet, "X-Amz-Content-Sha256", PreflightResult{"http://example.com", "GET, HEAD", "x-amz-content-sha256", "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", "x-amz-date, x-amz-content-sha256", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
{"http://example.com", http.MethodGet, "X-Amz-Date", PreflightResult{"http://example.com", "GET, HEAD", "x-amz-date", "Content-Type, Content-Length, ETag", "100", "true", varyHdr, nil}},
{"http://example.com", http.MethodGet, "X-Amz-Content-Sha256", PreflightResult{"http://example.com", "GET, HEAD", "x-amz-content-sha256", "Content-Type, Content-Length, ETag", "100", "true", varyHdr, nil}},
{"http://example.com", http.MethodHead, "", PreflightResult{"http://example.com", "GET, HEAD", "", "Content-Type, Content-Length, ETag", "100", "true", varyHdr, nil}},
{"https://example.com", http.MethodGet, "X-Amz-Date,X-Amz-Content-Sha256", PreflightResult{"https://example.com", "GET, HEAD", "x-amz-date, x-amz-content-sha256", "Content-Type, Content-Length, ETag", "100", "true", varyHdr, nil}},
// second rule matches: origin is a wildcard
{"http://anything.com", http.MethodHead, "X-Amz-Meta-Something", PreflightResult{"*", "HEAD", "x-amz-meta-something", "", "", "false", varyHdr, nil}},
{"hello.com", http.MethodHead, "", PreflightResult{"*", "HEAD", "", "", "", "false", varyHdr, nil}},
{"http://anything.com", http.MethodHead, "X-Amz-Meta-Something", PreflightResult{"*", "HEAD", "x-amz-meta-something", "ETag", "", "false", varyHdr, nil}},
{"hello.com", http.MethodHead, "", PreflightResult{"*", "HEAD", "", "ETag", "", "false", varyHdr, nil}},
// third rule matches
{"something.net", http.MethodPut, "Authorization", PreflightResult{"something.net", "POST, PUT", "authorization", "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}},
{"something.net", http.MethodPut, "Authorization", PreflightResult{"something.net", "POST, PUT", "authorization", "Content-Disposition, Content-Encoding, ETag", "3000", "true", varyHdr, nil}},
{"something.net", http.MethodPost, "", PreflightResult{"something.net", "POST, PUT", "", "Content-Disposition, Content-Encoding, ETag", "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-server-side-encryption", "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", "x-amz-anything-suffix", "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", "x-amz-yyy-xxx-suffix", "Authorization, Content-Type", "2000", "true", varyHdr, nil}},
{"http://www.hello.world.com", http.MethodGet, "", PreflightResult{"http://www.hello.world.com", "GET", "", "X-Amz-Expected-Bucket-Owner, ETag", "5000", "true", varyHdr, nil}},
{"http://www.example.com", http.MethodGet, "x-amz-server-side-encryption", PreflightResult{"http://www.example.com", "GET", "x-amz-server-side-encryption", "X-Amz-Expected-Bucket-Owner, ETag", "5000", "true", varyHdr, nil}},
} {
err := testOPTIONSEdnpoint(s, bucket, test.origin, test.method, test.headers, &test.result)
if err != nil {