aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Lu <chrislusf@users.noreply.github.com>2025-07-15 00:23:54 -0700
committerGitHub <noreply@github.com>2025-07-15 00:23:54 -0700
commit4b040e8a8701199d4c680bb6f241c4751c8210a2 (patch)
tree45d76546220c8d6f3287e3f5498ddf598079cc8e
parent548fa0b50a2a57de538d6f6961bfe819128d0ee5 (diff)
downloadseaweedfs-4b040e8a8701199d4c680bb6f241c4751c8210a2.tar.xz
seaweedfs-4b040e8a8701199d4c680bb6f241c4751c8210a2.zip
adding cors support (#6987)
* adding cors support * address some comments * optimize matchesWildcard * address comments * fix for tests * address comments * address comments * address comments * path building * refactor * Update weed/s3api/s3api_bucket_config.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * address comment Service-level responses need both Access-Control-Allow-Methods and Access-Control-Allow-Headers. After setting Access-Control-Allow-Origin and Access-Control-Expose-Headers, also set Access-Control-Allow-Methods: * and Access-Control-Allow-Headers: * so service endpoints satisfy CORS preflight requirements. * Update weed/s3api/s3api_bucket_config.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update weed/s3api/s3api_object_handlers.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update weed/s3api/s3api_object_handlers.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix * refactor * Update weed/s3api/s3api_bucket_config.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update weed/s3api/s3api_object_handlers.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update weed/s3api/s3api_server.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * simplify * add cors tests * fix tests * fix tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
-rw-r--r--.github/workflows/s3-go-tests.yml (renamed from .github/workflows/s3-versioning-tests.yml)119
-rw-r--r--.gitignore4
-rw-r--r--test/s3/cors/Makefile337
-rw-r--r--test/s3/cors/README.md362
-rw-r--r--test/s3/cors/go.mod36
-rw-r--r--test/s3/cors/go.sum63
-rw-r--r--test/s3/cors/s3_cors_http_test.go536
-rw-r--r--test/s3/cors/s3_cors_test.go600
-rw-r--r--weed/s3api/cors/cors.go649
-rw-r--r--weed/s3api/cors/cors_test.go526
-rw-r--r--weed/s3api/cors/middleware.go143
-rw-r--r--weed/s3api/s3api_bucket_config.go129
-rw-r--r--weed/s3api/s3api_bucket_cors_handlers.go140
-rw-r--r--weed/s3api/s3api_bucket_skip_handlers.go18
-rw-r--r--weed/s3api/s3api_object_handlers.go35
-rw-r--r--weed/s3api/s3api_server.go84
-rw-r--r--weed/s3api/s3err/error_handler.go28
17 files changed, 3756 insertions, 53 deletions
diff --git a/.github/workflows/s3-versioning-tests.yml b/.github/workflows/s3-go-tests.yml
index a34544b43..09e7aca5e 100644
--- a/.github/workflows/s3-versioning-tests.yml
+++ b/.github/workflows/s3-go-tests.yml
@@ -1,10 +1,10 @@
-name: "S3 Versioning and Retention Tests (Go)"
+name: "S3 Go Tests"
on:
pull_request:
concurrency:
- group: ${{ github.head_ref }}/s3-versioning-retention
+ group: ${{ github.head_ref }}/s3-go-tests
cancel-in-progress: true
permissions:
@@ -130,6 +130,54 @@ jobs:
path: test/s3/versioning/weed-test*.log
retention-days: 3
+ s3-cors-compatibility:
+ name: S3 CORS Compatibility Test
+ runs-on: ubuntu-22.04
+ timeout-minutes: 20
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: 'go.mod'
+ id: go
+
+ - name: Install SeaweedFS
+ run: |
+ go install -buildvcs=false
+
+ - name: Run Core CORS Test (AWS S3 compatible)
+ timeout-minutes: 15
+ working-directory: test/s3/cors
+ run: |
+ set -x
+ echo "=== System Information ==="
+ uname -a
+ free -h
+
+ # Run the specific test that is equivalent to AWS S3 CORS behavior
+ make test-with-server || {
+ echo "❌ Test failed, checking logs..."
+ if [ -f weed-test.log ]; then
+ echo "=== Server logs ==="
+ tail -100 weed-test.log
+ fi
+ echo "=== Process information ==="
+ ps aux | grep -E "(weed|test)" || true
+ exit 1
+ }
+
+ - name: Upload server logs on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: s3-cors-compatibility-logs
+ path: test/s3/cors/weed-test*.log
+ retention-days: 3
+
s3-retention-tests:
name: S3 Retention Tests
runs-on: ubuntu-22.04
@@ -197,6 +245,73 @@ jobs:
path: test/s3/retention/weed-test*.log
retention-days: 3
+ s3-cors-tests:
+ name: S3 CORS Tests
+ runs-on: ubuntu-22.04
+ timeout-minutes: 30
+ strategy:
+ matrix:
+ test-type: ["quick", "comprehensive"]
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: 'go.mod'
+ id: go
+
+ - name: Install SeaweedFS
+ run: |
+ go install -buildvcs=false
+
+ - name: Run S3 CORS Tests - ${{ matrix.test-type }}
+ timeout-minutes: 25
+ working-directory: test/s3/cors
+ run: |
+ set -x
+ echo "=== System Information ==="
+ uname -a
+ free -h
+ df -h
+ echo "=== Starting Tests ==="
+
+ # Run tests with automatic server management
+ # The test-with-server target handles server startup/shutdown automatically
+ if [ "${{ matrix.test-type }}" = "quick" ]; then
+ # Override TEST_PATTERN for quick tests only
+ make test-with-server TEST_PATTERN="TestCORSConfigurationManagement|TestServiceLevelCORS|TestCORSBasicWorkflow"
+ else
+ # Run all CORS tests
+ make test-with-server
+ fi
+
+ - name: Show server logs on failure
+ if: failure()
+ working-directory: test/s3/cors
+ run: |
+ echo "=== Server Logs ==="
+ if [ -f weed-test.log ]; then
+ echo "Last 100 lines of server logs:"
+ tail -100 weed-test.log
+ else
+ echo "No server log file found"
+ fi
+
+ echo "=== Test Environment ==="
+ ps aux | grep -E "(weed|test)" || true
+ netstat -tlnp | grep -E "(8333|9333|8080)" || true
+
+ - name: Upload test logs on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: s3-cors-test-logs-${{ matrix.test-type }}
+ path: test/s3/cors/weed-test*.log
+ retention-days: 3
+
s3-retention-worm:
name: S3 Retention WORM Integration Test
runs-on: ubuntu-22.04
diff --git a/.gitignore b/.gitignore
index 9efc7c66e..8d3a78ba5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -103,3 +103,7 @@ weed_binary
/test/s3/copying/filerldb2
/filerldb2
/test/s3/retention/test-volume-data
+test/s3/cors/weed-test.log
+test/s3/cors/weed-server.pid
+/test/s3/cors/test-volume-data
+test/s3/cors/cors.test
diff --git a/test/s3/cors/Makefile b/test/s3/cors/Makefile
new file mode 100644
index 000000000..e59124a6a
--- /dev/null
+++ b/test/s3/cors/Makefile
@@ -0,0 +1,337 @@
+# CORS Integration Tests Makefile
+# This Makefile provides comprehensive targets for running CORS integration tests
+
+.PHONY: help build-weed setup-server start-server stop-server test-cors test-cors-quick test-cors-comprehensive test-all clean logs check-deps
+
+# Configuration
+WEED_BINARY := ../../../weed/weed_binary
+S3_PORT := 8333
+MASTER_PORT := 9333
+VOLUME_PORT := 8080
+FILER_PORT := 8888
+TEST_TIMEOUT := 10m
+TEST_PATTERN := TestCORS
+
+# Default target
+help:
+ @echo "CORS Integration Tests Makefile"
+ @echo ""
+ @echo "Available targets:"
+ @echo " help - Show this help message"
+ @echo " build-weed - Build the SeaweedFS binary"
+ @echo " check-deps - Check dependencies and build binary if needed"
+ @echo " start-server - Start SeaweedFS server for testing"
+ @echo " start-server-simple - Start server without process cleanup (for CI)"
+ @echo " stop-server - Stop SeaweedFS server"
+ @echo " test-cors - Run all CORS tests"
+ @echo " test-cors-quick - Run core CORS tests only"
+ @echo " test-cors-simple - Run tests without server management"
+ @echo " test-cors-comprehensive - Run comprehensive CORS tests"
+ @echo " test-with-server - Start server, run tests, stop server"
+ @echo " logs - Show server logs"
+ @echo " clean - Clean up test artifacts and stop server"
+ @echo " health-check - Check if server is accessible"
+ @echo ""
+ @echo "Configuration:"
+ @echo " S3_PORT=${S3_PORT}"
+ @echo " TEST_TIMEOUT=${TEST_TIMEOUT}"
+
+# Build the SeaweedFS binary
+build-weed:
+ @echo "Building SeaweedFS binary..."
+ @cd ../../../weed && go build -o weed_binary .
+ @chmod +x $(WEED_BINARY)
+ @echo "✅ SeaweedFS binary built at $(WEED_BINARY)"
+
+check-deps: build-weed
+ @echo "Checking dependencies..."
+ @echo "🔍 DEBUG: Checking Go installation..."
+ @command -v go >/dev/null 2>&1 || (echo "Go is required but not installed" && exit 1)
+ @echo "🔍 DEBUG: Go version: $$(go version)"
+ @echo "🔍 DEBUG: Checking binary at $(WEED_BINARY)..."
+ @test -f $(WEED_BINARY) || (echo "SeaweedFS binary not found at $(WEED_BINARY)" && exit 1)
+ @echo "🔍 DEBUG: Binary size: $$(ls -lh $(WEED_BINARY) | awk '{print $$5}')"
+ @echo "🔍 DEBUG: Binary permissions: $$(ls -la $(WEED_BINARY) | awk '{print $$1}')"
+ @echo "🔍 DEBUG: Checking Go module dependencies..."
+ @go list -m github.com/aws/aws-sdk-go-v2 >/dev/null 2>&1 || (echo "AWS SDK Go v2 not found. Run 'go mod tidy'." && exit 1)
+ @go list -m github.com/stretchr/testify >/dev/null 2>&1 || (echo "Testify not found. Run 'go mod tidy'." && exit 1)
+ @echo "✅ All dependencies are available"
+
+# Start SeaweedFS server for testing
+start-server: check-deps
+ @echo "Starting SeaweedFS server..."
+ @echo "🔍 DEBUG: Current working directory: $$(pwd)"
+ @echo "🔍 DEBUG: Checking for existing weed processes..."
+ @ps aux | grep weed | grep -v grep || echo "No existing weed processes found"
+ @echo "🔍 DEBUG: Cleaning up any existing PID file..."
+ @rm -f weed-server.pid
+ @echo "🔍 DEBUG: Checking for port conflicts..."
+ @if netstat -tlnp 2>/dev/null | grep $(S3_PORT) >/dev/null; then \
+ echo "⚠️ Port $(S3_PORT) is already in use, trying to find the process..."; \
+ netstat -tlnp 2>/dev/null | grep $(S3_PORT) || true; \
+ else \
+ echo "✅ Port $(S3_PORT) is available"; \
+ fi
+ @echo "🔍 DEBUG: Checking binary at $(WEED_BINARY)"
+ @ls -la $(WEED_BINARY) || (echo "❌ Binary not found!" && exit 1)
+ @echo "🔍 DEBUG: Checking config file at ../../../docker/compose/s3.json"
+ @ls -la ../../../docker/compose/s3.json || echo "⚠️ Config file not found, continuing without it"
+ @echo "🔍 DEBUG: Creating volume directory..."
+ @mkdir -p ./test-volume-data
+ @echo "🔍 DEBUG: Launching SeaweedFS server in background..."
+ @echo "🔍 DEBUG: Command: $(WEED_BINARY) server -debug -s3 -s3.port=$(S3_PORT) -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -filer -filer.maxMB=64 -master.volumeSizeLimitMB=50 -volume.max=100 -dir=./test-volume-data -volume.preStopSeconds=1 -metricsPort=9324"
+ @$(WEED_BINARY) server \
+ -debug \
+ -s3 \
+ -s3.port=$(S3_PORT) \
+ -s3.allowEmptyFolder=false \
+ -s3.allowDeleteBucketNotEmpty=true \
+ -s3.config=../../../docker/compose/s3.json \
+ -filer \
+ -filer.maxMB=64 \
+ -master.volumeSizeLimitMB=50 \
+ -volume.max=100 \
+ -dir=./test-volume-data \
+ -volume.preStopSeconds=1 \
+ -metricsPort=9324 \
+ > weed-test.log 2>&1 & echo $$! > weed-server.pid
+ @echo "🔍 DEBUG: Server PID: $$(cat weed-server.pid 2>/dev/null || echo 'PID file not found')"
+ @echo "🔍 DEBUG: Checking if PID is still running..."
+ @sleep 2
+ @if [ -f weed-server.pid ]; then \
+ SERVER_PID=$$(cat weed-server.pid); \
+ ps -p $$SERVER_PID || echo "⚠️ Server PID $$SERVER_PID not found after 2 seconds"; \
+ else \
+ echo "⚠️ PID file not found"; \
+ fi
+ @echo "🔍 DEBUG: Waiting for server to start (up to 90 seconds)..."
+ @for i in $$(seq 1 90); do \
+ echo "🔍 DEBUG: Attempt $$i/90 - checking port $(S3_PORT)"; \
+ if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \
+ echo "✅ SeaweedFS server started successfully on port $(S3_PORT) after $$i seconds"; \
+ exit 0; \
+ fi; \
+ if [ $$i -eq 5 ]; then \
+ echo "🔍 DEBUG: After 5 seconds, checking process and logs..."; \
+ ps aux | grep weed | grep -v grep || echo "No weed processes found"; \
+ if [ -f weed-test.log ]; then \
+ echo "=== First server logs ==="; \
+ head -20 weed-test.log; \
+ fi; \
+ fi; \
+ if [ $$i -eq 15 ]; then \
+ echo "🔍 DEBUG: After 15 seconds, checking port bindings..."; \
+ netstat -tlnp 2>/dev/null | grep $(S3_PORT) || echo "Port $(S3_PORT) not bound"; \
+ netstat -tlnp 2>/dev/null | grep 9333 || echo "Port 9333 not bound"; \
+ netstat -tlnp 2>/dev/null | grep 8080 || echo "Port 8080 not bound"; \
+ fi; \
+ if [ $$i -eq 30 ]; then \
+ echo "⚠️ Server taking longer than expected (30s), checking logs..."; \
+ if [ -f weed-test.log ]; then \
+ echo "=== Recent server logs ==="; \
+ tail -20 weed-test.log; \
+ fi; \
+ fi; \
+ sleep 1; \
+ done; \
+ echo "❌ Server failed to start within 90 seconds"; \
+ echo "🔍 DEBUG: Final process check:"; \
+ ps aux | grep weed | grep -v grep || echo "No weed processes found"; \
+ echo "🔍 DEBUG: Final port check:"; \
+ netstat -tlnp 2>/dev/null | grep -E "(8333|9333|8080)" || echo "No ports bound"; \
+ echo "=== Full server logs ==="; \
+ if [ -f weed-test.log ]; then \
+ cat weed-test.log; \
+ else \
+ echo "No log file found"; \
+ fi; \
+ exit 1
+
+# Stop SeaweedFS server
+stop-server:
+ @echo "Stopping SeaweedFS server..."
+ @if [ -f weed-server.pid ]; then \
+ SERVER_PID=$$(cat weed-server.pid); \
+ echo "Killing server PID $$SERVER_PID"; \
+ if ps -p $$SERVER_PID >/dev/null 2>&1; then \
+ kill -TERM $$SERVER_PID 2>/dev/null || true; \
+ sleep 2; \
+ if ps -p $$SERVER_PID >/dev/null 2>&1; then \
+ echo "Process still running, sending KILL signal..."; \
+ kill -KILL $$SERVER_PID 2>/dev/null || true; \
+ sleep 1; \
+ fi; \
+ else \
+ echo "Process $$SERVER_PID not found (already stopped)"; \
+ fi; \
+ rm -f weed-server.pid; \
+ else \
+ echo "No PID file found, checking for running processes..."; \
+ echo "⚠️ Skipping automatic process cleanup to avoid CI issues"; \
+ echo "Note: Any remaining weed processes should be cleaned up by the CI environment"; \
+ fi
+ @echo "✅ SeaweedFS server stopped"
+
+# Show server logs
+logs:
+ @if test -f weed-test.log; then \
+ echo "=== SeaweedFS Server Logs ==="; \
+ tail -f weed-test.log; \
+ else \
+ echo "No log file found. Server may not be running."; \
+ fi
+
+# Core CORS tests (basic functionality)
+test-cors-quick: check-deps
+ @echo "Running core CORS tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSConfigurationManagement|TestCORSPreflightRequest|TestCORSActualRequest" .
+ @echo "✅ Core CORS tests completed"
+
+# All CORS tests (comprehensive)
+test-cors: check-deps
+ @echo "Running all CORS tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "$(TEST_PATTERN)" .
+ @echo "✅ All CORS tests completed"
+
+# Comprehensive CORS tests (all features)
+test-cors-comprehensive: check-deps
+ @echo "Running comprehensive CORS tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORS" .
+ @echo "✅ Comprehensive CORS tests completed"
+
+# All tests without server management
+test-cors-simple: check-deps
+ @echo "Running CORS tests (assuming server is already running)..."
+ @go test -v -timeout=$(TEST_TIMEOUT) .
+ @echo "✅ All CORS tests completed"
+
+# Start server, run tests, stop server
+test-with-server: start-server
+ @echo "Running CORS tests with managed server..."
+ @sleep 5 # Give server time to fully start
+ @make test-cors-comprehensive || (echo "Tests failed, stopping server..." && make stop-server && exit 1)
+ @make stop-server
+ @echo "✅ All tests completed with managed server"
+
+# Health check
+health-check:
+ @echo "Checking server health..."
+ @if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \
+ echo "✅ Server is accessible on port $(S3_PORT)"; \
+ else \
+ echo "❌ Server is not accessible on port $(S3_PORT)"; \
+ exit 1; \
+ fi
+
+# Clean up
+clean:
+ @echo "Cleaning up test artifacts..."
+ @make stop-server
+ @rm -f weed-test.log
+ @rm -f weed-server.pid
+ @rm -rf ./test-volume-data
+ @rm -f cors.test
+ @go clean -testcache
+ @echo "✅ Cleanup completed"
+
+# Individual test targets for specific functionality
+test-basic-cors:
+ @echo "Running basic CORS tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSConfigurationManagement" .
+
+test-preflight-cors:
+ @echo "Running preflight CORS tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSPreflightRequest" .
+
+test-actual-cors:
+ @echo "Running actual CORS request tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSActualRequest" .
+
+test-origin-matching:
+ @echo "Running origin matching tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSOriginMatching" .
+
+test-header-matching:
+ @echo "Running header matching tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSHeaderMatching" .
+
+test-method-matching:
+ @echo "Running method matching tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSMethodMatching" .
+
+test-multiple-rules:
+ @echo "Running multiple rules tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSMultipleRulesMatching" .
+
+test-validation:
+ @echo "Running validation tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSValidation" .
+
+test-caching:
+ @echo "Running caching tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSCaching" .
+
+test-error-handling:
+ @echo "Running error handling tests..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSErrorHandling" .
+
+# Development targets
+dev-start: start-server
+ @echo "Development server started. Access S3 API at http://localhost:$(S3_PORT)"
+ @echo "To stop: make stop-server"
+
+dev-test: check-deps
+ @echo "Running tests in development mode..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSConfigurationManagement" .
+
+# CI targets
+ci-test: check-deps
+ @echo "Running tests in CI mode..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -race .
+
+# All targets
+test-all: test-cors test-cors-comprehensive
+ @echo "✅ All CORS tests completed"
+
+# Benchmark targets
+benchmark-cors:
+ @echo "Running CORS performance benchmarks..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -bench=. -benchmem .
+
+# Coverage targets
+coverage:
+ @echo "Running tests with coverage..."
+ @go test -v -timeout=$(TEST_TIMEOUT) -coverprofile=coverage.out .
+ @go tool cover -html=coverage.out -o coverage.html
+ @echo "Coverage report generated: coverage.html"
+
+# Format and lint
+fmt:
+ @echo "Formatting Go code..."
+ @go fmt .
+
+lint:
+ @echo "Running linter..."
+ @golint . || echo "golint not available, skipping..."
+
+# Install dependencies for development
+install-deps:
+ @echo "Installing Go dependencies..."
+ @go mod tidy
+ @go mod download
+
+# Show current configuration
+show-config:
+ @echo "Current configuration:"
+ @echo " WEED_BINARY: $(WEED_BINARY)"
+ @echo " S3_PORT: $(S3_PORT)"
+ @echo " TEST_TIMEOUT: $(TEST_TIMEOUT)"
+ @echo " TEST_PATTERN: $(TEST_PATTERN)"
+
+# Legacy targets for backward compatibility
+test: test-with-server
+test-verbose: test-cors-comprehensive
+test-single: test-basic-cors
+test-clean: clean
+build: check-deps
+setup: check-deps \ No newline at end of file
diff --git a/test/s3/cors/README.md b/test/s3/cors/README.md
new file mode 100644
index 000000000..1b93d9ccc
--- /dev/null
+++ b/test/s3/cors/README.md
@@ -0,0 +1,362 @@
+# CORS Integration Tests for SeaweedFS S3 API
+
+This directory contains comprehensive integration tests for the CORS (Cross-Origin Resource Sharing) functionality in SeaweedFS S3 API.
+
+## Overview
+
+The CORS integration tests validate the complete CORS implementation including:
+- CORS configuration management (PUT/GET/DELETE)
+- CORS rule validation
+- CORS middleware behavior
+- Caching functionality
+- Error handling
+- Real-world CORS scenarios
+
+## Prerequisites
+
+1. **Go 1.19+**: For building SeaweedFS and running tests
+2. **Network Access**: Tests use `localhost:8333` by default
+3. **System Dependencies**: `curl` and `netstat` for health checks
+
+## Quick Start
+
+The tests now automatically start their own SeaweedFS server, so you don't need to manually start one.
+
+### 1. Run All Tests with Managed Server
+
+```bash
+# Run all tests with automatic server management
+make test-with-server
+
+# Run core CORS tests only
+make test-cors-quick
+
+# Run comprehensive CORS tests
+make test-cors-comprehensive
+```
+
+### 2. Manual Server Management
+
+If you prefer to manage the server manually:
+
+```bash
+# Start server
+make start-server
+
+# Run tests (assuming server is running)
+make test-cors-simple
+
+# Stop server
+make stop-server
+```
+
+### 3. Individual Test Categories
+
+```bash
+# Run specific test types
+make test-basic-cors # Basic CORS configuration
+make test-preflight-cors # Preflight OPTIONS requests
+make test-actual-cors # Actual CORS request handling
+make test-origin-matching # Origin matching logic
+make test-header-matching # Header matching logic
+make test-method-matching # Method matching logic
+make test-multiple-rules # Multiple CORS rules
+make test-validation # CORS validation
+make test-caching # CORS caching behavior
+make test-error-handling # Error handling
+```
+
+## Test Server Management
+
+The tests use a comprehensive server management system similar to other SeaweedFS integration tests:
+
+### Server Configuration
+
+- **S3 Port**: 8333 (configurable via `S3_PORT`)
+- **Master Port**: 9333
+- **Volume Port**: 8080
+- **Filer Port**: 8888
+- **Metrics Port**: 9324
+- **Data Directory**: `./test-volume-data` (auto-created)
+- **Log File**: `weed-test.log`
+
+### Server Lifecycle
+
+1. **Build**: Automatically builds `../../../weed/weed_binary`
+2. **Start**: Launches SeaweedFS with S3 API enabled
+3. **Health Check**: Waits up to 90 seconds for server to be ready
+4. **Test**: Runs the requested tests
+5. **Stop**: Gracefully shuts down the server
+6. **Cleanup**: Removes temporary files and data
+
+### Available Commands
+
+```bash
+# Server management
+make start-server # Start SeaweedFS server
+make stop-server # Stop SeaweedFS server
+make health-check # Check server health
+make logs # View server logs
+
+# Test execution
+make test-with-server # Full test cycle with server management
+make test-cors-simple # Run tests without server management
+make test-cors-quick # Run core tests only
+make test-cors-comprehensive # Run all tests
+
+# Development
+make dev-start # Start server for development
+make dev-test # Run development tests
+make build-weed # Build SeaweedFS binary
+make check-deps # Check dependencies
+
+# Maintenance
+make clean # Clean up all artifacts
+make coverage # Generate coverage report
+make fmt # Format code
+make lint # Run linter
+```
+
+## Test Configuration
+
+### Default Configuration
+
+The tests use these default settings (configurable via environment variables):
+
+```bash
+WEED_BINARY=../../../weed/weed_binary
+S3_PORT=8333
+TEST_TIMEOUT=10m
+TEST_PATTERN=TestCORS
+```
+
+### Configuration File
+
+The `test_config.json` file contains S3 client configuration:
+
+```json
+{
+ "endpoint": "http://localhost:8333",
+ "access_key": "some_access_key1",
+ "secret_key": "some_secret_key1",
+ "region": "us-east-1",
+ "bucket_prefix": "test-cors-",
+ "use_ssl": false,
+ "skip_verify_ssl": true
+}
+```
+
+## Troubleshooting
+
+### Compilation Issues
+
+If you encounter compilation errors, the most common issues are:
+
+1. **AWS SDK v2 Type Mismatches**: The `MaxAgeSeconds` field in `types.CORSRule` expects `int32`, not `*int32`. Use direct values like `3600` instead of `aws.Int32(3600)`.
+
+2. **Field Name Issues**: The `GetBucketCorsOutput` type has a `CORSRules` field directly, not a `CORSConfiguration` field.
+
+Example fix:
+```go
+// ❌ Incorrect
+MaxAgeSeconds: aws.Int32(3600),
+assert.Len(t, getResp.CORSConfiguration.CORSRules, 1)
+
+// ✅ Correct
+MaxAgeSeconds: 3600,
+assert.Len(t, getResp.CORSRules, 1)
+```
+
+### Server Issues
+
+1. **Server Won't Start**
+ ```bash
+ # Check for port conflicts
+ netstat -tlnp | grep 8333
+
+ # View server logs
+ make logs
+
+ # Force cleanup
+ make clean
+ ```
+
+2. **Test Failures**
+ ```bash
+ # Run with server management
+ make test-with-server
+
+ # Run specific test
+ make test-basic-cors
+
+ # Check server health
+ make health-check
+ ```
+
+3. **Connection Issues**
+ ```bash
+ # Verify server is running
+ curl -s http://localhost:8333
+
+ # Check server logs
+ tail -f weed-test.log
+ ```
+
+### Performance Issues
+
+If tests are slow or timing out:
+
+```bash
+# Increase timeout
+export TEST_TIMEOUT=30m
+make test-with-server
+
+# Run quick tests only
+make test-cors-quick
+
+# Check server resources
+make debug-status
+```
+
+## Test Coverage
+
+### Core Functionality Tests
+
+#### 1. CORS Configuration Management (`TestCORSConfigurationManagement`)
+- PUT CORS configuration
+- GET CORS configuration
+- DELETE CORS configuration
+- Configuration updates
+- Error handling for non-existent configurations
+
+#### 2. Multiple CORS Rules (`TestCORSMultipleRules`)
+- Multiple rules in single configuration
+- Rule precedence and ordering
+- Complex rule combinations
+
+#### 3. CORS Validation (`TestCORSValidation`)
+- Invalid HTTP methods
+- Empty origins validation
+- Negative MaxAge validation
+- Rule limit validation
+
+#### 4. Wildcard Support (`TestCORSWithWildcards`)
+- Wildcard origins (`*`, `https://*.example.com`)
+- Wildcard headers (`*`)
+- Wildcard expose headers
+
+#### 5. Rule Limits (`TestCORSRuleLimit`)
+- Maximum 100 rules per configuration
+- Rule limit enforcement
+- Large configuration handling
+
+#### 6. Error Handling (`TestCORSErrorHandling`)
+- Non-existent bucket operations
+- Invalid configurations
+- Malformed requests
+
+### HTTP-Level Tests
+
+#### 1. Preflight Requests (`TestCORSPreflightRequest`)
+- OPTIONS request handling
+- CORS headers in preflight responses
+- Access-Control-Request-Method validation
+- Access-Control-Request-Headers validation
+
+#### 2. Actual Requests (`TestCORSActualRequest`)
+- CORS headers in actual responses
+- Origin validation for real requests
+- Proper expose headers handling
+
+#### 3. Origin Matching (`TestCORSOriginMatching`)
+- Exact origin matching
+- Wildcard origin matching (`*`)
+- Subdomain wildcard matching (`https://*.example.com`)
+- Non-matching origins (should be rejected)
+
+#### 4. Header Matching (`TestCORSHeaderMatching`)
+- Wildcard header matching (`*`)
+- Specific header matching
+- Case-insensitive matching
+- Disallowed headers
+
+#### 5. Method Matching (`TestCORSMethodMatching`)
+- Allowed methods verification
+- Disallowed methods rejection
+- Method-specific CORS behavior
+
+#### 6. Multiple Rules (`TestCORSMultipleRulesMatching`)
+- Rule precedence and selection
+- Multiple rules with different configurations
+- Complex rule interactions
+
+### Integration Tests
+
+#### 1. Caching (`TestCORSCaching`)
+- CORS configuration caching
+- Cache invalidation
+- Cache performance
+
+#### 2. Object Operations (`TestCORSObjectOperations`)
+- CORS with actual S3 operations
+- PUT/GET/DELETE objects with CORS
+- CORS headers in object responses
+
+#### 3. Without Configuration (`TestCORSWithoutConfiguration`)
+- Behavior when no CORS configuration exists
+- Default CORS behavior
+- Graceful degradation
+
+## Development
+
+### Running Tests During Development
+
+```bash
+# Start server for development
+make dev-start
+
+# Run quick test
+make dev-test
+
+# View logs in real-time
+make logs
+```
+
+### Adding New Tests
+
+1. Follow the existing naming convention (`TestCORSXxxYyy`)
+2. Use the helper functions (`getS3Client`, `createTestBucket`, etc.)
+3. Add cleanup with `defer cleanupTestBucket(t, client, bucketName)`
+4. Include proper error checking with `require.NoError(t, err)`
+5. Use assertions with `assert.Equal(t, expected, actual)`
+6. Add the test to the appropriate Makefile target
+
+### Code Quality
+
+```bash
+# Format code
+make fmt
+
+# Run linter
+make lint
+
+# Generate coverage report
+make coverage
+```
+
+## Performance Notes
+
+- Tests create and destroy buckets for each test case
+- Large configuration tests may take several minutes
+- Server startup typically takes 15-30 seconds
+- Tests run in parallel where possible for efficiency
+
+## Integration with SeaweedFS
+
+These tests validate the CORS implementation in:
+- `weed/s3api/cors/` - Core CORS package
+- `weed/s3api/s3api_bucket_cors_handlers.go` - HTTP handlers
+- `weed/s3api/s3api_server.go` - Router integration
+- `weed/s3api/s3api_bucket_config.go` - Configuration management
+
+The tests ensure AWS S3 API compatibility and proper CORS behavior across all supported scenarios. \ No newline at end of file
diff --git a/test/s3/cors/go.mod b/test/s3/cors/go.mod
new file mode 100644
index 000000000..a5c91a9c6
--- /dev/null
+++ b/test/s3/cors/go.mod
@@ -0,0 +1,36 @@
+module github.com/seaweedfs/seaweedfs/test/s3/cors
+
+go 1.19
+
+require (
+ github.com/aws/aws-sdk-go-v2 v1.21.0
+ github.com/aws/aws-sdk-go-v2/config v1.18.42
+ github.com/aws/aws-sdk-go-v2/credentials v1.13.40
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.40.0
+ github.com/k0kubun/pp v3.0.1+incompatible
+ github.com/stretchr/testify v1.8.4
+)
+
+require (
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.3.43 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.14.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.22.0 // indirect
+ github.com/aws/smithy-go v1.14.2 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.16 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/test/s3/cors/go.sum b/test/s3/cors/go.sum
new file mode 100644
index 000000000..1c9f2a9c8
--- /dev/null
+++ b/test/s3/cors/go.sum
@@ -0,0 +1,63 @@
+github.com/aws/aws-sdk-go-v2 v1.21.0 h1:gMT0IW+03wtYJhRqTVYn0wLzwdnK9sRMcxmtfGzRdJc=
+github.com/aws/aws-sdk-go-v2 v1.21.0/go.mod h1:/RfNgGmRxI+iFOB1OeJUyxiU+9s88k3pfHvDagGEp0M=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13 h1:OPLEkmhXf6xFPiz0bLeDArZIDx1NNS4oJyG4nv3Gct0=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13/go.mod h1:gpAbvyDGQFozTEmlTFO8XcQKHzubdq0LzRyJpG6MiXM=
+github.com/aws/aws-sdk-go-v2/config v1.18.42 h1:28jHROB27xZwU0CB88giDSjz7M1Sba3olb5JBGwina8=
+github.com/aws/aws-sdk-go-v2/config v1.18.42/go.mod h1:4AZM3nMMxwlG+eZlxvBKqwVbkDLlnN2a4UGTL6HjaZI=
+github.com/aws/aws-sdk-go-v2/credentials v1.13.40 h1:s8yOkDh+5b1jUDhMBtngF6zKWLDs84chUk2Vk0c38Og=
+github.com/aws/aws-sdk-go-v2/credentials v1.13.40/go.mod h1:VtEHVAAqDWASwdOqj/1huyT6uHbs5s8FUHfDQdky/Rs=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11 h1:uDZJF1hu0EVT/4bogChk8DyjSF6fof6uL/0Y26Ma7Fg=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11/go.mod h1:TEPP4tENqBGO99KwVpV9MlOX4NSrSLP8u3KRy2CDwA8=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41 h1:22dGT7PneFMx4+b3pz7lMTRyN8ZKH7M2cW4GP9yUS2g=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41/go.mod h1:CrObHAuPneJBlfEJ5T3szXOUkLEThaGfvnhTf33buas=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35 h1:SijA0mgjV8E+8G45ltVHs0fvKpTj8xmZJ3VwhGKtUSI=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35/go.mod h1:SJC1nEVVva1g3pHAIdCp7QsRIkMmLAgoDquQ9Rr8kYw=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.3.43 h1:g+qlObJH4Kn4n21g69DjspU0hKTjWtq7naZ9OLCv0ew=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.3.43/go.mod h1:rzfdUlfA+jdgLDmPKjd3Chq9V7LVLYo1Nz++Wb91aRo=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4 h1:6lJvvkQ9HmbHZ4h/IEwclwv2mrTW8Uq1SOB/kXy0mfw=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4/go.mod h1:1PrKYwxTM+zjpw9Y41KFtoJCQrJ34Z47Y4VgVbfndjo=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14 h1:m0QTSI6pZYJTk5WSKx3fm5cNW/DCicVzULBgU/6IyD0=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14/go.mod h1:dDilntgHy9WnHXsh7dDtUPgHKEfTJIBUTHM8OWm0f/0=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36 h1:eev2yZX7esGRjqRbnVk1UxMLw4CyVZDpZXRCcy75oQk=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36/go.mod h1:lGnOkH9NJATw0XEPcAknFBj3zzNTEGRHtSw+CwC1YTg=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35 h1:CdzPW9kKitgIiLV1+MHobfR5Xg25iYnyzWZhyQuSlDI=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35/go.mod h1:QGF2Rs33W5MaN9gYdEQOBBFPLwTZkEhRwI33f7KIG0o=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4 h1:v0jkRigbSD6uOdwcaUQmgEwG1BkPfAPDqaeNt/29ghg=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4/go.mod h1:LhTyt8J04LL+9cIt7pYJ5lbS/U98ZmXovLOR/4LUsk8=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.40.0 h1:wl5dxN1NONhTDQD9uaEvNsDRX29cBmGED/nl0jkWlt4=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.40.0/go.mod h1:rDGMZA7f4pbmTtPOk5v5UM2lmX6UAbRnMDJeDvnH7AM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.14.1 h1:YkNzx1RLS0F5qdf9v1Q8Cuv9NXCL2TkosOxhzlUPV64=
+github.com/aws/aws-sdk-go-v2/service/sso v1.14.1/go.mod h1:fIAwKQKBFu90pBxx07BFOMJLpRUGu8VOzLJakeY+0K4=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.1 h1:8lKOidPkmSmfUtiTgtdXWgaKItCZ/g75/jEk6Ql6GsA=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.1/go.mod h1:yygr8ACQRY2PrEcy3xsUI357stq2AxnFM6DIsR9lij4=
+github.com/aws/aws-sdk-go-v2/service/sts v1.22.0 h1:s4bioTgjSFRwOoyEFzAVCmFmoowBgjTR8gkrF/sQ4wk=
+github.com/aws/aws-sdk-go-v2/service/sts v1.22.0/go.mod h1:VC7JDqsqiwXukYEDjoHh9U0fOJtNWh04FPQz4ct4GGU=
+github.com/aws/smithy-go v1.14.2 h1:MJU9hqBGbvWZdApzpvoF2WAIJDbtjK2NDJSiJP7HblQ=
+github.com/aws/smithy-go v1.14.2/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 h1:uC1QfSlInpQF+M0ao65imhwqKnz3Q2z/d8PWZRMQvDM=
+github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
+github.com/k0kubun/pp v3.0.1+incompatible h1:3tqvf7QgUnZ5tXO6pNAZlrvHgl6DvifjDrd9g2S9Z40=
+github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/test/s3/cors/s3_cors_http_test.go b/test/s3/cors/s3_cors_http_test.go
new file mode 100644
index 000000000..b94caef27
--- /dev/null
+++ b/test/s3/cors/s3_cors_http_test.go
@@ -0,0 +1,536 @@
+package cors
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestCORSPreflightRequest tests CORS preflight OPTIONS requests
+func TestCORSPreflightRequest(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Set up CORS configuration
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"Content-Type", "Authorization"},
+ AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
+ AllowedOrigins: []string{"https://example.com"},
+ ExposeHeaders: []string{"ETag", "Content-Length"},
+ MaxAgeSeconds: 3600,
+ },
+ },
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ require.NoError(t, err, "Should be able to put CORS configuration")
+
+ // Test preflight request with raw HTTP
+ httpClient := &http.Client{Timeout: 10 * time.Second}
+
+ // Create OPTIONS request
+ req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil)
+ require.NoError(t, err, "Should be able to create OPTIONS request")
+
+ // Add CORS preflight headers
+ req.Header.Set("Origin", "https://example.com")
+ req.Header.Set("Access-Control-Request-Method", "PUT")
+ req.Header.Set("Access-Control-Request-Headers", "Content-Type, Authorization")
+
+ // Send the request
+ resp, err := httpClient.Do(req)
+ require.NoError(t, err, "Should be able to send OPTIONS request")
+ defer resp.Body.Close()
+
+ // Verify CORS headers in response
+ assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header")
+ assert.Contains(t, resp.Header.Get("Access-Control-Allow-Methods"), "PUT", "Should allow PUT method")
+ assert.Contains(t, resp.Header.Get("Access-Control-Allow-Headers"), "Content-Type", "Should allow Content-Type header")
+ assert.Contains(t, resp.Header.Get("Access-Control-Allow-Headers"), "Authorization", "Should allow Authorization header")
+ assert.Equal(t, "3600", resp.Header.Get("Access-Control-Max-Age"), "Should have correct Max-Age header")
+ assert.Contains(t, resp.Header.Get("Access-Control-Expose-Headers"), "ETag", "Should expose ETag header")
+ assert.Equal(t, http.StatusOK, resp.StatusCode, "OPTIONS request should return 200")
+}
+
+// TestCORSActualRequest tests CORS behavior with actual requests
+func TestCORSActualRequest(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Set up CORS configuration
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET", "PUT"},
+ AllowedOrigins: []string{"https://example.com"},
+ ExposeHeaders: []string{"ETag", "Content-Length"},
+ MaxAgeSeconds: 3600,
+ },
+ },
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ require.NoError(t, err, "Should be able to put CORS configuration")
+
+ // First, put an object using S3 client
+ objectKey := "test-cors-object"
+ _, err = client.PutObject(context.TODO(), &s3.PutObjectInput{
+ Bucket: aws.String(bucketName),
+ Key: aws.String(objectKey),
+ Body: strings.NewReader("Test CORS content"),
+ })
+ require.NoError(t, err, "Should be able to put object")
+
+ // Test GET request with CORS headers using raw HTTP
+ httpClient := &http.Client{Timeout: 10 * time.Second}
+
+ req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s/%s", getDefaultConfig().Endpoint, bucketName, objectKey), nil)
+ require.NoError(t, err, "Should be able to create GET request")
+
+ // Add Origin header to simulate CORS request
+ req.Header.Set("Origin", "https://example.com")
+
+ // Send the request
+ resp, err := httpClient.Do(req)
+ require.NoError(t, err, "Should be able to send GET request")
+ defer resp.Body.Close()
+
+ // Verify CORS headers in response
+ assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header")
+ assert.Contains(t, resp.Header.Get("Access-Control-Expose-Headers"), "ETag", "Should expose ETag header")
+ assert.Equal(t, http.StatusOK, resp.StatusCode, "GET request should return 200")
+}
+
+// TestCORSOriginMatching tests origin matching with different patterns
+func TestCORSOriginMatching(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ testCases := []struct {
+ name string
+ allowedOrigins []string
+ requestOrigin string
+ shouldAllow bool
+ }{
+ {
+ name: "exact match",
+ allowedOrigins: []string{"https://example.com"},
+ requestOrigin: "https://example.com",
+ shouldAllow: true,
+ },
+ {
+ name: "wildcard match",
+ allowedOrigins: []string{"*"},
+ requestOrigin: "https://example.com",
+ shouldAllow: true,
+ },
+ {
+ name: "subdomain wildcard match",
+ allowedOrigins: []string{"https://*.example.com"},
+ requestOrigin: "https://api.example.com",
+ shouldAllow: true,
+ },
+ {
+ name: "no match",
+ allowedOrigins: []string{"https://example.com"},
+ requestOrigin: "https://malicious.com",
+ shouldAllow: false,
+ },
+ {
+ name: "subdomain wildcard no match",
+ allowedOrigins: []string{"https://*.example.com"},
+ requestOrigin: "https://example.com",
+ shouldAllow: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Set up CORS configuration for this test case
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: tc.allowedOrigins,
+ ExposeHeaders: []string{"ETag"},
+ MaxAgeSeconds: 3600,
+ },
+ },
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ require.NoError(t, err, "Should be able to put CORS configuration")
+
+ // Test preflight request
+ httpClient := &http.Client{Timeout: 10 * time.Second}
+
+ req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil)
+ require.NoError(t, err, "Should be able to create OPTIONS request")
+
+ req.Header.Set("Origin", tc.requestOrigin)
+ req.Header.Set("Access-Control-Request-Method", "GET")
+
+ resp, err := httpClient.Do(req)
+ require.NoError(t, err, "Should be able to send OPTIONS request")
+ defer resp.Body.Close()
+
+ if tc.shouldAllow {
+ assert.Equal(t, tc.requestOrigin, resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header")
+ assert.Contains(t, resp.Header.Get("Access-Control-Allow-Methods"), "GET", "Should allow GET method")
+ } else {
+ assert.Empty(t, resp.Header.Get("Access-Control-Allow-Origin"), "Should not have Allow-Origin header for disallowed origin")
+ }
+ })
+ }
+}
+
+// TestCORSHeaderMatching tests header matching with different patterns
+func TestCORSHeaderMatching(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ testCases := []struct {
+ name string
+ allowedHeaders []string
+ requestHeaders string
+ shouldAllow bool
+ expectedHeaders string
+ }{
+ {
+ name: "wildcard headers",
+ allowedHeaders: []string{"*"},
+ requestHeaders: "Content-Type, Authorization",
+ shouldAllow: true,
+ expectedHeaders: "Content-Type, Authorization",
+ },
+ {
+ name: "specific headers match",
+ allowedHeaders: []string{"Content-Type", "Authorization"},
+ requestHeaders: "Content-Type, Authorization",
+ shouldAllow: true,
+ expectedHeaders: "Content-Type, Authorization",
+ },
+ {
+ name: "partial header match",
+ allowedHeaders: []string{"Content-Type"},
+ requestHeaders: "Content-Type",
+ shouldAllow: true,
+ expectedHeaders: "Content-Type",
+ },
+ {
+ name: "case insensitive match",
+ allowedHeaders: []string{"content-type"},
+ requestHeaders: "Content-Type",
+ shouldAllow: true,
+ expectedHeaders: "Content-Type",
+ },
+ {
+ name: "disallowed header",
+ allowedHeaders: []string{"Content-Type"},
+ requestHeaders: "Authorization",
+ shouldAllow: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Set up CORS configuration for this test case
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: tc.allowedHeaders,
+ AllowedMethods: []string{"GET", "POST"},
+ AllowedOrigins: []string{"https://example.com"},
+ ExposeHeaders: []string{"ETag"},
+ MaxAgeSeconds: 3600,
+ },
+ },
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ require.NoError(t, err, "Should be able to put CORS configuration")
+
+ // Test preflight request
+ httpClient := &http.Client{Timeout: 10 * time.Second}
+
+ req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil)
+ require.NoError(t, err, "Should be able to create OPTIONS request")
+
+ req.Header.Set("Origin", "https://example.com")
+ req.Header.Set("Access-Control-Request-Method", "POST")
+ req.Header.Set("Access-Control-Request-Headers", tc.requestHeaders)
+
+ resp, err := httpClient.Do(req)
+ require.NoError(t, err, "Should be able to send OPTIONS request")
+ defer resp.Body.Close()
+
+ if tc.shouldAllow {
+ assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header")
+ allowedHeaders := resp.Header.Get("Access-Control-Allow-Headers")
+ for _, header := range strings.Split(tc.expectedHeaders, ", ") {
+ assert.Contains(t, allowedHeaders, header, "Should allow header: %s", header)
+ }
+ } else {
+ // Even if headers are not allowed, the origin should still be in the response
+ // but the headers should not be echoed back
+ assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header")
+ allowedHeaders := resp.Header.Get("Access-Control-Allow-Headers")
+ assert.NotContains(t, allowedHeaders, "Authorization", "Should not allow Authorization header")
+ }
+ })
+ }
+}
+
+// TestCORSWithoutConfiguration tests CORS behavior when no configuration is set
+func TestCORSWithoutConfiguration(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Test preflight request without CORS configuration
+ httpClient := &http.Client{Timeout: 10 * time.Second}
+
+ req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil)
+ require.NoError(t, err, "Should be able to create OPTIONS request")
+
+ req.Header.Set("Origin", "https://example.com")
+ req.Header.Set("Access-Control-Request-Method", "GET")
+
+ resp, err := httpClient.Do(req)
+ require.NoError(t, err, "Should be able to send OPTIONS request")
+ defer resp.Body.Close()
+
+ // Without CORS configuration, CORS headers should not be present
+ assert.Empty(t, resp.Header.Get("Access-Control-Allow-Origin"), "Should not have Allow-Origin header without CORS config")
+ assert.Empty(t, resp.Header.Get("Access-Control-Allow-Methods"), "Should not have Allow-Methods header without CORS config")
+ assert.Empty(t, resp.Header.Get("Access-Control-Allow-Headers"), "Should not have Allow-Headers header without CORS config")
+}
+
+// TestCORSMethodMatching tests method matching
+func TestCORSMethodMatching(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Set up CORS configuration with limited methods
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET", "POST"},
+ AllowedOrigins: []string{"https://example.com"},
+ ExposeHeaders: []string{"ETag"},
+ MaxAgeSeconds: 3600,
+ },
+ },
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ require.NoError(t, err, "Should be able to put CORS configuration")
+
+ testCases := []struct {
+ method string
+ shouldAllow bool
+ }{
+ {"GET", true},
+ {"POST", true},
+ {"PUT", false},
+ {"DELETE", false},
+ {"HEAD", false},
+ }
+
+ for _, tc := range testCases {
+ t.Run(fmt.Sprintf("method_%s", tc.method), func(t *testing.T) {
+ httpClient := &http.Client{Timeout: 10 * time.Second}
+
+ req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil)
+ require.NoError(t, err, "Should be able to create OPTIONS request")
+
+ req.Header.Set("Origin", "https://example.com")
+ req.Header.Set("Access-Control-Request-Method", tc.method)
+
+ resp, err := httpClient.Do(req)
+ require.NoError(t, err, "Should be able to send OPTIONS request")
+ defer resp.Body.Close()
+
+ if tc.shouldAllow {
+ assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header")
+ assert.Contains(t, resp.Header.Get("Access-Control-Allow-Methods"), tc.method, "Should allow method: %s", tc.method)
+ } else {
+ // Even if method is not allowed, the origin should still be in the response
+ // but the method should not be in the allowed methods
+ assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header")
+ allowedMethods := resp.Header.Get("Access-Control-Allow-Methods")
+ assert.NotContains(t, allowedMethods, tc.method, "Should not allow method: %s", tc.method)
+ }
+ })
+ }
+}
+
+// TestCORSMultipleRulesMatching tests CORS with multiple rules
+func TestCORSMultipleRulesMatching(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Set up CORS configuration with multiple rules
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"Content-Type"},
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{"https://example.com"},
+ ExposeHeaders: []string{"ETag"},
+ MaxAgeSeconds: 3600,
+ },
+ {
+ AllowedHeaders: []string{"Authorization"},
+ AllowedMethods: []string{"POST", "PUT"},
+ AllowedOrigins: []string{"https://api.example.com"},
+ ExposeHeaders: []string{"Content-Length"},
+ MaxAgeSeconds: 7200,
+ },
+ },
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ require.NoError(t, err, "Should be able to put CORS configuration")
+
+ // Test first rule
+ httpClient := &http.Client{Timeout: 10 * time.Second}
+
+ req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil)
+ require.NoError(t, err, "Should be able to create OPTIONS request")
+
+ req.Header.Set("Origin", "https://example.com")
+ req.Header.Set("Access-Control-Request-Method", "GET")
+ req.Header.Set("Access-Control-Request-Headers", "Content-Type")
+
+ resp, err := httpClient.Do(req)
+ require.NoError(t, err, "Should be able to send OPTIONS request")
+ defer resp.Body.Close()
+
+ assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should match first rule")
+ assert.Contains(t, resp.Header.Get("Access-Control-Allow-Methods"), "GET", "Should allow GET method")
+ assert.Contains(t, resp.Header.Get("Access-Control-Allow-Headers"), "Content-Type", "Should allow Content-Type header")
+ assert.Equal(t, "3600", resp.Header.Get("Access-Control-Max-Age"), "Should have first rule's max age")
+
+ // Test second rule
+ req2, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil)
+ require.NoError(t, err, "Should be able to create OPTIONS request")
+
+ req2.Header.Set("Origin", "https://api.example.com")
+ req2.Header.Set("Access-Control-Request-Method", "POST")
+ req2.Header.Set("Access-Control-Request-Headers", "Authorization")
+
+ resp2, err := httpClient.Do(req2)
+ require.NoError(t, err, "Should be able to send OPTIONS request")
+ defer resp2.Body.Close()
+
+ assert.Equal(t, "https://api.example.com", resp2.Header.Get("Access-Control-Allow-Origin"), "Should match second rule")
+ assert.Contains(t, resp2.Header.Get("Access-Control-Allow-Methods"), "POST", "Should allow POST method")
+ assert.Contains(t, resp2.Header.Get("Access-Control-Allow-Headers"), "Authorization", "Should allow Authorization header")
+ assert.Equal(t, "7200", resp2.Header.Get("Access-Control-Max-Age"), "Should have second rule's max age")
+}
+
+// TestServiceLevelCORS tests that service-level endpoints (like /status) get proper CORS headers
+func TestServiceLevelCORS(t *testing.T) {
+ assert := assert.New(t)
+
+ endpoints := []string{
+ "/",
+ "/status",
+ "/healthz",
+ }
+
+ for _, endpoint := range endpoints {
+ t.Run(fmt.Sprintf("endpoint_%s", strings.ReplaceAll(endpoint, "/", "_")), func(t *testing.T) {
+ req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s%s", getDefaultConfig().Endpoint, endpoint), nil)
+ assert.NoError(err)
+
+ // Add Origin header to trigger CORS
+ req.Header.Set("Origin", "http://example.com")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ assert.NoError(err)
+ defer resp.Body.Close()
+
+ // Should return 200 OK
+ assert.Equal(http.StatusOK, resp.StatusCode)
+
+ // Should have CORS headers set
+ assert.Equal("*", resp.Header.Get("Access-Control-Allow-Origin"))
+ assert.Equal("*", resp.Header.Get("Access-Control-Expose-Headers"))
+ assert.Equal("*", resp.Header.Get("Access-Control-Allow-Methods"))
+ assert.Equal("*", resp.Header.Get("Access-Control-Allow-Headers"))
+ })
+ }
+}
+
+// TestServiceLevelCORSWithoutOrigin tests that service-level endpoints without Origin header don't get CORS headers
+func TestServiceLevelCORSWithoutOrigin(t *testing.T) {
+ assert := assert.New(t)
+
+ req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/status", getDefaultConfig().Endpoint), nil)
+ assert.NoError(err)
+
+ // No Origin header
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ assert.NoError(err)
+ defer resp.Body.Close()
+
+ // Should return 200 OK
+ assert.Equal(http.StatusOK, resp.StatusCode)
+
+ // Should not have CORS headers set (or have empty values)
+ corsHeaders := []string{
+ "Access-Control-Allow-Origin",
+ "Access-Control-Expose-Headers",
+ "Access-Control-Allow-Methods",
+ "Access-Control-Allow-Headers",
+ }
+
+ for _, header := range corsHeaders {
+ value := resp.Header.Get(header)
+ // Headers should either be empty or not present
+ assert.True(value == "" || value == "*", "Header %s should be empty or wildcard, got: %s", header, value)
+ }
+}
diff --git a/test/s3/cors/s3_cors_test.go b/test/s3/cors/s3_cors_test.go
new file mode 100644
index 000000000..8f1745adf
--- /dev/null
+++ b/test/s3/cors/s3_cors_test.go
@@ -0,0 +1,600 @@
+package cors
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/k0kubun/pp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// S3TestConfig holds configuration for S3 tests
+type S3TestConfig struct {
+ Endpoint string
+ AccessKey string
+ SecretKey string
+ Region string
+ BucketPrefix string
+ UseSSL bool
+ SkipVerifySSL bool
+}
+
+// getDefaultConfig returns a fresh instance of the default test configuration
+// to avoid parallel test issues with global mutable state
+func getDefaultConfig() *S3TestConfig {
+ return &S3TestConfig{
+ Endpoint: "http://localhost:8333", // Default SeaweedFS S3 port
+ AccessKey: "some_access_key1",
+ SecretKey: "some_secret_key1",
+ Region: "us-east-1",
+ BucketPrefix: "test-cors-",
+ UseSSL: false,
+ SkipVerifySSL: true,
+ }
+}
+
+// getS3Client creates an AWS S3 client for testing
+func getS3Client(t *testing.T) *s3.Client {
+ defaultConfig := getDefaultConfig()
+ cfg, err := config.LoadDefaultConfig(context.TODO(),
+ config.WithRegion(defaultConfig.Region),
+ config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
+ defaultConfig.AccessKey,
+ defaultConfig.SecretKey,
+ "",
+ )),
+ config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(
+ func(service, region string, options ...interface{}) (aws.Endpoint, error) {
+ return aws.Endpoint{
+ URL: defaultConfig.Endpoint,
+ SigningRegion: defaultConfig.Region,
+ }, nil
+ })),
+ )
+ require.NoError(t, err)
+
+ client := s3.NewFromConfig(cfg, func(o *s3.Options) {
+ o.UsePathStyle = true
+ })
+ return client
+}
+
+// createTestBucket creates a test bucket with a unique name
+func createTestBucket(t *testing.T, client *s3.Client) string {
+ defaultConfig := getDefaultConfig()
+ bucketName := fmt.Sprintf("%s%d", defaultConfig.BucketPrefix, time.Now().UnixNano())
+
+ _, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{
+ Bucket: aws.String(bucketName),
+ })
+ require.NoError(t, err)
+
+ return bucketName
+}
+
+// cleanupTestBucket removes the test bucket and all its contents
+func cleanupTestBucket(t *testing.T, client *s3.Client, bucketName string) {
+ // First, delete all objects in the bucket
+ listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
+ Bucket: aws.String(bucketName),
+ })
+ if err == nil {
+ for _, obj := range listResp.Contents {
+ _, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
+ Bucket: aws.String(bucketName),
+ Key: obj.Key,
+ })
+ if err != nil {
+ t.Logf("Warning: failed to delete object %s: %v", *obj.Key, err)
+ }
+ }
+ }
+
+ // Then delete the bucket
+ _, err = client.DeleteBucket(context.TODO(), &s3.DeleteBucketInput{
+ Bucket: aws.String(bucketName),
+ })
+ if err != nil {
+ t.Logf("Warning: failed to delete bucket %s: %v", bucketName, err)
+ }
+}
+
+// TestCORSConfigurationManagement tests basic CORS configuration CRUD operations
+func TestCORSConfigurationManagement(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Test 1: Get CORS configuration when none exists (should return error)
+ _, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ })
+ assert.Error(t, err, "Should get error when no CORS configuration exists")
+
+ // Test 2: Put CORS configuration
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET", "POST", "PUT"},
+ AllowedOrigins: []string{"https://example.com"},
+ ExposeHeaders: []string{"ETag"},
+ MaxAgeSeconds: 3600,
+ },
+ },
+ }
+
+ _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ assert.NoError(t, err, "Should be able to put CORS configuration")
+
+ // Test 3: Get CORS configuration
+ getResp, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ })
+ assert.NoError(t, err, "Should be able to get CORS configuration")
+ assert.NotNil(t, getResp.CORSRules, "CORS configuration should not be nil")
+ assert.Len(t, getResp.CORSRules, 1, "Should have one CORS rule")
+
+ rule := getResp.CORSRules[0]
+ assert.Equal(t, []string{"*"}, rule.AllowedHeaders, "Allowed headers should match")
+ assert.Equal(t, []string{"GET", "POST", "PUT"}, rule.AllowedMethods, "Allowed methods should match")
+ assert.Equal(t, []string{"https://example.com"}, rule.AllowedOrigins, "Allowed origins should match")
+ assert.Equal(t, []string{"ETag"}, rule.ExposeHeaders, "Expose headers should match")
+ assert.Equal(t, int32(3600), rule.MaxAgeSeconds, "Max age should match")
+
+ // Test 4: Update CORS configuration
+ updatedCorsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"Content-Type"},
+ AllowedMethods: []string{"GET", "POST"},
+ AllowedOrigins: []string{"https://example.com", "https://another.com"},
+ ExposeHeaders: []string{"ETag", "Content-Length"},
+ MaxAgeSeconds: 7200,
+ },
+ },
+ }
+
+ _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: updatedCorsConfig,
+ })
+ assert.NoError(t, err, "Should be able to update CORS configuration")
+
+ // Verify the update
+ getResp, err = client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ })
+ assert.NoError(t, err, "Should be able to get updated CORS configuration")
+ rule = getResp.CORSRules[0]
+ assert.Equal(t, []string{"Content-Type"}, rule.AllowedHeaders, "Updated allowed headers should match")
+ assert.Equal(t, []string{"https://example.com", "https://another.com"}, rule.AllowedOrigins, "Updated allowed origins should match")
+
+ // Test 5: Delete CORS configuration
+ _, err = client.DeleteBucketCors(context.TODO(), &s3.DeleteBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ })
+ assert.NoError(t, err, "Should be able to delete CORS configuration")
+
+ // Verify deletion
+ _, err = client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ })
+ assert.Error(t, err, "Should get error after deleting CORS configuration")
+}
+
+// TestCORSMultipleRules tests CORS configuration with multiple rules
+func TestCORSMultipleRules(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Create CORS configuration with multiple rules
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET", "HEAD"},
+ AllowedOrigins: []string{"https://example.com"},
+ ExposeHeaders: []string{"ETag"},
+ MaxAgeSeconds: 3600,
+ },
+ {
+ AllowedHeaders: []string{"Content-Type", "Authorization"},
+ AllowedMethods: []string{"POST", "PUT", "DELETE"},
+ AllowedOrigins: []string{"https://app.example.com"},
+ ExposeHeaders: []string{"ETag", "Content-Length"},
+ MaxAgeSeconds: 7200,
+ },
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{"*"},
+ ExposeHeaders: []string{"ETag"},
+ MaxAgeSeconds: 1800,
+ },
+ },
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ assert.NoError(t, err, "Should be able to put CORS configuration with multiple rules")
+
+ // Get and verify the configuration
+ getResp, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ })
+ assert.NoError(t, err, "Should be able to get CORS configuration")
+ assert.Len(t, getResp.CORSRules, 3, "Should have three CORS rules")
+
+ // Verify first rule
+ rule1 := getResp.CORSRules[0]
+ assert.Equal(t, []string{"*"}, rule1.AllowedHeaders)
+ assert.Equal(t, []string{"GET", "HEAD"}, rule1.AllowedMethods)
+ assert.Equal(t, []string{"https://example.com"}, rule1.AllowedOrigins)
+
+ // Verify second rule
+ rule2 := getResp.CORSRules[1]
+ assert.Equal(t, []string{"Content-Type", "Authorization"}, rule2.AllowedHeaders)
+ assert.Equal(t, []string{"POST", "PUT", "DELETE"}, rule2.AllowedMethods)
+ assert.Equal(t, []string{"https://app.example.com"}, rule2.AllowedOrigins)
+
+ // Verify third rule
+ rule3 := getResp.CORSRules[2]
+ assert.Equal(t, []string{"*"}, rule3.AllowedHeaders)
+ assert.Equal(t, []string{"GET"}, rule3.AllowedMethods)
+ assert.Equal(t, []string{"*"}, rule3.AllowedOrigins)
+}
+
+// TestCORSValidation tests CORS configuration validation
+func TestCORSValidation(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Test invalid HTTP method
+ invalidMethodConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"INVALID_METHOD"},
+ AllowedOrigins: []string{"https://example.com"},
+ },
+ },
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: invalidMethodConfig,
+ })
+ assert.Error(t, err, "Should get error for invalid HTTP method")
+
+ // Test empty origins
+ emptyOriginsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{},
+ },
+ },
+ }
+
+ _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: emptyOriginsConfig,
+ })
+ assert.Error(t, err, "Should get error for empty origins")
+
+ // Test negative MaxAge
+ negativeMaxAgeConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{"https://example.com"},
+ MaxAgeSeconds: -1,
+ },
+ },
+ }
+
+ _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: negativeMaxAgeConfig,
+ })
+ assert.Error(t, err, "Should get error for negative MaxAge")
+}
+
+// TestCORSWithWildcards tests CORS configuration with wildcard patterns
+func TestCORSWithWildcards(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Create CORS configuration with wildcard patterns
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET", "POST"},
+ AllowedOrigins: []string{"https://*.example.com"},
+ ExposeHeaders: []string{"*"},
+ MaxAgeSeconds: 3600,
+ },
+ },
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ assert.NoError(t, err, "Should be able to put CORS configuration with wildcards")
+
+ // Get and verify the configuration
+ getResp, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ })
+ assert.NoError(t, err, "Should be able to get CORS configuration")
+ assert.Len(t, getResp.CORSRules, 1, "Should have one CORS rule")
+
+ rule := getResp.CORSRules[0]
+ assert.Equal(t, []string{"*"}, rule.AllowedHeaders, "Wildcard headers should be preserved")
+ assert.Equal(t, []string{"https://*.example.com"}, rule.AllowedOrigins, "Wildcard origins should be preserved")
+ assert.Equal(t, []string{"*"}, rule.ExposeHeaders, "Wildcard expose headers should be preserved")
+}
+
+// TestCORSRuleLimit tests the maximum number of CORS rules
+func TestCORSRuleLimit(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Create CORS configuration with maximum allowed rules (100)
+ rules := make([]types.CORSRule, 100)
+ for i := 0; i < 100; i++ {
+ rules[i] = types.CORSRule{
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{fmt.Sprintf("https://example%d.com", i)},
+ MaxAgeSeconds: 3600,
+ }
+ }
+
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: rules,
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ assert.NoError(t, err, "Should be able to put CORS configuration with 100 rules")
+
+ // Try to add one more rule (should fail)
+ rules = append(rules, types.CORSRule{
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{"https://example101.com"},
+ MaxAgeSeconds: 3600,
+ })
+
+ corsConfig.CORSRules = rules
+
+ _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ assert.Error(t, err, "Should get error when exceeding maximum number of rules")
+}
+
+// TestCORSNonExistentBucket tests CORS operations on non-existent bucket
+func TestCORSNonExistentBucket(t *testing.T) {
+ client := getS3Client(t)
+ nonExistentBucket := "non-existent-bucket-cors-test"
+
+ // Test Get CORS on non-existent bucket
+ _, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{
+ Bucket: aws.String(nonExistentBucket),
+ })
+ assert.Error(t, err, "Should get error for non-existent bucket")
+
+ // Test Put CORS on non-existent bucket
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{"https://example.com"},
+ },
+ },
+ }
+
+ _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(nonExistentBucket),
+ CORSConfiguration: corsConfig,
+ })
+ assert.Error(t, err, "Should get error for non-existent bucket")
+
+ // Test Delete CORS on non-existent bucket
+ _, err = client.DeleteBucketCors(context.TODO(), &s3.DeleteBucketCorsInput{
+ Bucket: aws.String(nonExistentBucket),
+ })
+ assert.Error(t, err, "Should get error for non-existent bucket")
+}
+
+// TestCORSObjectOperations tests CORS behavior with object operations
+func TestCORSObjectOperations(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Set up CORS configuration
+ corsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
+ AllowedOrigins: []string{"https://example.com"},
+ ExposeHeaders: []string{"ETag", "Content-Length"},
+ MaxAgeSeconds: 3600,
+ },
+ },
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig,
+ })
+ assert.NoError(t, err, "Should be able to put CORS configuration")
+
+ // Test putting an object (this should work normally)
+ objectKey := "test-object.txt"
+ objectContent := "Hello, CORS World!"
+
+ _, err = client.PutObject(context.TODO(), &s3.PutObjectInput{
+ Bucket: aws.String(bucketName),
+ Key: aws.String(objectKey),
+ Body: strings.NewReader(objectContent),
+ })
+ assert.NoError(t, err, "Should be able to put object in CORS-enabled bucket")
+
+ // Test getting the object
+ getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{
+ Bucket: aws.String(bucketName),
+ Key: aws.String(objectKey),
+ })
+ assert.NoError(t, err, "Should be able to get object from CORS-enabled bucket")
+ assert.NotNil(t, getResp.Body, "Object body should not be nil")
+
+ // Test deleting the object
+ _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
+ Bucket: aws.String(bucketName),
+ Key: aws.String(objectKey),
+ })
+ assert.NoError(t, err, "Should be able to delete object from CORS-enabled bucket")
+}
+
+// TestCORSCaching tests CORS configuration caching behavior
+func TestCORSCaching(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Set up initial CORS configuration
+ corsConfig1 := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{"https://example.com"},
+ MaxAgeSeconds: 3600,
+ },
+ },
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig1,
+ })
+ assert.NoError(t, err, "Should be able to put initial CORS configuration")
+
+ // Get the configuration
+ getResp1, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ })
+ assert.NoError(t, err, "Should be able to get initial CORS configuration")
+ assert.Len(t, getResp1.CORSRules, 1, "Should have one CORS rule")
+
+ // Update the configuration
+ corsConfig2 := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"Content-Type"},
+ AllowedMethods: []string{"GET", "POST"},
+ AllowedOrigins: []string{"https://example.com", "https://another.com"},
+ MaxAgeSeconds: 7200,
+ },
+ },
+ }
+
+ _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: corsConfig2,
+ })
+ assert.NoError(t, err, "Should be able to update CORS configuration")
+
+ // Get the updated configuration (should reflect the changes)
+ getResp2, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ })
+ assert.NoError(t, err, "Should be able to get updated CORS configuration")
+ assert.Len(t, getResp2.CORSRules, 1, "Should have one CORS rule")
+
+ rule := getResp2.CORSRules[0]
+ assert.Equal(t, []string{"Content-Type"}, rule.AllowedHeaders, "Should have updated headers")
+ assert.Equal(t, []string{"GET", "POST"}, rule.AllowedMethods, "Should have updated methods")
+ assert.Equal(t, []string{"https://example.com", "https://another.com"}, rule.AllowedOrigins, "Should have updated origins")
+ assert.Equal(t, int32(7200), rule.MaxAgeSeconds, "Should have updated max age")
+}
+
+// TestCORSErrorHandling tests various error conditions
+func TestCORSErrorHandling(t *testing.T) {
+ client := getS3Client(t)
+ bucketName := createTestBucket(t, client)
+ defer cleanupTestBucket(t, client, bucketName)
+
+ // Test empty CORS configuration
+ emptyCorsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{},
+ }
+
+ _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: emptyCorsConfig,
+ })
+ assert.Error(t, err, "Should get error for empty CORS configuration")
+
+ // Test nil CORS configuration
+ _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: nil,
+ })
+ assert.Error(t, err, "Should get error for nil CORS configuration")
+
+ // Test CORS rule with empty methods
+ emptyMethodsConfig := &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedHeaders: []string{"*"},
+ AllowedMethods: []string{},
+ AllowedOrigins: []string{"https://example.com"},
+ },
+ },
+ }
+
+ _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{
+ Bucket: aws.String(bucketName),
+ CORSConfiguration: emptyMethodsConfig,
+ })
+ assert.Error(t, err, "Should get error for empty methods")
+}
+
+// Debugging helper to pretty print responses
+func debugResponse(t *testing.T, title string, response interface{}) {
+ t.Logf("=== %s ===", title)
+ pp.Println(response)
+}
diff --git a/weed/s3api/cors/cors.go b/weed/s3api/cors/cors.go
new file mode 100644
index 000000000..1eef71b72
--- /dev/null
+++ b/weed/s3api/cors/cors.go
@@ -0,0 +1,649 @@
+package cors
+
+import (
+ "context"
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "net/http"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+)
+
+// S3 metadata file name constant to avoid typos and reduce duplication
+const S3MetadataFileName = ".s3metadata"
+
+// CORSRule represents a single CORS rule
+type CORSRule struct {
+ ID string `xml:"ID,omitempty" json:"ID,omitempty"`
+ AllowedMethods []string `xml:"AllowedMethod" json:"AllowedMethods"`
+ AllowedOrigins []string `xml:"AllowedOrigin" json:"AllowedOrigins"`
+ AllowedHeaders []string `xml:"AllowedHeader,omitempty" json:"AllowedHeaders,omitempty"`
+ ExposeHeaders []string `xml:"ExposeHeader,omitempty" json:"ExposeHeaders,omitempty"`
+ MaxAgeSeconds *int `xml:"MaxAgeSeconds,omitempty" json:"MaxAgeSeconds,omitempty"`
+}
+
+// CORSConfiguration represents the CORS configuration for a bucket
+type CORSConfiguration struct {
+ XMLName xml.Name `xml:"CORSConfiguration"`
+ CORSRules []CORSRule `xml:"CORSRule" json:"CORSRules"`
+}
+
+// CORSRequest represents a CORS request
+type CORSRequest struct {
+ Origin string
+ Method string
+ RequestHeaders []string
+ IsPreflightRequest bool
+ AccessControlRequestMethod string
+ AccessControlRequestHeaders []string
+}
+
+// CORSResponse represents CORS response headers
+type CORSResponse struct {
+ AllowOrigin string
+ AllowMethods string
+ AllowHeaders string
+ ExposeHeaders string
+ MaxAge string
+ AllowCredentials bool
+}
+
+// ValidateConfiguration validates a CORS configuration
+func ValidateConfiguration(config *CORSConfiguration) error {
+ if config == nil {
+ return fmt.Errorf("CORS configuration cannot be nil")
+ }
+
+ if len(config.CORSRules) == 0 {
+ return fmt.Errorf("CORS configuration must have at least one rule")
+ }
+
+ if len(config.CORSRules) > 100 {
+ return fmt.Errorf("CORS configuration cannot have more than 100 rules")
+ }
+
+ for i, rule := range config.CORSRules {
+ if err := validateRule(&rule); err != nil {
+ return fmt.Errorf("invalid CORS rule at index %d: %v", i, err)
+ }
+ }
+
+ return nil
+}
+
+// validateRule validates a single CORS rule
+func validateRule(rule *CORSRule) error {
+ if len(rule.AllowedMethods) == 0 {
+ return fmt.Errorf("AllowedMethods cannot be empty")
+ }
+
+ if len(rule.AllowedOrigins) == 0 {
+ return fmt.Errorf("AllowedOrigins cannot be empty")
+ }
+
+ // Validate allowed methods
+ validMethods := map[string]bool{
+ "GET": true,
+ "PUT": true,
+ "POST": true,
+ "DELETE": true,
+ "HEAD": true,
+ }
+
+ for _, method := range rule.AllowedMethods {
+ if !validMethods[method] {
+ return fmt.Errorf("invalid HTTP method: %s", method)
+ }
+ }
+
+ // Validate origins
+ for _, origin := range rule.AllowedOrigins {
+ if origin == "*" {
+ continue
+ }
+ if err := validateOrigin(origin); err != nil {
+ return fmt.Errorf("invalid origin %s: %v", origin, err)
+ }
+ }
+
+ // Validate MaxAgeSeconds
+ if rule.MaxAgeSeconds != nil && *rule.MaxAgeSeconds < 0 {
+ return fmt.Errorf("MaxAgeSeconds cannot be negative")
+ }
+
+ return nil
+}
+
+// validateOrigin validates an origin string
+func validateOrigin(origin string) error {
+ if origin == "" {
+ return fmt.Errorf("origin cannot be empty")
+ }
+
+ // Special case: "*" is always valid
+ if origin == "*" {
+ return nil
+ }
+
+ // Count wildcards
+ wildcardCount := strings.Count(origin, "*")
+ if wildcardCount > 1 {
+ return fmt.Errorf("origin can contain at most one wildcard")
+ }
+
+ // If there's a wildcard, it should be in a valid position
+ if wildcardCount == 1 {
+ // Must be in the format: http://*.example.com or https://*.example.com
+ if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
+ return fmt.Errorf("origin with wildcard must start with http:// or https://")
+ }
+ }
+
+ return nil
+}
+
+// ParseRequest parses an HTTP request to extract CORS information
+func ParseRequest(r *http.Request) *CORSRequest {
+ corsReq := &CORSRequest{
+ Origin: r.Header.Get("Origin"),
+ Method: r.Method,
+ }
+
+ // Check if this is a preflight request
+ if r.Method == "OPTIONS" {
+ corsReq.IsPreflightRequest = true
+ corsReq.AccessControlRequestMethod = r.Header.Get("Access-Control-Request-Method")
+
+ if headers := r.Header.Get("Access-Control-Request-Headers"); headers != "" {
+ corsReq.AccessControlRequestHeaders = strings.Split(headers, ",")
+ for i := range corsReq.AccessControlRequestHeaders {
+ corsReq.AccessControlRequestHeaders[i] = strings.TrimSpace(corsReq.AccessControlRequestHeaders[i])
+ }
+ }
+ }
+
+ return corsReq
+}
+
+// EvaluateRequest evaluates a CORS request against a CORS configuration
+func EvaluateRequest(config *CORSConfiguration, corsReq *CORSRequest) (*CORSResponse, error) {
+ if config == nil || corsReq == nil {
+ return nil, fmt.Errorf("config and corsReq cannot be nil")
+ }
+
+ if corsReq.Origin == "" {
+ return nil, fmt.Errorf("origin header is required for CORS requests")
+ }
+
+ // Find the first rule that matches the origin
+ for _, rule := range config.CORSRules {
+ if matchesOrigin(rule.AllowedOrigins, corsReq.Origin) {
+ // For preflight requests, we need more detailed validation
+ if corsReq.IsPreflightRequest {
+ return buildPreflightResponse(&rule, corsReq), nil
+ } else {
+ // For actual requests, check method
+ if contains(rule.AllowedMethods, corsReq.Method) {
+ return buildResponse(&rule, corsReq), nil
+ }
+ }
+ }
+ }
+
+ return nil, fmt.Errorf("no matching CORS rule found")
+}
+
+// matchesRule checks if a CORS request matches a CORS rule
+func matchesRule(rule *CORSRule, corsReq *CORSRequest) bool {
+ // Check origin - this is the primary matching criterion
+ if !matchesOrigin(rule.AllowedOrigins, corsReq.Origin) {
+ return false
+ }
+
+ // For preflight requests, we need to validate both the requested method and headers
+ if corsReq.IsPreflightRequest {
+ // Check if the requested method is allowed
+ if corsReq.AccessControlRequestMethod != "" {
+ if !contains(rule.AllowedMethods, corsReq.AccessControlRequestMethod) {
+ return false
+ }
+ }
+
+ // Check if all requested headers are allowed
+ if len(corsReq.AccessControlRequestHeaders) > 0 {
+ for _, requestedHeader := range corsReq.AccessControlRequestHeaders {
+ if !matchesHeader(rule.AllowedHeaders, requestedHeader) {
+ return false
+ }
+ }
+ }
+
+ return true
+ }
+
+ // For non-preflight requests, check method matching
+ method := corsReq.Method
+ if !contains(rule.AllowedMethods, method) {
+ return false
+ }
+
+ return true
+}
+
+// matchesOrigin checks if an origin matches any of the allowed origins
+func matchesOrigin(allowedOrigins []string, origin string) bool {
+ for _, allowedOrigin := range allowedOrigins {
+ if allowedOrigin == "*" {
+ return true
+ }
+
+ if allowedOrigin == origin {
+ return true
+ }
+
+ // Check wildcard matching
+ if strings.Contains(allowedOrigin, "*") {
+ if matchesWildcard(allowedOrigin, origin) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// matchesWildcard checks if an origin matches a wildcard pattern
+// Uses string manipulation instead of regex for better performance
+func matchesWildcard(pattern, origin string) bool {
+ // Handle simple cases first
+ if pattern == "*" {
+ return true
+ }
+ if pattern == origin {
+ return true
+ }
+
+ // For CORS, we typically only deal with * wildcards (not ? wildcards)
+ // Use string manipulation for * wildcards only (more efficient than regex)
+
+ // Split pattern by wildcards
+ parts := strings.Split(pattern, "*")
+ if len(parts) == 1 {
+ // No wildcards, exact match
+ return pattern == origin
+ }
+
+ // Check if string starts with first part
+ if len(parts[0]) > 0 && !strings.HasPrefix(origin, parts[0]) {
+ return false
+ }
+
+ // Check if string ends with last part
+ if len(parts[len(parts)-1]) > 0 && !strings.HasSuffix(origin, parts[len(parts)-1]) {
+ return false
+ }
+
+ // Check middle parts
+ searchStr := origin
+ if len(parts[0]) > 0 {
+ searchStr = searchStr[len(parts[0]):]
+ }
+ if len(parts[len(parts)-1]) > 0 {
+ searchStr = searchStr[:len(searchStr)-len(parts[len(parts)-1])]
+ }
+
+ for i := 1; i < len(parts)-1; i++ {
+ if len(parts[i]) > 0 {
+ index := strings.Index(searchStr, parts[i])
+ if index == -1 {
+ return false
+ }
+ searchStr = searchStr[index+len(parts[i]):]
+ }
+ }
+
+ return true
+}
+
+// matchesHeader checks if a header matches allowed headers
+func matchesHeader(allowedHeaders []string, header string) bool {
+ if len(allowedHeaders) == 0 {
+ return true // No restrictions
+ }
+
+ for _, allowedHeader := range allowedHeaders {
+ if allowedHeader == "*" {
+ return true
+ }
+
+ if strings.EqualFold(allowedHeader, header) {
+ return true
+ }
+
+ // Check wildcard matching for headers
+ if strings.Contains(allowedHeader, "*") {
+ if matchesWildcard(strings.ToLower(allowedHeader), strings.ToLower(header)) {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+// buildPreflightResponse builds a CORS response for preflight requests
+// This function allows partial matches - origin can match while methods/headers may not
+func buildPreflightResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
+ response := &CORSResponse{
+ AllowOrigin: corsReq.Origin,
+ }
+
+ // Check if the requested method is allowed
+ methodAllowed := corsReq.AccessControlRequestMethod == "" || contains(rule.AllowedMethods, corsReq.AccessControlRequestMethod)
+
+ // Check requested headers
+ var allowedRequestHeaders []string
+ allHeadersAllowed := true
+
+ if len(corsReq.AccessControlRequestHeaders) > 0 {
+ // Check if wildcard is allowed
+ hasWildcard := false
+ for _, header := range rule.AllowedHeaders {
+ if header == "*" {
+ hasWildcard = true
+ break
+ }
+ }
+
+ if hasWildcard {
+ // All requested headers are allowed with wildcard
+ allowedRequestHeaders = corsReq.AccessControlRequestHeaders
+ } else {
+ // Check each requested header individually
+ for _, requestedHeader := range corsReq.AccessControlRequestHeaders {
+ if matchesHeader(rule.AllowedHeaders, requestedHeader) {
+ allowedRequestHeaders = append(allowedRequestHeaders, requestedHeader)
+ } else {
+ allHeadersAllowed = false
+ }
+ }
+ }
+ }
+
+ // Only set method and header info if both method and ALL headers are allowed
+ if methodAllowed && allHeadersAllowed {
+ response.AllowMethods = strings.Join(rule.AllowedMethods, ", ")
+
+ if len(allowedRequestHeaders) > 0 {
+ response.AllowHeaders = strings.Join(allowedRequestHeaders, ", ")
+ }
+
+ // Set exposed headers
+ if len(rule.ExposeHeaders) > 0 {
+ response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
+ }
+
+ // Set max age
+ if rule.MaxAgeSeconds != nil {
+ response.MaxAge = strconv.Itoa(*rule.MaxAgeSeconds)
+ }
+ }
+
+ return response
+}
+
+// buildResponse builds a CORS response from a matching rule
+func buildResponse(rule *CORSRule, corsReq *CORSRequest) *CORSResponse {
+ response := &CORSResponse{
+ AllowOrigin: corsReq.Origin,
+ }
+
+ // Set allowed methods - for preflight requests, return all allowed methods
+ if corsReq.IsPreflightRequest {
+ response.AllowMethods = strings.Join(rule.AllowedMethods, ", ")
+ } else {
+ // For non-preflight requests, return all allowed methods
+ response.AllowMethods = strings.Join(rule.AllowedMethods, ", ")
+ }
+
+ // Set allowed headers
+ if corsReq.IsPreflightRequest && len(rule.AllowedHeaders) > 0 {
+ // For preflight requests, check if wildcard is allowed
+ hasWildcard := false
+ for _, header := range rule.AllowedHeaders {
+ if header == "*" {
+ hasWildcard = true
+ break
+ }
+ }
+
+ if hasWildcard && len(corsReq.AccessControlRequestHeaders) > 0 {
+ // Return the specific headers that were requested when wildcard is allowed
+ response.AllowHeaders = strings.Join(corsReq.AccessControlRequestHeaders, ", ")
+ } else if len(corsReq.AccessControlRequestHeaders) > 0 {
+ // For non-wildcard cases, return the requested headers (preserving case)
+ // since we already validated they are allowed in matchesRule
+ response.AllowHeaders = strings.Join(corsReq.AccessControlRequestHeaders, ", ")
+ } else {
+ // Fallback to configured headers if no specific headers were requested
+ response.AllowHeaders = strings.Join(rule.AllowedHeaders, ", ")
+ }
+ } else if len(rule.AllowedHeaders) > 0 {
+ // For non-preflight requests, return the allowed headers from the rule
+ response.AllowHeaders = strings.Join(rule.AllowedHeaders, ", ")
+ }
+
+ // Set exposed headers
+ if len(rule.ExposeHeaders) > 0 {
+ response.ExposeHeaders = strings.Join(rule.ExposeHeaders, ", ")
+ }
+
+ // Set max age
+ if rule.MaxAgeSeconds != nil {
+ response.MaxAge = strconv.Itoa(*rule.MaxAgeSeconds)
+ }
+
+ return response
+}
+
+// contains checks if a slice contains a string
+func contains(slice []string, item string) bool {
+ for _, s := range slice {
+ if s == item {
+ return true
+ }
+ }
+ return false
+}
+
+// ApplyHeaders applies CORS headers to an HTTP response
+func ApplyHeaders(w http.ResponseWriter, corsResp *CORSResponse) {
+ if corsResp == nil {
+ return
+ }
+
+ if corsResp.AllowOrigin != "" {
+ w.Header().Set("Access-Control-Allow-Origin", corsResp.AllowOrigin)
+ }
+
+ if corsResp.AllowMethods != "" {
+ w.Header().Set("Access-Control-Allow-Methods", corsResp.AllowMethods)
+ }
+
+ if corsResp.AllowHeaders != "" {
+ w.Header().Set("Access-Control-Allow-Headers", corsResp.AllowHeaders)
+ }
+
+ if corsResp.ExposeHeaders != "" {
+ w.Header().Set("Access-Control-Expose-Headers", corsResp.ExposeHeaders)
+ }
+
+ if corsResp.MaxAge != "" {
+ w.Header().Set("Access-Control-Max-Age", corsResp.MaxAge)
+ }
+
+ if corsResp.AllowCredentials {
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ }
+}
+
+// FilerClient interface for dependency injection
+type FilerClient interface {
+ WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error
+}
+
+// EntryGetter interface for getting filer entries
+type EntryGetter interface {
+ GetEntry(directory, name string) (*filer_pb.Entry, error)
+}
+
+// Storage provides CORS configuration storage operations
+type Storage struct {
+ filerClient FilerClient
+ entryGetter EntryGetter
+ bucketsPath string
+}
+
+// NewStorage creates a new CORS storage instance
+func NewStorage(filerClient FilerClient, entryGetter EntryGetter, bucketsPath string) *Storage {
+ return &Storage{
+ filerClient: filerClient,
+ entryGetter: entryGetter,
+ bucketsPath: bucketsPath,
+ }
+}
+
+// Store stores CORS configuration in the filer
+func (s *Storage) Store(bucket string, config *CORSConfiguration) error {
+ // Store in bucket metadata
+ bucketMetadataPath := filepath.Join(s.bucketsPath, bucket, S3MetadataFileName)
+
+ // Get existing metadata
+ existingEntry, err := s.entryGetter.GetEntry("", bucketMetadataPath)
+ var metadata map[string]interface{}
+
+ if err == nil && existingEntry != nil && len(existingEntry.Content) > 0 {
+ if err := json.Unmarshal(existingEntry.Content, &metadata); err != nil {
+ glog.V(1).Infof("Failed to unmarshal existing metadata: %v", err)
+ metadata = make(map[string]interface{})
+ }
+ } else {
+ metadata = make(map[string]interface{})
+ }
+
+ metadata["cors"] = config
+
+ metadataBytes, err := json.Marshal(metadata)
+ if err != nil {
+ return fmt.Errorf("failed to marshal bucket metadata: %v", err)
+ }
+
+ // Store metadata
+ return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
+ request := &filer_pb.CreateEntryRequest{
+ Directory: s.bucketsPath + "/" + bucket,
+ Entry: &filer_pb.Entry{
+ Name: S3MetadataFileName,
+ IsDirectory: false,
+ Attributes: &filer_pb.FuseAttributes{
+ Crtime: time.Now().Unix(),
+ Mtime: time.Now().Unix(),
+ FileMode: 0644,
+ },
+ Content: metadataBytes,
+ },
+ }
+
+ _, err := client.CreateEntry(context.Background(), request)
+ return err
+ })
+}
+
+// Load loads CORS configuration from the filer
+func (s *Storage) Load(bucket string) (*CORSConfiguration, error) {
+ bucketMetadataPath := filepath.Join(s.bucketsPath, bucket, S3MetadataFileName)
+
+ entry, err := s.entryGetter.GetEntry("", bucketMetadataPath)
+ if err != nil || entry == nil {
+ return nil, fmt.Errorf("no CORS configuration found")
+ }
+
+ if len(entry.Content) == 0 {
+ return nil, fmt.Errorf("no CORS configuration found")
+ }
+
+ var metadata map[string]interface{}
+ if err := json.Unmarshal(entry.Content, &metadata); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal metadata: %v", err)
+ }
+
+ corsData, exists := metadata["cors"]
+ if !exists {
+ return nil, fmt.Errorf("no CORS configuration found")
+ }
+
+ // Convert back to CORSConfiguration
+ corsBytes, err := json.Marshal(corsData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal CORS data: %v", err)
+ }
+
+ var config CORSConfiguration
+ if err := json.Unmarshal(corsBytes, &config); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal CORS configuration: %v", err)
+ }
+
+ return &config, nil
+}
+
+// Delete deletes CORS configuration from the filer
+func (s *Storage) Delete(bucket string) error {
+ bucketMetadataPath := filepath.Join(s.bucketsPath, bucket, S3MetadataFileName)
+
+ entry, err := s.entryGetter.GetEntry("", bucketMetadataPath)
+ if err != nil || entry == nil {
+ return nil // Already deleted or doesn't exist
+ }
+
+ var metadata map[string]interface{}
+ if len(entry.Content) > 0 {
+ if err := json.Unmarshal(entry.Content, &metadata); err != nil {
+ return fmt.Errorf("failed to unmarshal metadata: %v", err)
+ }
+ } else {
+ return nil // No metadata to delete
+ }
+
+ // Remove CORS configuration
+ delete(metadata, "cors")
+
+ metadataBytes, err := json.Marshal(metadata)
+ if err != nil {
+ return fmt.Errorf("failed to marshal metadata: %v", err)
+ }
+
+ // Update metadata
+ return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
+ request := &filer_pb.CreateEntryRequest{
+ Directory: s.bucketsPath + "/" + bucket,
+ Entry: &filer_pb.Entry{
+ Name: S3MetadataFileName,
+ IsDirectory: false,
+ Attributes: &filer_pb.FuseAttributes{
+ Crtime: time.Now().Unix(),
+ Mtime: time.Now().Unix(),
+ FileMode: 0644,
+ },
+ Content: metadataBytes,
+ },
+ }
+
+ _, err := client.CreateEntry(context.Background(), request)
+ return err
+ })
+}
diff --git a/weed/s3api/cors/cors_test.go b/weed/s3api/cors/cors_test.go
new file mode 100644
index 000000000..1b5c54028
--- /dev/null
+++ b/weed/s3api/cors/cors_test.go
@@ -0,0 +1,526 @@
+package cors
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+)
+
+func TestValidateConfiguration(t *testing.T) {
+ tests := []struct {
+ name string
+ config *CORSConfiguration
+ wantErr bool
+ }{
+ {
+ name: "nil config",
+ config: nil,
+ wantErr: true,
+ },
+ {
+ name: "empty rules",
+ config: &CORSConfiguration{
+ CORSRules: []CORSRule{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "valid single rule",
+ config: &CORSConfiguration{
+ CORSRules: []CORSRule{
+ {
+ AllowedMethods: []string{"GET", "POST"},
+ AllowedOrigins: []string{"*"},
+ },
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "too many rules",
+ config: &CORSConfiguration{
+ CORSRules: make([]CORSRule, 101),
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid method",
+ config: &CORSConfiguration{
+ CORSRules: []CORSRule{
+ {
+ AllowedMethods: []string{"INVALID"},
+ AllowedOrigins: []string{"*"},
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty origins",
+ config: &CORSConfiguration{
+ CORSRules: []CORSRule{
+ {
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{},
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid origin with multiple wildcards",
+ config: &CORSConfiguration{
+ CORSRules: []CORSRule{
+ {
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{"http://*.*.example.com"},
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "negative MaxAgeSeconds",
+ config: &CORSConfiguration{
+ CORSRules: []CORSRule{
+ {
+ AllowedMethods: []string{"GET"},
+ AllowedOrigins: []string{"*"},
+ MaxAgeSeconds: intPtr(-1),
+ },
+ },
+ },
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := ValidateConfiguration(tt.config)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ValidateConfiguration() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidateOrigin(t *testing.T) {
+ tests := []struct {
+ name string
+ origin string
+ wantErr bool
+ }{
+ {
+ name: "empty origin",
+ origin: "",
+ wantErr: true,
+ },
+ {
+ name: "valid origin",
+ origin: "http://example.com",
+ wantErr: false,
+ },
+ {
+ name: "wildcard origin",
+ origin: "*",
+ wantErr: false,
+ },
+ {
+ name: "valid wildcard origin",
+ origin: "http://*.example.com",
+ wantErr: false,
+ },
+ {
+ name: "https wildcard origin",
+ origin: "https://*.example.com",
+ wantErr: false,
+ },
+ {
+ name: "invalid wildcard origin",
+ origin: "*.example.com",
+ wantErr: true,
+ },
+ {
+ name: "multiple wildcards",
+ origin: "http://*.*.example.com",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateOrigin(tt.origin)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validateOrigin() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestParseRequest(t *testing.T) {
+ tests := []struct {
+ name string
+ req *http.Request
+ want *CORSRequest
+ }{
+ {
+ name: "simple GET request",
+ req: &http.Request{
+ Method: "GET",
+ Header: http.Header{
+ "Origin": []string{"http://example.com"},
+ },
+ },
+ want: &CORSRequest{
+ Origin: "http://example.com",
+ Method: "GET",
+ IsPreflightRequest: false,
+ },
+ },
+ {
+ name: "OPTIONS preflight request",
+ req: &http.Request{
+ Method: "OPTIONS",
+ Header: http.Header{
+ "Origin": []string{"http://example.com"},
+ "Access-Control-Request-Method": []string{"PUT"},
+ "Access-Control-Request-Headers": []string{"Content-Type, Authorization"},
+ },
+ },
+ want: &CORSRequest{
+ Origin: "http://example.com",
+ Method: "OPTIONS",
+ IsPreflightRequest: true,
+ AccessControlRequestMethod: "PUT",
+ AccessControlRequestHeaders: []string{"Content-Type", "Authorization"},
+ },
+ },
+ {
+ name: "request without origin",
+ req: &http.Request{
+ Method: "GET",
+ Header: http.Header{},
+ },
+ want: &CORSRequest{
+ Origin: "",
+ Method: "GET",
+ IsPreflightRequest: false,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ParseRequest(tt.req)
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("ParseRequest() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestMatchesOrigin(t *testing.T) {
+ tests := []struct {
+ name string
+ allowedOrigins []string
+ origin string
+ want bool
+ }{
+ {
+ name: "wildcard match",
+ allowedOrigins: []string{"*"},
+ origin: "http://example.com",
+ want: true,
+ },
+ {
+ name: "exact match",
+ allowedOrigins: []string{"http://example.com"},
+ origin: "http://example.com",
+ want: true,
+ },
+ {
+ name: "no match",
+ allowedOrigins: []string{"http://example.com"},
+ origin: "http://other.com",
+ want: false,
+ },
+ {
+ name: "wildcard subdomain match",
+ allowedOrigins: []string{"http://*.example.com"},
+ origin: "http://api.example.com",
+ want: true,
+ },
+ {
+ name: "wildcard subdomain no match",
+ allowedOrigins: []string{"http://*.example.com"},
+ origin: "http://example.com",
+ want: false,
+ },
+ {
+ name: "multiple origins with match",
+ allowedOrigins: []string{"http://example.com", "http://other.com"},
+ origin: "http://other.com",
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := matchesOrigin(tt.allowedOrigins, tt.origin)
+ if got != tt.want {
+ t.Errorf("matchesOrigin() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestMatchesHeader(t *testing.T) {
+ tests := []struct {
+ name string
+ allowedHeaders []string
+ header string
+ want bool
+ }{
+ {
+ name: "empty allowed headers",
+ allowedHeaders: []string{},
+ header: "Content-Type",
+ want: true,
+ },
+ {
+ name: "wildcard match",
+ allowedHeaders: []string{"*"},
+ header: "Content-Type",
+ want: true,
+ },
+ {
+ name: "exact match",
+ allowedHeaders: []string{"Content-Type"},
+ header: "Content-Type",
+ want: true,
+ },
+ {
+ name: "case insensitive match",
+ allowedHeaders: []string{"content-type"},
+ header: "Content-Type",
+ want: true,
+ },
+ {
+ name: "no match",
+ allowedHeaders: []string{"Authorization"},
+ header: "Content-Type",
+ want: false,
+ },
+ {
+ name: "wildcard prefix match",
+ allowedHeaders: []string{"x-amz-*"},
+ header: "x-amz-date",
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := matchesHeader(tt.allowedHeaders, tt.header)
+ if got != tt.want {
+ t.Errorf("matchesHeader() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestEvaluateRequest(t *testing.T) {
+ config := &CORSConfiguration{
+ CORSRules: []CORSRule{
+ {
+ AllowedMethods: []string{"GET", "POST"},
+ AllowedOrigins: []string{"http://example.com"},
+ AllowedHeaders: []string{"Content-Type"},
+ ExposeHeaders: []string{"ETag"},
+ MaxAgeSeconds: intPtr(3600),
+ },
+ {
+ AllowedMethods: []string{"PUT"},
+ AllowedOrigins: []string{"*"},
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ config *CORSConfiguration
+ corsReq *CORSRequest
+ want *CORSResponse
+ wantErr bool
+ }{
+ {
+ name: "matching first rule",
+ config: config,
+ corsReq: &CORSRequest{
+ Origin: "http://example.com",
+ Method: "GET",
+ },
+ want: &CORSResponse{
+ AllowOrigin: "http://example.com",
+ AllowMethods: "GET, POST",
+ AllowHeaders: "Content-Type",
+ ExposeHeaders: "ETag",
+ MaxAge: "3600",
+ },
+ wantErr: false,
+ },
+ {
+ name: "matching second rule",
+ config: config,
+ corsReq: &CORSRequest{
+ Origin: "http://other.com",
+ Method: "PUT",
+ },
+ want: &CORSResponse{
+ AllowOrigin: "http://other.com",
+ AllowMethods: "PUT",
+ },
+ wantErr: false,
+ },
+ {
+ name: "no matching rule",
+ config: config,
+ corsReq: &CORSRequest{
+ Origin: "http://forbidden.com",
+ Method: "GET",
+ },
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "preflight request",
+ config: config,
+ corsReq: &CORSRequest{
+ Origin: "http://example.com",
+ Method: "OPTIONS",
+ IsPreflightRequest: true,
+ AccessControlRequestMethod: "POST",
+ AccessControlRequestHeaders: []string{"Content-Type"},
+ },
+ want: &CORSResponse{
+ AllowOrigin: "http://example.com",
+ AllowMethods: "GET, POST",
+ AllowHeaders: "Content-Type",
+ ExposeHeaders: "ETag",
+ MaxAge: "3600",
+ },
+ wantErr: false,
+ },
+ {
+ name: "preflight request with forbidden header",
+ config: config,
+ corsReq: &CORSRequest{
+ Origin: "http://example.com",
+ Method: "OPTIONS",
+ IsPreflightRequest: true,
+ AccessControlRequestMethod: "POST",
+ AccessControlRequestHeaders: []string{"Authorization"},
+ },
+ want: &CORSResponse{
+ AllowOrigin: "http://example.com",
+ // No AllowMethods or AllowHeaders because the requested header is forbidden
+ },
+ wantErr: false,
+ },
+ {
+ name: "request without origin",
+ config: config,
+ corsReq: &CORSRequest{
+ Origin: "",
+ Method: "GET",
+ },
+ want: nil,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := EvaluateRequest(tt.config, tt.corsReq)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("EvaluateRequest() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("EvaluateRequest() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestApplyHeaders(t *testing.T) {
+ tests := []struct {
+ name string
+ corsResp *CORSResponse
+ want map[string]string
+ }{
+ {
+ name: "nil response",
+ corsResp: nil,
+ want: map[string]string{},
+ },
+ {
+ name: "complete response",
+ corsResp: &CORSResponse{
+ AllowOrigin: "http://example.com",
+ AllowMethods: "GET, POST",
+ AllowHeaders: "Content-Type",
+ ExposeHeaders: "ETag",
+ MaxAge: "3600",
+ },
+ 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",
+ },
+ },
+ {
+ name: "with credentials",
+ corsResp: &CORSResponse{
+ AllowOrigin: "http://example.com",
+ AllowMethods: "GET",
+ AllowCredentials: true,
+ },
+ want: map[string]string{
+ "Access-Control-Allow-Origin": "http://example.com",
+ "Access-Control-Allow-Methods": "GET",
+ "Access-Control-Allow-Credentials": "true",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create a proper response writer using httptest
+ w := httptest.NewRecorder()
+
+ ApplyHeaders(w, tt.corsResp)
+
+ // Extract headers from the response
+ headers := make(map[string]string)
+ for key, values := range w.Header() {
+ if len(values) > 0 {
+ headers[key] = values[0]
+ }
+ }
+
+ if !reflect.DeepEqual(headers, tt.want) {
+ t.Errorf("ApplyHeaders() headers = %v, want %v", headers, tt.want)
+ }
+ })
+ }
+}
+
+// Helper functions and types for testing
+
+func intPtr(i int) *int {
+ return &i
+}
diff --git a/weed/s3api/cors/middleware.go b/weed/s3api/cors/middleware.go
new file mode 100644
index 000000000..14ff32355
--- /dev/null
+++ b/weed/s3api/cors/middleware.go
@@ -0,0 +1,143 @@
+package cors
+
+import (
+ "net/http"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
+)
+
+// BucketChecker interface for checking bucket existence
+type BucketChecker interface {
+ CheckBucket(r *http.Request, bucket string) s3err.ErrorCode
+}
+
+// CORSConfigGetter interface for getting CORS configuration
+type CORSConfigGetter interface {
+ GetCORSConfiguration(bucket string) (*CORSConfiguration, s3err.ErrorCode)
+}
+
+// Middleware handles CORS evaluation for all S3 API requests
+type Middleware struct {
+ storage *Storage
+ bucketChecker BucketChecker
+ corsConfigGetter CORSConfigGetter
+}
+
+// NewMiddleware creates a new CORS middleware instance
+func NewMiddleware(storage *Storage, bucketChecker BucketChecker, corsConfigGetter CORSConfigGetter) *Middleware {
+ return &Middleware{
+ storage: storage,
+ bucketChecker: bucketChecker,
+ corsConfigGetter: corsConfigGetter,
+ }
+}
+
+// evaluateCORSRequest performs the common CORS request evaluation logic
+// Returns: (corsResponse, responseWritten, shouldContinue)
+// - corsResponse: the CORS response if evaluation succeeded
+// - responseWritten: true if an error response was already written
+// - shouldContinue: true if the request should continue to the next handler
+func (m *Middleware) evaluateCORSRequest(w http.ResponseWriter, r *http.Request) (*CORSResponse, bool, bool) {
+ // Parse CORS request
+ corsReq := ParseRequest(r)
+ if corsReq.Origin == "" {
+ // Not a CORS request
+ return nil, false, true
+ }
+
+ // Extract bucket from request
+ bucket, _ := s3_constants.GetBucketAndObject(r)
+ if bucket == "" {
+ return nil, false, true
+ }
+
+ // Check if bucket exists
+ if err := m.bucketChecker.CheckBucket(r, bucket); err != s3err.ErrNone {
+ // For non-existent buckets, let the normal handler deal with it
+ return nil, false, true
+ }
+
+ // Load CORS configuration from cache
+ config, errCode := m.corsConfigGetter.GetCORSConfiguration(bucket)
+ if errCode != s3err.ErrNone || config == nil {
+ // No CORS configuration, handle based on request type
+ if corsReq.IsPreflightRequest {
+ // Preflight request without CORS config should fail
+ s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
+ return nil, true, false // Response written, don't continue
+ }
+ // Non-preflight request, continue normally
+ return nil, false, true
+ }
+
+ // 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 nil, true, false // Response written, don't continue
+ }
+ // Non-preflight request, continue normally but without CORS headers
+ return nil, false, true
+ }
+
+ return corsResp, false, false
+}
+
+// Handler returns the CORS middleware handler
+func (m *Middleware) Handler(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Use the common evaluation logic
+ corsResp, responseWritten, shouldContinue := m.evaluateCORSRequest(w, r)
+ if responseWritten {
+ // Response was already written (error case)
+ return
+ }
+
+ if shouldContinue {
+ // Continue with normal request processing
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Parse request to check if it's a preflight request
+ corsReq := ParseRequest(r)
+
+ // Apply CORS headers to response
+ ApplyHeaders(w, corsResp)
+
+ // Handle preflight requests
+ if corsReq.IsPreflightRequest {
+ // Preflight request should return 200 OK with just CORS headers
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ // Continue with normal request processing
+ next.ServeHTTP(w, r)
+ })
+}
+
+// HandleOptionsRequest handles OPTIONS requests for CORS preflight
+func (m *Middleware) HandleOptionsRequest(w http.ResponseWriter, r *http.Request) {
+ // Use the common evaluation logic
+ corsResp, responseWritten, shouldContinue := m.evaluateCORSRequest(w, r)
+ if responseWritten {
+ // Response was already written (error case)
+ return
+ }
+
+ if shouldContinue || corsResp == nil {
+ // Not a CORS request or should continue normally
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ // Apply CORS headers and return success
+ ApplyHeaders(w, corsResp)
+ w.WriteHeader(http.StatusOK)
+}
diff --git a/weed/s3api/s3api_bucket_config.go b/weed/s3api/s3api_bucket_config.go
index 273eb6fbd..a157b93e8 100644
--- a/weed/s3api/s3api_bucket_config.go
+++ b/weed/s3api/s3api_bucket_config.go
@@ -1,12 +1,16 @@
package s3api
import (
+ "encoding/json"
"fmt"
+ "path/filepath"
+ "strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/cors"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
@@ -18,6 +22,7 @@ type BucketConfig struct {
Ownership string
ACL []byte
Owner string
+ CORS *cors.CORSConfiguration
LastModified time.Time
Entry *filer_pb.Entry
}
@@ -118,6 +123,19 @@ func (s3a *S3ApiServer) getBucketConfig(bucket string) (*BucketConfig, s3err.Err
}
}
+ // Load CORS configuration from .s3metadata
+ if corsConfig, err := s3a.loadCORSFromMetadata(bucket); err != nil {
+ if err == filer_pb.ErrNotFound {
+ // Missing metadata is not an error; fall back cleanly
+ glog.V(2).Infof("CORS metadata not found for bucket %s, falling back to default behavior", bucket)
+ } else {
+ // Log parsing or validation errors
+ glog.Errorf("Failed to load CORS configuration for bucket %s: %v", bucket, err)
+ }
+ } else {
+ config.CORS = corsConfig
+ }
+
// Cache the result
s3a.bucketConfigCache.Set(bucket, config)
@@ -244,3 +262,114 @@ func (s3a *S3ApiServer) removeBucketConfigKey(bucket, key string) s3err.ErrorCod
return nil
})
}
+
+// loadCORSFromMetadata loads CORS configuration from bucket metadata
+func (s3a *S3ApiServer) loadCORSFromMetadata(bucket string) (*cors.CORSConfiguration, error) {
+ // Validate bucket name to prevent path traversal attacks
+ if bucket == "" || strings.Contains(bucket, "/") || strings.Contains(bucket, "\\") ||
+ strings.Contains(bucket, "..") || strings.Contains(bucket, "~") {
+ return nil, fmt.Errorf("invalid bucket name: %s", bucket)
+ }
+
+ // Clean the bucket name further to prevent any potential path traversal
+ bucket = filepath.Clean(bucket)
+ if bucket == "." || bucket == ".." {
+ return nil, fmt.Errorf("invalid bucket name: %s", bucket)
+ }
+
+ bucketMetadataPath := filepath.Join(s3a.option.BucketsPath, bucket, cors.S3MetadataFileName)
+
+ entry, err := s3a.getEntry("", bucketMetadataPath)
+ if err != nil {
+ glog.V(3).Infof("loadCORSFromMetadata: error retrieving metadata for bucket %s: %v", bucket, err)
+ return nil, fmt.Errorf("error retrieving metadata for bucket %s: %v", bucket, err)
+ }
+ if entry == nil {
+ glog.V(3).Infof("loadCORSFromMetadata: no metadata entry found for bucket %s", bucket)
+ return nil, fmt.Errorf("no metadata entry found for bucket %s", bucket)
+ }
+
+ if len(entry.Content) == 0 {
+ glog.V(3).Infof("loadCORSFromMetadata: empty metadata content for bucket %s", bucket)
+ return nil, fmt.Errorf("no metadata content for bucket %s", bucket)
+ }
+
+ var metadata map[string]json.RawMessage
+ if err := json.Unmarshal(entry.Content, &metadata); err != nil {
+ glog.Errorf("loadCORSFromMetadata: failed to unmarshal metadata for bucket %s: %v", bucket, err)
+ return nil, fmt.Errorf("failed to unmarshal metadata: %v", err)
+ }
+
+ corsData, exists := metadata["cors"]
+ if !exists {
+ glog.V(3).Infof("loadCORSFromMetadata: no CORS configuration found for bucket %s", bucket)
+ return nil, fmt.Errorf("no CORS configuration found")
+ }
+
+ // Directly unmarshal the raw JSON to CORSConfiguration to avoid round-trip allocations
+ var config cors.CORSConfiguration
+ if err := json.Unmarshal(corsData, &config); err != nil {
+ glog.Errorf("loadCORSFromMetadata: failed to unmarshal CORS configuration for bucket %s: %v", bucket, err)
+ return nil, fmt.Errorf("failed to unmarshal CORS configuration: %v", err)
+ }
+
+ return &config, nil
+}
+
+// getCORSConfiguration retrieves CORS configuration with caching
+func (s3a *S3ApiServer) getCORSConfiguration(bucket string) (*cors.CORSConfiguration, s3err.ErrorCode) {
+ config, errCode := s3a.getBucketConfig(bucket)
+ if errCode != s3err.ErrNone {
+ return nil, errCode
+ }
+
+ return config.CORS, s3err.ErrNone
+}
+
+// getCORSStorage returns a CORS storage instance for persistent operations
+func (s3a *S3ApiServer) getCORSStorage() *cors.Storage {
+ entryGetter := &S3EntryGetter{server: s3a}
+ return cors.NewStorage(s3a, entryGetter, s3a.option.BucketsPath)
+}
+
+// updateCORSConfiguration updates CORS configuration and invalidates cache
+func (s3a *S3ApiServer) updateCORSConfiguration(bucket string, corsConfig *cors.CORSConfiguration) s3err.ErrorCode {
+ // Update in-memory cache
+ errCode := s3a.updateBucketConfig(bucket, func(config *BucketConfig) error {
+ config.CORS = corsConfig
+ return nil
+ })
+ if errCode != s3err.ErrNone {
+ return errCode
+ }
+
+ // Persist to .s3metadata file
+ storage := s3a.getCORSStorage()
+ if err := storage.Store(bucket, corsConfig); err != nil {
+ glog.Errorf("updateCORSConfiguration: failed to persist CORS config to metadata for bucket %s: %v", bucket, err)
+ return s3err.ErrInternalError
+ }
+
+ return s3err.ErrNone
+}
+
+// removeCORSConfiguration removes CORS configuration and invalidates cache
+func (s3a *S3ApiServer) removeCORSConfiguration(bucket string) s3err.ErrorCode {
+ // Remove from in-memory cache
+ errCode := s3a.updateBucketConfig(bucket, func(config *BucketConfig) error {
+ config.CORS = nil
+ return nil
+ })
+ if errCode != s3err.ErrNone {
+ return errCode
+ }
+
+ // Remove from .s3metadata file
+ storage := s3a.getCORSStorage()
+ if err := storage.Delete(bucket); err != nil {
+ glog.Errorf("removeCORSConfiguration: failed to remove CORS config from metadata for bucket %s: %v", bucket, err)
+ return s3err.ErrInternalError
+ }
+
+ return s3err.ErrNone
+}
diff --git a/weed/s3api/s3api_bucket_cors_handlers.go b/weed/s3api/s3api_bucket_cors_handlers.go
new file mode 100644
index 000000000..e46021d7e
--- /dev/null
+++ b/weed/s3api/s3api_bucket_cors_handlers.go
@@ -0,0 +1,140 @@
+package s3api
+
+import (
+ "encoding/xml"
+ "net/http"
+
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/cors"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
+)
+
+// S3EntryGetter implements cors.EntryGetter interface
+type S3EntryGetter struct {
+ server *S3ApiServer
+}
+
+func (g *S3EntryGetter) GetEntry(directory, name string) (*filer_pb.Entry, error) {
+ return g.server.getEntry(directory, name)
+}
+
+// S3BucketChecker implements cors.BucketChecker interface
+type S3BucketChecker struct {
+ server *S3ApiServer
+}
+
+func (c *S3BucketChecker) CheckBucket(r *http.Request, bucket string) s3err.ErrorCode {
+ return c.server.checkBucket(r, bucket)
+}
+
+// S3CORSConfigGetter implements cors.CORSConfigGetter interface
+type S3CORSConfigGetter struct {
+ server *S3ApiServer
+}
+
+func (g *S3CORSConfigGetter) GetCORSConfiguration(bucket string) (*cors.CORSConfiguration, s3err.ErrorCode) {
+ return g.server.getCORSConfiguration(bucket)
+}
+
+// getCORSMiddleware returns a CORS middleware instance with caching
+func (s3a *S3ApiServer) getCORSMiddleware() *cors.Middleware {
+ storage := s3a.getCORSStorage()
+ bucketChecker := &S3BucketChecker{server: s3a}
+ corsConfigGetter := &S3CORSConfigGetter{server: s3a}
+
+ return cors.NewMiddleware(storage, bucketChecker, corsConfigGetter)
+}
+
+// GetBucketCorsHandler handles Get bucket CORS configuration
+// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketCors.html
+func (s3a *S3ApiServer) GetBucketCorsHandler(w http.ResponseWriter, r *http.Request) {
+ bucket, _ := s3_constants.GetBucketAndObject(r)
+ glog.V(3).Infof("GetBucketCorsHandler %s", bucket)
+
+ if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
+ s3err.WriteErrorResponse(w, r, err)
+ return
+ }
+
+ // Load CORS configuration from cache
+ config, errCode := s3a.getCORSConfiguration(bucket)
+ if errCode != s3err.ErrNone {
+ if errCode == s3err.ErrNoSuchBucket {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
+ } else {
+ s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
+ }
+ return
+ }
+
+ if config == nil {
+ s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchCORSConfiguration)
+ return
+ }
+
+ // Return CORS configuration as XML
+ writeSuccessResponseXML(w, r, config)
+}
+
+// PutBucketCorsHandler handles Put bucket CORS configuration
+// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html
+func (s3a *S3ApiServer) PutBucketCorsHandler(w http.ResponseWriter, r *http.Request) {
+ bucket, _ := s3_constants.GetBucketAndObject(r)
+ glog.V(3).Infof("PutBucketCorsHandler %s", bucket)
+
+ if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
+ s3err.WriteErrorResponse(w, r, err)
+ return
+ }
+
+ // Parse CORS configuration from request body
+ var config cors.CORSConfiguration
+ if err := xml.NewDecoder(r.Body).Decode(&config); err != nil {
+ glog.V(1).Infof("Failed to parse CORS configuration: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
+ return
+ }
+
+ // Validate CORS configuration
+ if err := cors.ValidateConfiguration(&config); err != nil {
+ glog.V(1).Infof("Invalid CORS configuration: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
+ return
+ }
+
+ // Store CORS configuration and update cache
+ // This handles both cache update and persistent storage through the unified bucket config system
+ if err := s3a.updateCORSConfiguration(bucket, &config); err != s3err.ErrNone {
+ glog.Errorf("Failed to update CORS configuration: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
+ return
+ }
+
+ // Return success
+ writeSuccessResponseEmpty(w, r)
+}
+
+// DeleteBucketCorsHandler handles Delete bucket CORS configuration
+// https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketCors.html
+func (s3a *S3ApiServer) DeleteBucketCorsHandler(w http.ResponseWriter, r *http.Request) {
+ bucket, _ := s3_constants.GetBucketAndObject(r)
+ glog.V(3).Infof("DeleteBucketCorsHandler %s", bucket)
+
+ if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
+ s3err.WriteErrorResponse(w, r, err)
+ return
+ }
+
+ // Remove CORS configuration from cache and persistent storage
+ // This handles both cache invalidation and persistent storage cleanup through the unified bucket config system
+ if err := s3a.removeCORSConfiguration(bucket); err != s3err.ErrNone {
+ glog.Errorf("Failed to remove CORS configuration: %v", err)
+ s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
+ return
+ }
+
+ // Return success (204 No Content)
+ w.WriteHeader(http.StatusNoContent)
+}
diff --git a/weed/s3api/s3api_bucket_skip_handlers.go b/weed/s3api/s3api_bucket_skip_handlers.go
index 798725203..d51d92b4d 100644
--- a/weed/s3api/s3api_bucket_skip_handlers.go
+++ b/weed/s3api/s3api_bucket_skip_handlers.go
@@ -8,24 +8,6 @@ import (
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
-// GetBucketCorsHandler Get bucket CORS
-// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketCors.html
-func (s3a *S3ApiServer) GetBucketCorsHandler(w http.ResponseWriter, r *http.Request) {
- s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchCORSConfiguration)
-}
-
-// PutBucketCorsHandler Put bucket CORS
-// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html
-func (s3a *S3ApiServer) PutBucketCorsHandler(w http.ResponseWriter, r *http.Request) {
- s3err.WriteErrorResponse(w, r, s3err.ErrNotImplemented)
-}
-
-// DeleteBucketCorsHandler Delete bucket CORS
-// https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketCors.html
-func (s3a *S3ApiServer) DeleteBucketCorsHandler(w http.ResponseWriter, r *http.Request) {
- s3err.WriteErrorResponse(w, r, http.StatusNoContent)
-}
-
// GetBucketPolicyHandler Get bucket Policy
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketPolicy.html
func (s3a *S3ApiServer) GetBucketPolicyHandler(w http.ResponseWriter, r *http.Request) {
diff --git a/weed/s3api/s3api_object_handlers.go b/weed/s3api/s3api_object_handlers.go
index 5163a72c2..6b811a024 100644
--- a/weed/s3api/s3api_object_handlers.go
+++ b/weed/s3api/s3api_object_handlers.go
@@ -20,6 +20,17 @@ import (
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
+// corsHeaders defines the CORS headers that need to be preserved
+// Package-level constant to avoid repeated allocations
+var corsHeaders = []string{
+ "Access-Control-Allow-Origin",
+ "Access-Control-Allow-Methods",
+ "Access-Control-Allow-Headers",
+ "Access-Control-Expose-Headers",
+ "Access-Control-Max-Age",
+ "Access-Control-Allow-Credentials",
+}
+
func mimeDetect(r *http.Request, dataReader io.Reader) io.ReadCloser {
mimeBuffer := make([]byte, 512)
size, _ := dataReader.Read(mimeBuffer)
@@ -381,10 +392,34 @@ func setUserMetadataKeyToLowercase(resp *http.Response) {
}
}
+func captureCORSHeaders(w http.ResponseWriter, headersToCapture []string) map[string]string {
+ captured := make(map[string]string)
+ for _, corsHeader := range headersToCapture {
+ if value := w.Header().Get(corsHeader); value != "" {
+ captured[corsHeader] = value
+ }
+ }
+ return captured
+}
+
+func restoreCORSHeaders(w http.ResponseWriter, capturedCORSHeaders map[string]string) {
+ for corsHeader, value := range capturedCORSHeaders {
+ w.Header().Set(corsHeader, value)
+ }
+}
+
func passThroughResponse(proxyResponse *http.Response, w http.ResponseWriter) (statusCode int, bytesTransferred int64) {
+ // Capture existing CORS headers that may have been set by middleware
+ capturedCORSHeaders := captureCORSHeaders(w, corsHeaders)
+
+ // Copy headers from proxy response
for k, v := range proxyResponse.Header {
w.Header()[k] = v
}
+
+ // Restore CORS headers that were set by middleware
+ restoreCORSHeaders(w, capturedCORSHeaders)
+
if proxyResponse.Header.Get("Content-Range") != "" && proxyResponse.StatusCode == 200 {
w.WriteHeader(http.StatusPartialContent)
statusCode = http.StatusPartialContent
diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go
index 426535fe0..5d113c645 100644
--- a/weed/s3api/s3api_server.go
+++ b/weed/s3api/s3api_server.go
@@ -121,6 +121,35 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
return s3ApiServer, nil
}
+// handleCORSOriginValidation handles the common CORS origin validation logic
+func (s3a *S3ApiServer) handleCORSOriginValidation(w http.ResponseWriter, r *http.Request) bool {
+ origin := r.Header.Get("Origin")
+ if origin != "" {
+ if len(s3a.option.AllowedOrigins) == 0 || s3a.option.AllowedOrigins[0] == "*" {
+ origin = "*"
+ } else {
+ originFound := false
+ for _, allowedOrigin := range s3a.option.AllowedOrigins {
+ if origin == allowedOrigin {
+ originFound = true
+ break
+ }
+ }
+ if !originFound {
+ writeFailureResponse(w, r, http.StatusForbidden)
+ return false
+ }
+ }
+ }
+
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Access-Control-Expose-Headers", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "*")
+ w.Header().Set("Access-Control-Allow-Headers", "*")
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ return true
+}
+
func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
// API Router
apiRouter := router.PathPrefix("/").Subrouter()
@@ -129,33 +158,6 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
apiRouter.Methods(http.MethodGet).Path("/status").HandlerFunc(s3a.StatusHandler)
apiRouter.Methods(http.MethodGet).Path("/healthz").HandlerFunc(s3a.StatusHandler)
- apiRouter.Methods(http.MethodOptions).HandlerFunc(
- func(w http.ResponseWriter, r *http.Request) {
- origin := r.Header.Get("Origin")
- if origin != "" {
- if len(s3a.option.AllowedOrigins) == 0 || s3a.option.AllowedOrigins[0] == "*" {
- origin = "*"
- } else {
- originFound := false
- for _, allowedOrigin := range s3a.option.AllowedOrigins {
- if origin == allowedOrigin {
- originFound = true
- }
- }
- if !originFound {
- writeFailureResponse(w, r, http.StatusForbidden)
- return
- }
- }
- }
-
- w.Header().Set("Access-Control-Allow-Origin", origin)
- w.Header().Set("Access-Control-Expose-Headers", "*")
- w.Header().Set("Access-Control-Allow-Methods", "*")
- w.Header().Set("Access-Control-Allow-Headers", "*")
- writeSuccessResponseEmpty(w, r)
- })
-
var routers []*mux.Router
if s3a.option.DomainName != "" {
domainNames := strings.Split(s3a.option.DomainName, ",")
@@ -168,7 +170,16 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
}
routers = append(routers, apiRouter.PathPrefix("/{bucket}").Subrouter())
+ // Get CORS middleware instance with caching
+ corsMiddleware := s3a.getCORSMiddleware()
+
for _, bucket := range routers {
+ // Apply CORS middleware to bucket routers for automatic CORS header handling
+ bucket.Use(corsMiddleware.Handler)
+
+ // Bucket-specific OPTIONS handler for CORS preflight requests
+ // Use PathPrefix to catch all bucket-level preflight routes including /bucket/object
+ bucket.PathPrefix("/").Methods(http.MethodOptions).HandlerFunc(corsMiddleware.HandleOptionsRequest)
// each case should follow the next rule:
// - requesting object with query must precede any other methods
@@ -330,6 +341,25 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
}
+ // Global OPTIONS handler for service-level requests (non-bucket requests)
+ // This handles requests like OPTIONS /, OPTIONS /status, OPTIONS /healthz
+ // Place this after bucket handlers to avoid interfering with bucket CORS middleware
+ apiRouter.Methods(http.MethodOptions).PathPrefix("/").HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ // Only handle if this is not a bucket-specific request
+ vars := mux.Vars(r)
+ bucket := vars["bucket"]
+ if bucket != "" {
+ // This is a bucket-specific request, let bucket CORS middleware handle it
+ http.NotFound(w, r)
+ return
+ }
+
+ if s3a.handleCORSOriginValidation(w, r) {
+ writeSuccessResponseEmpty(w, r)
+ }
+ })
+
// ListBuckets
apiRouter.Methods(http.MethodGet).Path("/").HandlerFunc(track(s3a.ListBucketsHandler, "LIST"))
diff --git a/weed/s3api/s3err/error_handler.go b/weed/s3api/s3err/error_handler.go
index 910dab12a..81335c489 100644
--- a/weed/s3api/s3err/error_handler.go
+++ b/weed/s3api/s3err/error_handler.go
@@ -4,13 +4,14 @@ import (
"bytes"
"encoding/xml"
"fmt"
- "github.com/aws/aws-sdk-go/private/protocol/xml/xmlutil"
- "github.com/gorilla/mux"
- "github.com/seaweedfs/seaweedfs/weed/glog"
"net/http"
"strconv"
"strings"
"time"
+
+ "github.com/aws/aws-sdk-go/private/protocol/xml/xmlutil"
+ "github.com/gorilla/mux"
+ "github.com/seaweedfs/seaweedfs/weed/glog"
)
type mimeType string
@@ -76,10 +77,25 @@ func EncodeXMLResponse(response interface{}) []byte {
func setCommonHeaders(w http.ResponseWriter, r *http.Request) {
w.Header().Set("x-amz-request-id", fmt.Sprintf("%d", time.Now().UnixNano()))
w.Header().Set("Accept-Ranges", "bytes")
+
+ // Only set static CORS headers for service-level requests, not bucket-specific requests
if r.Header.Get("Origin") != "" {
- w.Header().Set("Access-Control-Allow-Origin", "*")
- w.Header().Set("Access-Control-Expose-Headers", "*")
- w.Header().Set("Access-Control-Allow-Credentials", "true")
+ // Use mux.Vars to detect bucket-specific requests more reliably
+ vars := mux.Vars(r)
+ bucket := vars["bucket"]
+ isBucketRequest := bucket != ""
+
+ // Only apply static CORS headers if this is NOT a bucket-specific request
+ // and no bucket-specific CORS headers were already set
+ if !isBucketRequest && w.Header().Get("Access-Control-Allow-Origin") == "" {
+ // This is a service-level request (like OPTIONS /), apply static CORS
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "*")
+ w.Header().Set("Access-Control-Allow-Headers", "*")
+ w.Header().Set("Access-Control-Expose-Headers", "*")
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ }
+ // For bucket-specific requests, let the CORS middleware handle the headers
}
}