fix(s3api): missing Vary: Origin header on non-CORS and OPTIONS requests (#8072)
* fix: Refactor CORS middleware to consistently apply the `Vary: Origin` header when a configuration exists and streamline request processing logic. * fix: Add Vary: Origin header to CORS OPTIONS responses and refactor request handling for clarity and correctness. * fix: update CORS middleware tests to correctly parse and check for 'Origin' in Vary header. * refactor: extract `hasVaryOrigin` helper function to simplify Vary header checks in tests. * test: Remove `Vary: Origin` header from CORS test expectations. * refactor: consolidate CORS request handling into a new `processCORS` method using a `next` callback.
This commit is contained in:
@@ -361,10 +361,6 @@ func ApplyHeaders(w http.ResponseWriter, corsResp *CORSResponse) {
|
|||||||
|
|
||||||
if corsResp.AllowOrigin != "" {
|
if corsResp.AllowOrigin != "" {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", corsResp.AllowOrigin)
|
w.Header().Set("Access-Control-Allow-Origin", corsResp.AllowOrigin)
|
||||||
|
|
||||||
if corsResp.AllowOrigin != "*" {
|
|
||||||
w.Header().Add("Vary", "Origin")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if corsResp.AllowMethods != "" {
|
if corsResp.AllowMethods != "" {
|
||||||
|
|||||||
@@ -523,7 +523,6 @@ func TestApplyHeaders(t *testing.T) {
|
|||||||
"Access-Control-Allow-Headers": "Content-Type",
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
"Access-Control-Expose-Headers": "ETag",
|
"Access-Control-Expose-Headers": "ETag",
|
||||||
"Access-Control-Max-Age": "3600",
|
"Access-Control-Max-Age": "3600",
|
||||||
"Vary": "Origin",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -537,7 +536,6 @@ func TestApplyHeaders(t *testing.T) {
|
|||||||
"Access-Control-Allow-Origin": "http://example.com",
|
"Access-Control-Allow-Origin": "http://example.com",
|
||||||
"Access-Control-Allow-Methods": "GET",
|
"Access-Control-Allow-Methods": "GET",
|
||||||
"Access-Control-Allow-Credentials": "true",
|
"Access-Control-Allow-Credentials": "true",
|
||||||
"Vary": "Origin",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,103 +69,56 @@ func (m *Middleware) getCORSConfig(bucket string) (*CORSConfiguration, bool) {
|
|||||||
// Handler returns the CORS middleware handler
|
// Handler returns the CORS middleware handler
|
||||||
func (m *Middleware) Handler(next http.Handler) http.Handler {
|
func (m *Middleware) Handler(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Parse CORS request
|
m.processCORS(w, r, func() {
|
||||||
corsReq := ParseRequest(r)
|
|
||||||
|
|
||||||
// If not a CORS request, continue normally
|
|
||||||
if corsReq.Origin == "" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract bucket from request
|
|
||||||
bucket, _ := s3_constants.GetBucketAndObject(r)
|
|
||||||
if bucket == "" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get CORS configuration (bucket-specific or fallback) BEFORE checking bucket existence
|
|
||||||
// This ensures CORS headers are applied consistently regardless of bucket existence,
|
|
||||||
// preventing information disclosure about whether a bucket exists
|
|
||||||
config, found := m.getCORSConfig(bucket)
|
|
||||||
if !found {
|
|
||||||
// No CORS configuration at all, handle based on request type
|
|
||||||
if corsReq.IsPreflightRequest {
|
|
||||||
// Preflight request without CORS config should fail
|
|
||||||
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Non-preflight request, continue normally
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evaluate CORS request
|
|
||||||
corsResp, err := EvaluateRequest(config, corsReq)
|
|
||||||
if err != nil {
|
|
||||||
glog.V(3).Infof("CORS evaluation failed for bucket %s: %v", bucket, err)
|
|
||||||
if corsReq.IsPreflightRequest {
|
|
||||||
// Preflight request that doesn't match CORS rules should fail
|
|
||||||
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Non-preflight request, continue normally but without CORS headers
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply CORS headers early, before bucket existence check
|
|
||||||
// This ensures consistent CORS behavior and prevents information disclosure
|
|
||||||
ApplyHeaders(w, corsResp)
|
|
||||||
|
|
||||||
// Handle preflight requests - return success immediately without checking bucket existence
|
|
||||||
// This matches AWS S3 behavior where preflight requests succeed even for non-existent buckets
|
|
||||||
if corsReq.IsPreflightRequest {
|
|
||||||
// Preflight request should return 200 OK with just CORS headers
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For actual requests, continue with normal processing
|
|
||||||
// The handler will check bucket existence and return appropriate errors (e.g., NoSuchBucket)
|
|
||||||
// but CORS headers have already been applied above
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleOptionsRequest handles OPTIONS requests for CORS preflight
|
// HandleOptionsRequest handles OPTIONS requests for CORS preflight
|
||||||
func (m *Middleware) HandleOptionsRequest(w http.ResponseWriter, r *http.Request) {
|
func (m *Middleware) HandleOptionsRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
// Parse CORS request
|
m.processCORS(w, r, func() {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// processCORS handles the common CORS logic for both regular and preflight requests.
|
||||||
|
// It takes a next callback which is executed if the request flow proceeds (i.e., not short-circuited by CORS errors or preflight responses).
|
||||||
|
func (m *Middleware) processCORS(w http.ResponseWriter, r *http.Request, next func()) {
|
||||||
corsReq := ParseRequest(r)
|
corsReq := ParseRequest(r)
|
||||||
|
|
||||||
// If not a CORS request, return OK
|
|
||||||
if corsReq.Origin == "" {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract bucket from request
|
|
||||||
bucket, _ := s3_constants.GetBucketAndObject(r)
|
bucket, _ := s3_constants.GetBucketAndObject(r)
|
||||||
|
|
||||||
|
// 1. Basic Validation
|
||||||
if bucket == "" {
|
if bucket == "" {
|
||||||
w.WriteHeader(http.StatusOK)
|
next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get CORS configuration (bucket-specific or fallback) BEFORE checking bucket existence
|
// 2. Load Configuration
|
||||||
// This ensures CORS headers are applied consistently regardless of bucket existence
|
config, hasConfig := m.getCORSConfig(bucket)
|
||||||
config, found := m.getCORSConfig(bucket)
|
|
||||||
if !found {
|
// 3. Apply Vary Header (Always applied if config exists)
|
||||||
// No CORS configuration at all for OPTIONS request should return access denied
|
if hasConfig {
|
||||||
|
w.Header().Add("Vary", "Origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Handle Non-CORS Requests
|
||||||
|
if corsReq.Origin == "" {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Handle Missing Configuration
|
||||||
|
if !hasConfig {
|
||||||
if corsReq.IsPreflightRequest {
|
if corsReq.IsPreflightRequest {
|
||||||
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate CORS request
|
// 6. Evaluate CORS Request
|
||||||
corsResp, err := EvaluateRequest(config, corsReq)
|
corsResp, err := EvaluateRequest(config, corsReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
glog.V(3).Infof("CORS evaluation failed for bucket %s: %v", bucket, err)
|
glog.V(3).Infof("CORS evaluation failed for bucket %s: %v", bucket, err)
|
||||||
@@ -173,11 +126,17 @@ func (m *Middleware) HandleOptionsRequest(w http.ResponseWriter, r *http.Request
|
|||||||
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Success Case
|
||||||
|
ApplyHeaders(w, corsResp)
|
||||||
|
|
||||||
|
if corsReq.IsPreflightRequest {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply CORS headers and return success
|
next()
|
||||||
ApplyHeaders(w, corsResp)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cors
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@@ -403,3 +404,161 @@ func TestMiddlewareFallbackWithError(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMiddlewareVaryHeader tests that the Vary: Origin header is correctly applied in various scenarios
|
||||||
|
func TestMiddlewareVaryHeader(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bucketConfig *CORSConfiguration
|
||||||
|
shouldHaveVaryHeader bool
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Specific allowed origin",
|
||||||
|
bucketConfig: &CORSConfiguration{
|
||||||
|
CORSRules: []CORSRule{
|
||||||
|
{
|
||||||
|
AllowedOrigins: []string{"https://example.com"},
|
||||||
|
AllowedMethods: []string{"GET"},
|
||||||
|
AllowedHeaders: []string{"*"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shouldHaveVaryHeader: true,
|
||||||
|
description: "Should have Vary: Origin header when CORS config exists with specific origin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Wildcard allowed origin",
|
||||||
|
bucketConfig: &CORSConfiguration{
|
||||||
|
CORSRules: []CORSRule{
|
||||||
|
{
|
||||||
|
AllowedOrigins: []string{"*"},
|
||||||
|
AllowedMethods: []string{"GET"},
|
||||||
|
AllowedHeaders: []string{"*"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shouldHaveVaryHeader: true,
|
||||||
|
description: "Should have Vary: Origin header even when CORS config has wildcard origin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No CORS configuration",
|
||||||
|
bucketConfig: nil,
|
||||||
|
shouldHaveVaryHeader: false,
|
||||||
|
description: "Should NOT have Vary: Origin header when no CORS config exists",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Setup mocks
|
||||||
|
bucketChecker := &mockBucketChecker{bucketExists: true}
|
||||||
|
|
||||||
|
var errCode s3err.ErrorCode
|
||||||
|
if tt.bucketConfig == nil {
|
||||||
|
errCode = s3err.ErrNoSuchCORSConfiguration
|
||||||
|
} else {
|
||||||
|
errCode = s3err.ErrNone
|
||||||
|
}
|
||||||
|
|
||||||
|
configGetter := &mockCORSConfigGetter{
|
||||||
|
config: tt.bucketConfig,
|
||||||
|
errCode: errCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create middleware
|
||||||
|
middleware := NewMiddleware(bucketChecker, configGetter, nil)
|
||||||
|
|
||||||
|
// Create request WITHOUT Origin header
|
||||||
|
req := httptest.NewRequest("GET", "/testbucket/testobject", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{
|
||||||
|
"bucket": "testbucket",
|
||||||
|
"object": "testobject",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Create a simple handler that returns 200 OK
|
||||||
|
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Execute middleware
|
||||||
|
middleware.Handler(nextHandler).ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// Check Vary header
|
||||||
|
hasVaryOrigin := hasVaryOrigin(w.Header())
|
||||||
|
|
||||||
|
if tt.shouldHaveVaryHeader && !hasVaryOrigin {
|
||||||
|
t.Errorf("%s: expected Vary: Origin header, but got %v", tt.description, w.Header()["Vary"])
|
||||||
|
} else if !tt.shouldHaveVaryHeader && hasVaryOrigin {
|
||||||
|
t.Errorf("%s: expected NO Vary: Origin header, but got it", tt.description)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleOptionsRequestVaryHeader reproduces the issue where HandleOptionsRequest misses Vary: Origin
|
||||||
|
func TestHandleOptionsRequestVaryHeader(t *testing.T) {
|
||||||
|
// Setup mocks
|
||||||
|
bucketChecker := &mockBucketChecker{bucketExists: true}
|
||||||
|
|
||||||
|
config := &CORSConfiguration{
|
||||||
|
CORSRules: []CORSRule{
|
||||||
|
{
|
||||||
|
AllowedOrigins: []string{"https://example.com"},
|
||||||
|
AllowedMethods: []string{"GET", "POST"},
|
||||||
|
AllowedHeaders: []string{"*"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
configGetter := &mockCORSConfigGetter{
|
||||||
|
config: config,
|
||||||
|
errCode: s3err.ErrNone,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create middleware
|
||||||
|
middleware := NewMiddleware(bucketChecker, configGetter, nil)
|
||||||
|
|
||||||
|
// Create OPTIONS request
|
||||||
|
req := httptest.NewRequest("OPTIONS", "/testbucket/testobject", nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{
|
||||||
|
"bucket": "testbucket",
|
||||||
|
"object": "testobject",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set valid CORS headers
|
||||||
|
req.Header.Set("Origin", "https://example.com")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||||
|
|
||||||
|
// Create response recorder
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Execute HandleOptionsRequest
|
||||||
|
middleware.HandleOptionsRequest(w, req)
|
||||||
|
|
||||||
|
// Check Response Status
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Vary header - verified to FAIL without fix
|
||||||
|
if !hasVaryOrigin(w.Header()) {
|
||||||
|
t.Errorf("Expected Vary: Origin header in OPTIONS response, but got %v", w.Header()["Vary"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasVaryOrigin checks if the "Vary" header contains "Origin"
|
||||||
|
func hasVaryOrigin(header http.Header) bool {
|
||||||
|
varyHeaders := header["Vary"]
|
||||||
|
for _, h := range varyHeaders {
|
||||||
|
for _, v := range strings.Split(h, ",") {
|
||||||
|
if strings.TrimSpace(v) == "Origin" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user