mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2025-09-19 10:37:58 +08:00
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>
This commit is contained in:
@@ -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
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
337
test/s3/cors/Makefile
Normal file
337
test/s3/cors/Makefile
Normal file
@@ -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
|
362
test/s3/cors/README.md
Normal file
362
test/s3/cors/README.md
Normal file
@@ -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.
|
36
test/s3/cors/go.mod
Normal file
36
test/s3/cors/go.mod
Normal file
@@ -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
|
||||
)
|
63
test/s3/cors/go.sum
Normal file
63
test/s3/cors/go.sum
Normal file
@@ -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=
|
536
test/s3/cors/s3_cors_http_test.go
Normal file
536
test/s3/cors/s3_cors_http_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
600
test/s3/cors/s3_cors_test.go
Normal file
600
test/s3/cors/s3_cors_test.go
Normal file
@@ -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)
|
||||
}
|
649
weed/s3api/cors/cors.go
Normal file
649
weed/s3api/cors/cors.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
526
weed/s3api/cors/cors_test.go
Normal file
526
weed/s3api/cors/cors_test.go
Normal file
@@ -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
|
||||
}
|
143
weed/s3api/cors/middleware.go
Normal file
143
weed/s3api/cors/middleware.go
Normal file
@@ -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)
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
140
weed/s3api/s3api_bucket_cors_handlers.go
Normal file
140
weed/s3api/s3api_bucket_cors_handlers.go
Normal file
@@ -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)
|
||||
}
|
@@ -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) {
|
||||
|
@@ -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
|
||||
|
@@ -121,16 +121,8 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
return s3ApiServer, nil
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
// API Router
|
||||
apiRouter := router.PathPrefix("/").Subrouter()
|
||||
|
||||
// Readiness Probe
|
||||
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) {
|
||||
// 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] == "*" {
|
||||
@@ -140,11 +132,12 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
for _, allowedOrigin := range s3a.option.AllowedOrigins {
|
||||
if origin == allowedOrigin {
|
||||
originFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !originFound {
|
||||
writeFailureResponse(w, r, http.StatusForbidden)
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,8 +146,17 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
w.Header().Set("Access-Control-Expose-Headers", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
writeSuccessResponseEmpty(w, r)
|
||||
})
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
return true
|
||||
}
|
||||
|
||||
func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
|
||||
// API Router
|
||||
apiRouter := router.PathPrefix("/").Subrouter()
|
||||
|
||||
// Readiness Probe
|
||||
apiRouter.Methods(http.MethodGet).Path("/status").HandlerFunc(s3a.StatusHandler)
|
||||
apiRouter.Methods(http.MethodGet).Path("/healthz").HandlerFunc(s3a.StatusHandler)
|
||||
|
||||
var routers []*mux.Router
|
||||
if 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"))
|
||||
|
||||
|
@@ -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,11 +77,26 @@ 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") != "" {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
func WriteResponse(w http.ResponseWriter, r *http.Request, statusCode int, response []byte, mType mimeType) {
|
||||
|
Reference in New Issue
Block a user