mirror of
https://github.com/versity/versitygw.git
synced 2026-09-20 15:04:27 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user