From 4b040e8a8701199d4c680bb6f241c4751c8210a2 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 15 Jul 2025 00:23:54 -0700 Subject: [PATCH] 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> --- ...3-versioning-tests.yml => s3-go-tests.yml} | 119 +++- .gitignore | 4 + test/s3/cors/Makefile | 337 +++++++++ test/s3/cors/README.md | 362 ++++++++++ test/s3/cors/go.mod | 36 + test/s3/cors/go.sum | 63 ++ test/s3/cors/s3_cors_http_test.go | 536 +++++++++++++++ test/s3/cors/s3_cors_test.go | 600 ++++++++++++++++ weed/s3api/cors/cors.go | 649 ++++++++++++++++++ weed/s3api/cors/cors_test.go | 526 ++++++++++++++ weed/s3api/cors/middleware.go | 143 ++++ weed/s3api/s3api_bucket_config.go | 129 ++++ weed/s3api/s3api_bucket_cors_handlers.go | 140 ++++ weed/s3api/s3api_bucket_skip_handlers.go | 18 - weed/s3api/s3api_object_handlers.go | 35 + weed/s3api/s3api_server.go | 84 ++- weed/s3api/s3err/error_handler.go | 28 +- 17 files changed, 3756 insertions(+), 53 deletions(-) rename .github/workflows/{s3-versioning-tests.yml => s3-go-tests.yml} (71%) create mode 100644 test/s3/cors/Makefile create mode 100644 test/s3/cors/README.md create mode 100644 test/s3/cors/go.mod create mode 100644 test/s3/cors/go.sum create mode 100644 test/s3/cors/s3_cors_http_test.go create mode 100644 test/s3/cors/s3_cors_test.go create mode 100644 weed/s3api/cors/cors.go create mode 100644 weed/s3api/cors/cors_test.go create mode 100644 weed/s3api/cors/middleware.go create mode 100644 weed/s3api/s3api_bucket_cors_handlers.go diff --git a/.github/workflows/s3-versioning-tests.yml b/.github/workflows/s3-go-tests.yml similarity index 71% rename from .github/workflows/s3-versioning-tests.yml rename to .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 } }