From e73c23a4eedb79f1a0e00cd6b2b37e3c0a57b0b4 Mon Sep 17 00:00:00 2001 From: Mohd Quamar Tyagi <104281681+Tyagiquamar@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:40:49 +0530 Subject: [PATCH] fix(s3): set Access-Control-Allow-Credentials on CORS responses (#11886) * fix(s3): set Access-Control-Allow-Credentials on CORS responses (fixes #11885) * fix(s3): expand wildcard exposed headers for credentialed CORS responses * fix(s3): keep wildcard in exposed headers for non-credentialed CORS requests * fix(s3): expose x-amz-tagging-count on credentialed CORS responses --------- Co-authored-by: Chris Lu --- weed/s3api/cors/cors.go | 50 ++++++++++++++++++++++-- weed/s3api/cors/cors_test.go | 74 +++++++++++++++++++++++++----------- 2 files changed, 98 insertions(+), 26 deletions(-) diff --git a/weed/s3api/cors/cors.go b/weed/s3api/cors/cors.go index d6eb520af0..8562467304 100644 --- a/weed/s3api/cors/cors.go +++ b/weed/s3api/cors/cors.go @@ -235,13 +235,15 @@ func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse // Set exposed headers if len(rule.ExposeHeaders) > 0 { - response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ") + response.ExposeHeaders = formatExposeHeaders(rule.ExposeHeaders) } // Set max age if rule.MaxAgeSeconds != nil { response.MaxAge = strconv.Itoa(*rule.MaxAgeSeconds) } + + response.AllowCredentials = true } return response @@ -250,7 +252,8 @@ func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse // buildResponse builds a CORS response from a matching rule func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse { response := &CORSResponse{ - AllowOrigin: corsReq.Origin, + AllowOrigin: corsReq.Origin, + AllowCredentials: true, } // Set allowed methods @@ -263,7 +266,7 @@ func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse { // Set expose headers if len(rule.ExposeHeaders) > 0 { - response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ") + response.ExposeHeaders = formatExposeHeaders(rule.ExposeHeaders) } // Set max age @@ -274,6 +277,47 @@ func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse { return response } +// defaultExposeHeaders are emitted alongside "*" so credentialed CORS requests +// can read standard response headers ("*" is treated literally when credentials +// are included, per the Fetch spec). +var defaultExposeHeaders = []string{ + "ETag", + "Content-Length", + "Content-Type", + "Last-Modified", + "Content-Range", + "x-amz-request-id", + "x-amz-version-id", + "x-amz-tagging-count", +} + +// formatExposeHeaders formats the exposed headers slice into a comma-separated string. +func formatExposeHeaders(exposeHeaders []string) string { + var headers []string + hasWildcard := false + seen := map[string]bool{} + for _, h := range exposeHeaders { + if h == "*" { + hasWildcard = true + continue + } + if lower := strings.ToLower(h); !seen[lower] { + seen[lower] = true + headers = append(headers, h) + } + } + if hasWildcard { + for _, h := range defaultExposeHeaders { + if lower := strings.ToLower(h); !seen[lower] { + seen[lower] = true + headers = append(headers, h) + } + } + headers = append(headers, "*") + } + return strings.Join(headers, ", ") +} + // Helper functions // matchesOrigin checks if the request origin matches any allowed origin diff --git a/weed/s3api/cors/cors_test.go b/weed/s3api/cors/cors_test.go index 33d7b11b89..de30b49156 100644 --- a/weed/s3api/cors/cors_test.go +++ b/weed/s3api/cors/cors_test.go @@ -405,11 +405,12 @@ func TestEvaluateRequest(t *testing.T) { Method: "GET", }, want: &CORSResponse{ - AllowOrigin: "http://example.com", - AllowMethods: "GET, POST", - AllowHeaders: "Content-Type", - ExposeHeaders: "ETag", - MaxAge: "3600", + AllowOrigin: "http://example.com", + AllowMethods: "GET, POST", + AllowHeaders: "Content-Type", + ExposeHeaders: "ETag", + MaxAge: "3600", + AllowCredentials: true, }, wantErr: false, }, @@ -421,8 +422,9 @@ func TestEvaluateRequest(t *testing.T) { Method: "PUT", }, want: &CORSResponse{ - AllowOrigin: "http://other.com", - AllowMethods: "PUT", + AllowOrigin: "http://other.com", + AllowMethods: "PUT", + AllowCredentials: true, }, wantErr: false, }, @@ -447,11 +449,12 @@ func TestEvaluateRequest(t *testing.T) { AccessControlRequestHeaders: []string{"Content-Type"}, }, want: &CORSResponse{ - AllowOrigin: "http://example.com", - AllowMethods: "GET, POST", - AllowHeaders: "Content-Type", - ExposeHeaders: "ETag", - MaxAge: "3600", + AllowOrigin: "http://example.com", + AllowMethods: "GET, POST", + AllowHeaders: "Content-Type", + ExposeHeaders: "ETag", + MaxAge: "3600", + AllowCredentials: true, }, wantErr: false, }, @@ -467,7 +470,30 @@ func TestEvaluateRequest(t *testing.T) { }, want: &CORSResponse{ AllowOrigin: "http://example.com", - // No AllowMethods or AllowHeaders because the requested header is forbidden + // No AllowMethods, AllowHeaders, or AllowCredentials because the requested header is forbidden + }, + wantErr: false, + }, + { + name: "wildcard expose headers expands for credentialed responses", + config: &CORSConfiguration{ + CORSRules: []CORSRule{ + { + AllowedOrigins: []string{"http://example.com"}, + AllowedMethods: []string{"GET"}, + ExposeHeaders: []string{"*"}, + }, + }, + }, + corsReq: &CORSRequest{ + Origin: "http://example.com", + Method: "GET", + }, + want: &CORSResponse{ + AllowOrigin: "http://example.com", + AllowMethods: "GET", + ExposeHeaders: "ETag, Content-Length, Content-Type, Last-Modified, Content-Range, x-amz-request-id, x-amz-version-id, x-amz-tagging-count, *", + AllowCredentials: true, }, wantErr: false, }, @@ -511,18 +537,20 @@ func TestApplyHeaders(t *testing.T) { { name: "complete response", corsResp: &CORSResponse{ - AllowOrigin: "http://example.com", - AllowMethods: "GET, POST", - AllowHeaders: "Content-Type", - ExposeHeaders: "ETag", - MaxAge: "3600", + AllowOrigin: "http://example.com", + AllowMethods: "GET, POST", + AllowHeaders: "Content-Type", + ExposeHeaders: "ETag", + MaxAge: "3600", + AllowCredentials: true, }, want: map[string]string{ - "Access-Control-Allow-Origin": "http://example.com", - "Access-Control-Allow-Methods": "GET, POST", - "Access-Control-Allow-Headers": "Content-Type", - "Access-Control-Expose-Headers": "ETag", - "Access-Control-Max-Age": "3600", + "Access-Control-Allow-Origin": "http://example.com", + "Access-Control-Allow-Methods": "GET, POST", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Expose-Headers": "ETag", + "Access-Control-Max-Age": "3600", + "Access-Control-Allow-Credentials": "true", }, }, {