Browse Source
adding cors support (#6987)
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>pull/6993/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 3756 additions and 53 deletions
-
119.github/workflows/s3-go-tests.yml
-
4.gitignore
-
337test/s3/cors/Makefile
-
362test/s3/cors/README.md
-
36test/s3/cors/go.mod
-
63test/s3/cors/go.sum
-
536test/s3/cors/s3_cors_http_test.go
-
600test/s3/cors/s3_cors_test.go
-
649weed/s3api/cors/cors.go
-
526weed/s3api/cors/cors_test.go
-
143weed/s3api/cors/middleware.go
-
129weed/s3api/s3api_bucket_config.go
-
140weed/s3api/s3api_bucket_cors_handlers.go
-
18weed/s3api/s3api_bucket_skip_handlers.go
-
35weed/s3api/s3api_object_handlers.go
-
84weed/s3api/s3api_server.go
-
28weed/s3api/s3err/error_handler.go
@ -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 |
@ -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. |
@ -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 |
||||
|
) |
@ -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= |
@ -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) |
||||
|
} |
||||
|
} |
@ -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) |
||||
|
} |
@ -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 |
||||
|
}) |
||||
|
} |
@ -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 |
||||
|
} |
@ -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) |
||||
|
} |
@ -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) |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue