diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index 74942a25..67626d94 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -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: ®ion, 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 == "" { diff --git a/s3api/admin-router.go b/s3api/admin-router.go index 5704220f..b0534b96 100644 --- a/s3api/admin-router.go +++ b/s3api/admin-router.go @@ -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), + ) } diff --git a/s3api/admin-server.go b/s3api/admin-server.go index c336e3d9..16fdec96 100644 --- a/s3api/admin-server.go +++ b/s3api/admin-server.go @@ -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) diff --git a/s3api/controllers/base.go b/s3api/controllers/base.go index fabf8d79..4622c406 100644 --- a/s3api/controllers/base.go +++ b/s3api/controllers/base.go @@ -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 { diff --git a/s3api/controllers/base_test.go b/s3api/controllers/base_test.go index 13b6aaa3..4b7a583a 100644 --- a/s3api/controllers/base_test.go +++ b/s3api/controllers/base_test.go @@ -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 { } diff --git a/s3api/controllers/cors_default_origin_test.go b/s3api/controllers/cors_default_origin_test.go new file mode 100644 index 00000000..4d198399 --- /dev/null +++ b/s3api/controllers/cors_default_origin_test.go @@ -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) + } +} diff --git a/s3api/middlewares/apply-bucket-cors-preflight.go b/s3api/middlewares/apply-bucket-cors-preflight.go new file mode 100644 index 00000000..8ffa99c4 --- /dev/null +++ b/s3api/middlewares/apply-bucket-cors-preflight.go @@ -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() + } +} diff --git a/s3api/middlewares/apply-bucket-cors-preflight_test.go b/s3api/middlewares/apply-bucket-cors-preflight_test.go new file mode 100644 index 00000000..776bfae4 --- /dev/null +++ b/s3api/middlewares/apply-bucket-cors-preflight_test.go @@ -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) + } +} diff --git a/s3api/middlewares/apply-bucket-cors.go b/s3api/middlewares/apply-bucket-cors.go index 48445fbd..930dd161 100644 --- a/s3api/middlewares/apply-bucket-cors.go +++ b/s3api/middlewares/apply-bucket-cors.go @@ -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 } } diff --git a/s3api/middlewares/apply-default-cors-preflight.go b/s3api/middlewares/apply-default-cors-preflight.go new file mode 100644 index 00000000..dc8cf018 --- /dev/null +++ b/s3api/middlewares/apply-default-cors-preflight.go @@ -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 + } +} diff --git a/s3api/middlewares/apply-default-cors-preflight_test.go b/s3api/middlewares/apply-default-cors-preflight_test.go new file mode 100644 index 00000000..ae0ebfff --- /dev/null +++ b/s3api/middlewares/apply-default-cors-preflight_test.go @@ -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) + } +} diff --git a/s3api/middlewares/apply-default-cors.go b/s3api/middlewares/apply-default-cors.go new file mode 100644 index 00000000..afb40731 --- /dev/null +++ b/s3api/middlewares/apply-default-cors.go @@ -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 + } +} diff --git a/s3api/middlewares/apply-default-cors_test.go b/s3api/middlewares/apply-default-cors_test.go new file mode 100644 index 00000000..df181c2c --- /dev/null +++ b/s3api/middlewares/apply-default-cors_test.go @@ -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) + } +} diff --git a/s3api/router.go b/s3api/router.go index ae6e078a..bde5e05a 100644 --- a/s3api/router.go +++ b/s3api/router.go @@ -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)) diff --git a/s3api/router_cors_test.go b/s3api/router_cors_test.go new file mode 100644 index 00000000..2999e4cc --- /dev/null +++ b/s3api/router_cors_test.go @@ -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) + } +} diff --git a/s3api/router_test.go b/s3api/router_test.go index 4205f12f..c54451bb 100644 --- a/s3api/router_test.go +++ b/s3api/router_test.go @@ -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{}, "") }) } } diff --git a/s3api/server.go b/s3api/server.go index 16e60383..628358c2 100644 --- a/s3api/server.go +++ b/s3api/server.go @@ -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) diff --git a/tests/integration/OPTIONS.go b/tests/integration/OPTIONS.go index febdf30b..4e1c132e 100644 --- a/tests/integration/OPTIONS.go +++ b/tests/integration/OPTIONS.go @@ -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 {