Compare commits

...

2 Commits

Author SHA1 Message Date
maxts0gt d74ae22ac3 chore: clearer description 2025-07-27 12:52:35 +09:00
max-ts0gt af3d0e0bfb feat: add opt-in CORS security for wildcard origins
- Preserve backward compatibility: wildcard origins allow credentials by default
- Add MINIO_API_CORS_ALLOW_CREDENTIALS_WITH_WILDCARD=off for enhanced security
- Addresses CORS specification compliance while preventing service disruption
- Existing deployments continue working unchanged
2025-07-27 12:41:44 +09:00
5 changed files with 144 additions and 92 deletions

View File

@ -683,7 +683,8 @@ func corsHandler(handler http.Handler) http.Handler {
// Configure CORS dynamically based on current settings
// This ensures we handle configuration changes and wildcard security properly
hasWildcard := configHasWildcard()
allowCredentialsWithWildcard := globalAPIConfig.getCorsAllowCredentialsWithWildcard()
opts := cors.Options{
AllowOriginFunc: func(origin string) bool {
for _, allowedOrigin := range globalAPIConfig.getCorsAllowOrigins() {
@ -702,11 +703,12 @@ func corsHandler(handler http.Handler) http.Handler {
http.MethodOptions,
http.MethodPatch,
},
AllowedHeaders: commonS3Headers,
ExposedHeaders: commonS3Headers,
AllowedHeaders: commonS3Headers,
ExposedHeaders: commonS3Headers,
// CORS spec compliance: disable credentials when wildcard origins are configured
// Unless explicitly overridden by administrator (for backward compatibility)
// This prevents the security vulnerability where any website can make credentialed requests
AllowCredentials: !hasWildcard,
AllowCredentials: !hasWildcard || allowCredentialsWithWildcard,
}
// Use rs/cors directly without custom wrapper to avoid interface issues

View File

@ -27,59 +27,83 @@ import (
func TestCORSCredentialsWithWildcard(t *testing.T) {
// Save original config and restore after test
originalOrigins := globalAPIConfig.getCorsAllowOrigins()
originalAllowCredentialsWithWildcard := globalAPIConfig.getCorsAllowCredentialsWithWildcard()
defer func() {
globalAPIConfig.mu.Lock()
globalAPIConfig.corsAllowOrigins = originalOrigins
globalAPIConfig.corsAllowCredentialsWithWildcard = originalAllowCredentialsWithWildcard
globalAPIConfig.mu.Unlock()
}()
// Setup wildcard CORS config
globalAPIConfig.mu.Lock()
globalAPIConfig.corsAllowOrigins = []string{"*"}
globalAPIConfig.mu.Unlock()
// Test 1: Default secure behavior (wildcard without credentials)
t.Run("Secure Default", func(t *testing.T) {
// Setup wildcard CORS config with default secure behavior
globalAPIConfig.mu.Lock()
globalAPIConfig.corsAllowOrigins = []string{"*"}
globalAPIConfig.corsAllowCredentialsWithWildcard = false // default secure behavior
globalAPIConfig.mu.Unlock()
// Create a simple handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
// Create a simple handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Wrap with CORS handler
corsWrappedHandler := corsHandler(handler)
// Test preflight request
req := httptest.NewRequest("OPTIONS", "/", nil)
req.Header.Set("Origin", "https://example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
rr := httptest.NewRecorder()
corsWrappedHandler.ServeHTTP(rr, req)
// Verify specific origin is echoed back (rs/cors library behavior)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Errorf("Expected Access-Control-Allow-Origin: https://example.com, got: %s", got)
}
// Verify credentials header is NOT present (security fix)
if got := rr.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Errorf("Expected no Access-Control-Allow-Credentials header with wildcard origin, got: %s", got)
}
})
// Wrap with CORS handler
corsWrappedHandler := corsHandler(handler)
// Test 2: Backward compatibility opt-out (wildcard with credentials - insecure)
t.Run("Backward Compatibility Opt-out", func(t *testing.T) {
// Setup wildcard CORS config with explicit opt-out for backward compatibility
globalAPIConfig.mu.Lock()
globalAPIConfig.corsAllowOrigins = []string{"*"}
globalAPIConfig.corsAllowCredentialsWithWildcard = true // explicitly allow insecure behavior
globalAPIConfig.mu.Unlock()
// Test preflight request
req := httptest.NewRequest("OPTIONS", "/", nil)
req.Header.Set("Origin", "https://example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
// Create a simple handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
rr := httptest.NewRecorder()
corsWrappedHandler.ServeHTTP(rr, req)
// Wrap with CORS handler
corsWrappedHandler := corsHandler(handler)
// Verify specific origin is echoed back (rs/cors library behavior)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Errorf("Expected Access-Control-Allow-Origin: https://example.com, got: %s", got)
}
// Test preflight request
req := httptest.NewRequest("OPTIONS", "/", nil)
req.Header.Set("Origin", "https://example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
// Verify credentials header is NOT present (security fix)
if got := rr.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Errorf("Expected no Access-Control-Allow-Credentials header with wildcard origin, got: %s", got)
}
rr := httptest.NewRecorder()
corsWrappedHandler.ServeHTTP(rr, req)
// Test actual GET request
req = httptest.NewRequest("GET", "/", nil)
req.Header.Set("Origin", "https://example.com")
// Verify specific origin is echoed back (rs/cors library behavior)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Errorf("Expected Access-Control-Allow-Origin: https://example.com, got: %s", got)
}
rr = httptest.NewRecorder()
corsWrappedHandler.ServeHTTP(rr, req)
// Verify specific origin is echoed back (rs/cors library behavior)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Errorf("Expected Access-Control-Allow-Origin: https://example.com, got: %s", got)
}
// Verify credentials header is NOT present
if got := rr.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Errorf("Expected no Access-Control-Allow-Credentials header with wildcard origin, got: %s", got)
}
// Verify credentials header IS present (backward compatibility)
if got := rr.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Errorf("Expected Access-Control-Allow-Credentials: true with opt-out enabled, got: %s", got)
}
})
}
// Test that CORS credentials are allowed for specific origins
@ -168,7 +192,7 @@ func TestCORSUnauthorizedOrigin(t *testing.T) {
// Test preflight request from unauthorized origin
req := httptest.NewRequest("OPTIONS", "/", nil)
req.Header.Set("Origin", "https://example.org") // This origin is NOT in the allowed list
req.Header.Set("Origin", "https://example.org") // This origin is NOT in the allowed list
req.Header.Set("Access-Control-Request-Method", "GET")
rr := httptest.NewRecorder()
@ -188,15 +212,18 @@ func TestCORSUnauthorizedOrigin(t *testing.T) {
func TestCORSMixedConfiguration(t *testing.T) {
// Save original config and restore after test
originalOrigins := globalAPIConfig.getCorsAllowOrigins()
originalAllowCredentialsWithWildcard := globalAPIConfig.getCorsAllowCredentialsWithWildcard()
defer func() {
globalAPIConfig.mu.Lock()
globalAPIConfig.corsAllowOrigins = originalOrigins
globalAPIConfig.corsAllowCredentialsWithWildcard = originalAllowCredentialsWithWildcard
globalAPIConfig.mu.Unlock()
}()
// Setup mixed CORS config (wildcard + specific origins)
// Setup mixed CORS config (wildcard + specific origins) with default secure behavior
globalAPIConfig.mu.Lock()
globalAPIConfig.corsAllowOrigins = []string{"*", "https://example.com"}
globalAPIConfig.corsAllowCredentialsWithWildcard = false // default secure behavior
globalAPIConfig.mu.Unlock()
// Create a simple handler
@ -220,7 +247,7 @@ func TestCORSMixedConfiguration(t *testing.T) {
t.Errorf("Expected Access-Control-Allow-Origin: https://example.com, got: %s", got)
}
// Verify credentials header is NOT present due to wildcard in config
// Verify credentials header is NOT present due to wildcard in config (secure default)
if got := rr.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Errorf("Expected no Access-Control-Allow-Credentials header with wildcard in config, got: %s", got)
}

View File

@ -49,14 +49,15 @@ type apiConfig struct {
replicationMaxLWorkers int
transitionWorkers int
staleUploadsExpiry time.Duration
staleUploadsCleanupInterval time.Duration
deleteCleanupInterval time.Duration
enableODirect bool
gzipObjects bool
rootAccess bool
syncEvents bool
objectMaxVersions int64
staleUploadsExpiry time.Duration
staleUploadsCleanupInterval time.Duration
deleteCleanupInterval time.Duration
enableODirect bool
gzipObjects bool
rootAccess bool
syncEvents bool
objectMaxVersions int64
corsAllowCredentialsWithWildcard bool
}
const (
@ -187,6 +188,7 @@ func (t *apiConfig) init(cfg api.Config, setDriveCounts []int, legacy bool) {
t.rootAccess = cfg.RootAccess
t.syncEvents = cfg.SyncEvents
t.objectMaxVersions = cfg.ObjectMaxVersions
t.corsAllowCredentialsWithWildcard = cfg.CorsAllowCredentialsWithWildcard
if t.staleUploadsCleanupInterval != cfg.StaleUploadsCleanupInterval {
t.staleUploadsCleanupInterval = cfg.StaleUploadsCleanupInterval
@ -244,6 +246,12 @@ func (t *apiConfig) getCorsAllowOrigins() []string {
return corsAllowOrigins
}
func (t *apiConfig) getCorsAllowCredentialsWithWildcard() bool {
t.mu.RLock()
defer t.mu.RUnlock()
return t.corsAllowCredentialsWithWildcard
}
func (t *apiConfig) getStaleUploadsCleanupInterval() time.Duration {
t.mu.RLock()
defer t.mu.RUnlock()

View File

@ -32,14 +32,15 @@ import (
// API sub-system constants
const (
apiRequestsMax = "requests_max"
apiClusterDeadline = "cluster_deadline"
apiCorsAllowOrigin = "cors_allow_origin"
apiRemoteTransportDeadline = "remote_transport_deadline"
apiListQuorum = "list_quorum"
apiReplicationPriority = "replication_priority"
apiReplicationMaxWorkers = "replication_max_workers"
apiReplicationMaxLWorkers = "replication_max_lrg_workers"
apiRequestsMax = "requests_max"
apiClusterDeadline = "cluster_deadline"
apiCorsAllowOrigin = "cors_allow_origin"
apiCorsAllowCredentialsWithWildcard = "cors_allow_credentials_with_wildcard"
apiRemoteTransportDeadline = "remote_transport_deadline"
apiListQuorum = "list_quorum"
apiReplicationPriority = "replication_priority"
apiReplicationMaxWorkers = "replication_max_workers"
apiReplicationMaxLWorkers = "replication_max_lrg_workers"
apiTransitionWorkers = "transition_workers"
apiStaleUploadsCleanupInterval = "stale_uploads_cleanup_interval"
@ -52,17 +53,18 @@ const (
apiSyncEvents = "sync_events"
apiObjectMaxVersions = "object_max_versions"
EnvAPIRequestsMax = "MINIO_API_REQUESTS_MAX"
EnvAPIRequestsDeadline = "MINIO_API_REQUESTS_DEADLINE"
EnvAPIClusterDeadline = "MINIO_API_CLUSTER_DEADLINE"
EnvAPICorsAllowOrigin = "MINIO_API_CORS_ALLOW_ORIGIN"
EnvAPIRemoteTransportDeadline = "MINIO_API_REMOTE_TRANSPORT_DEADLINE"
EnvAPITransitionWorkers = "MINIO_API_TRANSITION_WORKERS"
EnvAPIListQuorum = "MINIO_API_LIST_QUORUM"
EnvAPISecureCiphers = "MINIO_API_SECURE_CIPHERS" // default config.EnableOn
EnvAPIReplicationPriority = "MINIO_API_REPLICATION_PRIORITY"
EnvAPIReplicationMaxWorkers = "MINIO_API_REPLICATION_MAX_WORKERS"
EnvAPIReplicationMaxLWorkers = "MINIO_API_REPLICATION_MAX_LRG_WORKERS"
EnvAPIRequestsMax = "MINIO_API_REQUESTS_MAX"
EnvAPIRequestsDeadline = "MINIO_API_REQUESTS_DEADLINE"
EnvAPIClusterDeadline = "MINIO_API_CLUSTER_DEADLINE"
EnvAPICorsAllowOrigin = "MINIO_API_CORS_ALLOW_ORIGIN"
EnvAPICorsAllowCredentialsWithWildcard = "MINIO_API_CORS_ALLOW_CREDENTIALS_WITH_WILDCARD"
EnvAPIRemoteTransportDeadline = "MINIO_API_REMOTE_TRANSPORT_DEADLINE"
EnvAPITransitionWorkers = "MINIO_API_TRANSITION_WORKERS"
EnvAPIListQuorum = "MINIO_API_LIST_QUORUM"
EnvAPISecureCiphers = "MINIO_API_SECURE_CIPHERS" // default config.EnableOn
EnvAPIReplicationPriority = "MINIO_API_REPLICATION_PRIORITY"
EnvAPIReplicationMaxWorkers = "MINIO_API_REPLICATION_MAX_WORKERS"
EnvAPIReplicationMaxLWorkers = "MINIO_API_REPLICATION_MAX_LRG_WORKERS"
EnvAPIStaleUploadsCleanupInterval = "MINIO_API_STALE_UPLOADS_CLEANUP_INTERVAL"
EnvAPIStaleUploadsExpiry = "MINIO_API_STALE_UPLOADS_EXPIRY"
@ -100,6 +102,10 @@ var (
Key: apiCorsAllowOrigin,
Value: "*",
},
config.KV{
Key: apiCorsAllowCredentialsWithWildcard,
Value: config.EnableOn,
},
config.KV{
Key: apiRemoteTransportDeadline,
Value: "2h",
@ -166,23 +172,24 @@ var (
// Config storage class configuration
type Config struct {
RequestsMax int `json:"requests_max"`
ClusterDeadline time.Duration `json:"cluster_deadline"`
CorsAllowOrigin []string `json:"cors_allow_origin"`
RemoteTransportDeadline time.Duration `json:"remote_transport_deadline"`
ListQuorum string `json:"list_quorum"`
ReplicationPriority string `json:"replication_priority"`
ReplicationMaxWorkers int `json:"replication_max_workers"`
ReplicationMaxLWorkers int `json:"replication_max_lrg_workers"`
TransitionWorkers int `json:"transition_workers"`
StaleUploadsCleanupInterval time.Duration `json:"stale_uploads_cleanup_interval"`
StaleUploadsExpiry time.Duration `json:"stale_uploads_expiry"`
DeleteCleanupInterval time.Duration `json:"delete_cleanup_interval"`
EnableODirect bool `json:"enable_odirect"`
GzipObjects bool `json:"gzip_objects"`
RootAccess bool `json:"root_access"`
SyncEvents bool `json:"sync_events"`
ObjectMaxVersions int64 `json:"object_max_versions"`
RequestsMax int `json:"requests_max"`
ClusterDeadline time.Duration `json:"cluster_deadline"`
CorsAllowOrigin []string `json:"cors_allow_origin"`
CorsAllowCredentialsWithWildcard bool `json:"cors_allow_credentials_with_wildcard"`
RemoteTransportDeadline time.Duration `json:"remote_transport_deadline"`
ListQuorum string `json:"list_quorum"`
ReplicationPriority string `json:"replication_priority"`
ReplicationMaxWorkers int `json:"replication_max_workers"`
ReplicationMaxLWorkers int `json:"replication_max_lrg_workers"`
TransitionWorkers int `json:"transition_workers"`
StaleUploadsCleanupInterval time.Duration `json:"stale_uploads_cleanup_interval"`
StaleUploadsExpiry time.Duration `json:"stale_uploads_expiry"`
DeleteCleanupInterval time.Duration `json:"delete_cleanup_interval"`
EnableODirect bool `json:"enable_odirect"`
GzipObjects bool `json:"gzip_objects"`
RootAccess bool `json:"root_access"`
SyncEvents bool `json:"sync_events"`
ObjectMaxVersions int64 `json:"object_max_versions"`
}
// UnmarshalJSON - Validate SS and RRS parity when unmarshalling JSON.
@ -211,11 +218,13 @@ func LookupConfig(kvs config.KVS) (cfg Config, err error) {
enableODirect := env.Get(EnvAPIODirect, kvs.Get(apiODirect)) == config.EnableOn
gzipObjects := env.Get(EnvAPIGzipObjects, kvs.Get(apiGzipObjects)) == config.EnableOn
rootAccess := env.Get(EnvAPIRootAccess, kvs.Get(apiRootAccess)) == config.EnableOn
corsAllowCredentialsWithWildcard := env.Get(EnvAPICorsAllowCredentialsWithWildcard, kvs.Get(apiCorsAllowCredentialsWithWildcard)) == config.EnableOn
cfg = Config{
EnableODirect: enableODirect || !disableODirect,
GzipObjects: gzipObjects,
RootAccess: rootAccess,
EnableODirect: enableODirect || !disableODirect,
GzipObjects: gzipObjects,
RootAccess: rootAccess,
CorsAllowCredentialsWithWildcard: corsAllowCredentialsWithWildcard,
}
var corsAllowOrigin []string

View File

@ -44,6 +44,12 @@ var (
Optional: true,
Type: "csv",
},
config.HelpKV{
Key: apiCorsAllowCredentialsWithWildcard,
Description: `allow credentials with wildcard CORS origins (default: 'on' for backward compatibility, 'off' for enhanced security compliance)` + defaultHelpPostfix(apiCorsAllowCredentialsWithWildcard),
Optional: true,
Type: "on|off",
},
config.HelpKV{
Key: apiRemoteTransportDeadline,
Description: `set the deadline for API requests on remote transports while proxying between federated instances e.g. "2h"` + defaultHelpPostfix(apiRemoteTransportDeadline),