diff --git a/.github/workflows/s3-proxy-signature-tests.yml b/.github/workflows/s3-proxy-signature-tests.yml new file mode 100644 index 000000000..5dd726da8 --- /dev/null +++ b/.github/workflows/s3-proxy-signature-tests.yml @@ -0,0 +1,122 @@ +name: "S3 Proxy Signature Tests" + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +concurrency: + group: ${{ github.head_ref || github.ref }}/s3-proxy-signature-tests + cancel-in-progress: true + +permissions: + contents: read + +jobs: + proxy-signature-tests: + name: S3 Proxy Signature Verification Tests + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v6 + + - name: Set up Go 1.x + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + id: go + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build SeaweedFS binary for Linux + run: | + set -x + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -buildvcs=false -v -o test/s3/proxy_signature/weed ./weed + + - name: Run S3 Proxy Signature Tests + timeout-minutes: 10 + working-directory: test/s3/proxy_signature + run: | + set -x + echo "Starting Docker Compose services..." + docker compose up -d --build + + # Check if containers are running + echo "Checking container status..." + docker compose ps + + # Wait for services to be ready + echo "Waiting for nginx proxy to be ready..." + PROXY_READY=0 + for i in $(seq 1 30); do + if curl -s http://localhost:9000/ > /dev/null 2>&1; then + echo "Proxy is ready" + PROXY_READY=1 + break + fi + echo "Waiting for proxy... ($i/30)" + sleep 1 + done + if [ $PROXY_READY -eq 0 ]; then + echo "ERROR: Proxy failed to become ready after 30 seconds" + echo "Docker compose logs:" + docker compose logs --no-color || true + exit 1 + fi + + # Wait for SeaweedFS to be ready + echo "Waiting for SeaweedFS S3 gateway to be ready via proxy..." + S3_READY=0 + for i in $(seq 1 30); do + # Check logs first for startup message (weed mini says "S3 service is ready") + if docker compose logs seaweedfs 2>&1 | grep -qE "S3 (gateway|service).*(started|ready)"; then + echo "SeaweedFS S3 gateway is ready" + S3_READY=1 + break + fi + # Fallback: check headers via proxy (which is already ready) + if curl -s -I http://localhost:9000/ | grep -qi "SeaweedFS"; then + echo "SeaweedFS S3 gateway is responding via proxy" + S3_READY=1 + break + fi + echo "Waiting for S3 gateway... ($i/30)" + sleep 1 + done + if [ $S3_READY -eq 0 ]; then + echo "ERROR: SeaweedFS S3 gateway failed to become ready after 30 seconds" + echo "Latest seaweedfs logs:" + docker compose logs --no-color --tail 20 seaweedfs || true + exit 1 + fi + + # Run the test script inside AWS CLI container + echo "Running test script..." + docker run --rm --network host \ + --entrypoint bash \ + amazon/aws-cli:latest \ + -c "$(cat test.sh)" + + TEST_RESULT=$? + + # Cleanup + docker compose down + + exit $TEST_RESULT + + - name: Cleanup on failure + if: failure() + working-directory: test/s3/proxy_signature + run: | + echo "Cleaning up Docker containers..." + ls -al weed || true + ldd weed || true + echo "Docker compose logs:" + docker compose logs --no-color || true + echo "Container status before cleanup:" + docker ps -a + echo "Stopping services..." + docker compose down || true diff --git a/test/s3/proxy_signature/Dockerfile b/test/s3/proxy_signature/Dockerfile new file mode 100644 index 000000000..6ec1a2ede --- /dev/null +++ b/test/s3/proxy_signature/Dockerfile @@ -0,0 +1,12 @@ +FROM alpine:3.20 +RUN apk add --no-cache curl && \ + addgroup -S seaweed && \ + adduser -S seaweed -G seaweed +COPY weed /usr/bin/weed +RUN chmod +x /usr/bin/weed && \ + chown seaweed:seaweed /usr/bin/weed && \ + mkdir -p /etc/seaweedfs /data/filerldb2 && \ + chown -R seaweed:seaweed /etc/seaweedfs /data && \ + chmod 755 /data /etc/seaweedfs /data/filerldb2 +WORKDIR /data +USER seaweed diff --git a/test/s3/proxy_signature/README.md b/test/s3/proxy_signature/README.md new file mode 100644 index 000000000..7a8f5dd81 --- /dev/null +++ b/test/s3/proxy_signature/README.md @@ -0,0 +1,79 @@ +# S3 Proxy Signature Verification Test + +Integration test that verifies S3 signature verification works correctly when +SeaweedFS is deployed behind a reverse proxy (nginx). + +## What it tests + +- S3 operations (create bucket, put/get/head/list/delete) through an nginx + reverse proxy with `X-Forwarded-Host`, `X-Forwarded-Port`, and + `X-Forwarded-Proto` headers +- SeaweedFS configured with `-s3.externalUrl=http://localhost:9000` so the + signature verification uses the client-facing host instead of the internal + backend address + +## Architecture + +```text +AWS CLI (signs with Host: localhost:9000) + | + v +nginx (:9000) + | proxy_pass → seaweedfs:8333 + | Sets: X-Forwarded-Host: localhost + | X-Forwarded-Port: 9000 + | X-Forwarded-Proto: http + v +SeaweedFS S3 (:8333, -s3.externalUrl=http://localhost:9000) + | externalHost = "localhost:9000" (parsed at startup) + | extractHostHeader() returns "localhost:9000" + | Matches what AWS CLI signed with + v +Signature verification succeeds +``` + +**Note:** When `-s3.externalUrl` is configured, direct access to the backend +port (8333) will fail signature verification because the client signs with a +different Host header than what `externalUrl` specifies. This is expected — +all S3 traffic should go through the proxy. + +## Prerequisites + +- Docker and Docker Compose +- AWS CLI v2 (on host or via Docker, see below) + +## Running + +```bash +# Build the weed binary first (from repo root): +cd /path/to/seaweedfs +go build -o test/s3/proxy_signature/weed ./weed +cd test/s3/proxy_signature + +# Start services +docker compose up -d --build + +# Option A: Run test with aws CLI installed locally +./test.sh + +# Option B: Run test without aws CLI (uses Docker container) +docker run --rm --network host --entrypoint "" amazon/aws-cli:latest \ + bash < test.sh + +# Tear down +docker compose down + +# Clean up the weed binary +rm -f weed +``` + +## Troubleshooting + +If signature verification fails through the proxy, check: + +1. nginx is setting `X-Forwarded-Host` and `X-Forwarded-Port` correctly +2. SeaweedFS is started with `-s3.externalUrl` matching the client endpoint +3. The AWS CLI endpoint URL matches the proxy address + +You can also set the `S3_EXTERNAL_URL` environment variable instead of the +`-s3.externalUrl` flag. diff --git a/test/s3/proxy_signature/docker-compose.yml b/test/s3/proxy_signature/docker-compose.yml new file mode 100644 index 000000000..6474e662d --- /dev/null +++ b/test/s3/proxy_signature/docker-compose.yml @@ -0,0 +1,28 @@ +services: + seaweedfs: + build: + context: . + dockerfile: Dockerfile + command: > + /usr/bin/weed mini + -s3.config=/etc/seaweedfs/s3.json + -s3.externalUrl=http://localhost:9000 + -ip=seaweedfs + volumes: + - ./s3.json:/etc/seaweedfs/s3.json:ro + healthcheck: + test: ["CMD", "curl", "-sf", "http://seaweedfs:8333/status"] + interval: 3s + timeout: 2s + retries: 20 + start_period: 5s + + nginx: + image: nginx:alpine + ports: + - "9000:9000" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + seaweedfs: + condition: service_healthy diff --git a/test/s3/proxy_signature/nginx.conf b/test/s3/proxy_signature/nginx.conf new file mode 100644 index 000000000..45eff2796 --- /dev/null +++ b/test/s3/proxy_signature/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 9000; + server_name localhost; + + # Allow large uploads + client_max_body_size 64m; + + location / { + proxy_pass http://seaweedfs:8333; + + # Standard reverse proxy headers — this is what Kong, Traefik, etc. do + proxy_set_header Host $host:$server_port; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_http_version 1.1; + proxy_buffering off; + proxy_request_buffering off; + } +} diff --git a/test/s3/proxy_signature/s3.json b/test/s3/proxy_signature/s3.json new file mode 100644 index 000000000..07566de73 --- /dev/null +++ b/test/s3/proxy_signature/s3.json @@ -0,0 +1,20 @@ +{ + "identities": [ + { + "name": "test_admin", + "credentials": [ + { + "accessKey": "test_access_key", + "secretKey": "test_secret_key" + } + ], + "actions": [ + "Admin", + "Read", + "List", + "Tagging", + "Write" + ] + } + ] +} diff --git a/test/s3/proxy_signature/test.sh b/test/s3/proxy_signature/test.sh new file mode 100755 index 000000000..c2d303753 --- /dev/null +++ b/test/s3/proxy_signature/test.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# +# Integration test for S3 signature verification behind a reverse proxy. +# +# Usage: +# # With aws CLI installed locally: +# docker compose up -d --build && ./test.sh && docker compose down +# +# # Without aws CLI (runs test inside a container): +# docker compose up -d --build +# docker run --rm --network host --entrypoint "" amazon/aws-cli:latest \ +# bash < test.sh +# docker compose down +# +# This script tests S3 operations through an nginx reverse proxy to verify +# that signature verification works correctly when SeaweedFS is configured +# with -s3.externalUrl=http://localhost:9000. +# +set -euo pipefail + +PROXY_ENDPOINT="http://localhost:9000" +ACCESS_KEY="test_access_key" +SECRET_KEY="test_secret_key" +REGION="us-east-1" +BUCKET="test-proxy-sig-$$" + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +pass() { echo -e "${GREEN}PASS${NC}: $1"; } +fail() { echo -e "${RED}FAIL${NC}: $1"; exit 1; } + +# Helper: run aws s3api command against a given endpoint +s3() { + local endpoint="$1" + shift + aws s3api \ + --endpoint-url "$endpoint" \ + --region "$REGION" \ + --no-verify-ssl \ + "$@" 2>&1 +} + +export AWS_ACCESS_KEY_ID="$ACCESS_KEY" +export AWS_SECRET_ACCESS_KEY="$SECRET_KEY" + +echo "=== S3 Proxy Signature Verification Test ===" +echo "" +echo "Testing S3 access through nginx reverse proxy at $PROXY_ENDPOINT" +echo "SeaweedFS configured with -s3.externalUrl=http://localhost:9000" +echo "AWS CLI signs requests with Host: localhost:9000" +echo "" + +# Wait for proxy to be ready +echo "Waiting for nginx proxy to be ready..." +for i in $(seq 1 30); do + # Use aws CLI for health check if curl is missing + if command -v curl >/dev/null 2>&1; then + http_code=$(curl -s -o /dev/null -w "%{http_code}" "$PROXY_ENDPOINT/" 2>/dev/null || echo "000") + case $http_code in + 200|403|405) break ;; + esac + else + if aws s3api list-buckets --endpoint-url "$PROXY_ENDPOINT" --no-sign-request >/dev/null 2>&1; then + break + fi + fi + if [ "$i" -eq 30 ]; then + fail "Proxy did not become ready in time" + fi + echo "Waiting for proxy $i/30..." + sleep 1 +done +echo "Proxy is ready." +echo "" + +# --- Test 1: Bucket operations through proxy --- +echo "--- Test 1: Bucket operations through proxy ---" +s3 "$PROXY_ENDPOINT" create-bucket --bucket "$BUCKET" > /dev/null \ + && pass "create-bucket" \ + || fail "create-bucket — signature verification likely failed" + +s3 "$PROXY_ENDPOINT" list-buckets > /dev/null \ + && pass "list-buckets" \ + || fail "list-buckets" +echo "" + +# --- Test 2: Object CRUD through proxy --- +echo "--- Test 2: Object CRUD through proxy ---" +echo "hello-from-proxy" > /tmp/test-proxy-sig.txt + +s3 "$PROXY_ENDPOINT" put-object --bucket "$BUCKET" --key "test.txt" --body /tmp/test-proxy-sig.txt > /dev/null \ + && pass "put-object" \ + || fail "put-object" + +s3 "$PROXY_ENDPOINT" head-object --bucket "$BUCKET" --key "test.txt" > /dev/null \ + && pass "head-object" \ + || fail "head-object" + +s3 "$PROXY_ENDPOINT" list-objects-v2 --bucket "$BUCKET" > /dev/null \ + && pass "list-objects-v2" \ + || fail "list-objects-v2" + +s3 "$PROXY_ENDPOINT" get-object --bucket "$BUCKET" --key "test.txt" /tmp/test-proxy-sig-get.txt > /dev/null \ + && pass "get-object" \ + || fail "get-object" + +# Verify content round-trip +CONTENT=$(cat /tmp/test-proxy-sig-get.txt) +if [ "$CONTENT" = "hello-from-proxy" ]; then + pass "content integrity (round-trip)" +else + fail "content mismatch: got \"$CONTENT\", expected \"hello-from-proxy\"" +fi +echo "" + +# --- Test 3: Delete operations through proxy --- +echo "--- Test 3: Delete through proxy ---" +s3 "$PROXY_ENDPOINT" delete-object --bucket "$BUCKET" --key "test.txt" > /dev/null \ + && pass "delete-object" \ + || fail "delete-object" + +s3 "$PROXY_ENDPOINT" delete-bucket --bucket "$BUCKET" > /dev/null \ + && pass "delete-bucket" \ + || fail "delete-bucket" +echo "" + +# Cleanup temp files +rm -f /tmp/test-proxy-sig.txt /tmp/test-proxy-sig-get.txt + +echo "=== All tests passed ===" diff --git a/weed/command/filer.go b/weed/command/filer.go index dfd08a9eb..e76759907 100644 --- a/weed/command/filer.go +++ b/weed/command/filer.go @@ -145,6 +145,7 @@ func init() { filerS3Options.cipher = cmdFiler.Flag.Bool("s3.encryptVolumeData", false, "encrypt data on volume servers for S3 uploads") filerS3Options.iamReadOnly = cmdFiler.Flag.Bool("s3.iam.readOnly", true, "disable IAM write operations on this server") filerS3Options.portIceberg = cmdFiler.Flag.Int("s3.port.iceberg", 8181, "Iceberg REST Catalog server listen port (0 to disable)") + filerS3Options.externalUrl = cmdFiler.Flag.String("s3.externalUrl", "", "the external URL clients use to connect (e.g. https://api.example.com:9000). Used for S3 signature verification behind a reverse proxy. Falls back to S3_EXTERNAL_URL env var.") // start webdav on filer filerStartWebDav = cmdFiler.Flag.Bool("webdav", false, "whether to start webdav gateway") diff --git a/weed/command/mini.go b/weed/command/mini.go index 95cc2f0aa..0b832614a 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -248,6 +248,7 @@ func initMiniS3Flags() { miniS3Options.iamConfig = miniIamConfig miniS3Options.auditLogConfig = cmdMini.Flag.String("s3.auditLogConfig", "", "path to the audit log config file") miniS3Options.allowDeleteBucketNotEmpty = miniS3AllowDeleteBucketNotEmpty + miniS3Options.externalUrl = cmdMini.Flag.String("s3.externalUrl", "", "the external URL clients use to connect (e.g. https://api.example.com:9000). Used for S3 signature verification behind a reverse proxy. Falls back to S3_EXTERNAL_URL env var.") // In mini mode, S3 uses the shared debug server started at line 681, not its own separate debug server miniS3Options.debug = new(bool) // explicitly false miniS3Options.debugPort = cmdMini.Flag.Int("s3.debug.port", 6060, "http port for debugging (unused in mini mode)") diff --git a/weed/command/s3.go b/weed/command/s3.go index 115147b69..cf4900654 100644 --- a/weed/command/s3.go +++ b/weed/command/s3.go @@ -67,6 +67,7 @@ type S3Options struct { debug *bool debugPort *int cipher *bool + externalUrl *string } func init() { @@ -101,6 +102,7 @@ func init() { s3StandaloneOptions.debug = cmdS3.Flag.Bool("debug", false, "serves runtime profiling data via pprof on the port specified by -debug.port") s3StandaloneOptions.debugPort = cmdS3.Flag.Int("debug.port", 6060, "http port for debugging") s3StandaloneOptions.cipher = cmdS3.Flag.Bool("encryptVolumeData", false, "encrypt data on volume servers") + s3StandaloneOptions.externalUrl = cmdS3.Flag.String("externalUrl", "", "the external URL clients use to connect (e.g. https://api.example.com:9000). Used for S3 signature verification behind a reverse proxy. Falls back to S3_EXTERNAL_URL env var.") } var cmdS3 = &Command{ @@ -222,6 +224,14 @@ func (s3opt *S3Options) GetCertificateWithUpdate(*tls.ClientHelloInfo) (*tls.Cer return &certs.Certs[0], err } +// resolveExternalUrl returns the external URL from the flag or falls back to the S3_EXTERNAL_URL env var. +func (s3opt *S3Options) resolveExternalUrl() string { + if s3opt.externalUrl != nil && *s3opt.externalUrl != "" { + return *s3opt.externalUrl + } + return os.Getenv("S3_EXTERNAL_URL") +} + func (s3opt *S3Options) startS3Server() bool { filerAddresses := pb.ServerAddresses(*s3opt.filer).ToAddresses() @@ -309,6 +319,7 @@ func (s3opt *S3Options) startS3Server() bool { Cipher: *s3opt.cipher, // encrypt data on volume servers BindIp: *s3opt.bindIp, GrpcPort: *s3opt.portGrpc, + ExternalUrl: s3opt.resolveExternalUrl(), }) if s3ApiServer_err != nil { glog.Fatalf("S3 API Server startup error: %v", s3ApiServer_err) diff --git a/weed/command/server.go b/weed/command/server.go index 5d480070b..e5b2d3280 100644 --- a/weed/command/server.go +++ b/weed/command/server.go @@ -178,6 +178,7 @@ func init() { s3Options.enableIam = cmdServer.Flag.Bool("s3.iam", true, "enable embedded IAM API on the same S3 port") s3Options.iamReadOnly = cmdServer.Flag.Bool("s3.iam.readOnly", true, "disable IAM write operations on this server") s3Options.cipher = cmdServer.Flag.Bool("s3.encryptVolumeData", false, "encrypt data on volume servers for S3 uploads") + s3Options.externalUrl = cmdServer.Flag.String("s3.externalUrl", "", "the external URL clients use to connect (e.g. https://api.example.com:9000). Used for S3 signature verification behind a reverse proxy. Falls back to S3_EXTERNAL_URL env var.") sftpOptions.port = cmdServer.Flag.Int("sftp.port", 2022, "SFTP server listen port") sftpOptions.sshPrivateKey = cmdServer.Flag.String("sftp.sshPrivateKey", "", "path to the SSH private key file for host authentication") diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 3e57ac68a..0d8dda899 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -5,7 +5,9 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" + "net/url" "os" "slices" "strings" @@ -53,6 +55,7 @@ type IdentityAccessManagement struct { identityAnonymous *Identity hashMu sync.RWMutex domain string + externalHost string // pre-computed host for S3 signature verification (from ExternalUrl) isAuthEnabled bool credentialManager *credential.CredentialManager filerClient *wdclient.FilerClient @@ -154,13 +157,56 @@ func (iam *IdentityAccessManagement) SetFilerClient(filerClient *wdclient.FilerC } } +// parseExternalUrlToHost parses an external URL and returns the host string +// to use for S3 signature verification. It applies the same default port +// stripping rules as the AWS SDK: port 80 is stripped for HTTP, port 443 +// is stripped for HTTPS, all other ports are preserved. +// Returns empty string for empty input. +func parseExternalUrlToHost(externalUrl string) (string, error) { + if externalUrl == "" { + return "", nil + } + u, err := url.Parse(externalUrl) + if err != nil { + return "", fmt.Errorf("invalid external URL: parse failed") + } + if u.Host == "" { + return "", fmt.Errorf("invalid external URL: missing host") + } + host, port, err := net.SplitHostPort(u.Host) + if err != nil { + // No port in the URL. For IPv6, strip brackets to match AWS SDK. + if strings.Contains(u.Host, ":") { + return strings.Trim(u.Host, "[]"), nil + } + return u.Host, nil + } + // Strip default ports to match AWS SDK SanitizeHostForHeader behavior + if (port == "80" && strings.EqualFold(u.Scheme, "http")) || + (port == "443" && strings.EqualFold(u.Scheme, "https")) { + return host, nil + } + return net.JoinHostPort(host, port), nil +} + func NewIdentityAccessManagement(option *S3ApiServerOption, filerClient *wdclient.FilerClient) *IdentityAccessManagement { return NewIdentityAccessManagementWithStore(option, filerClient, "") } func NewIdentityAccessManagementWithStore(option *S3ApiServerOption, filerClient *wdclient.FilerClient, explicitStore string) *IdentityAccessManagement { + var externalHost string + if option.ExternalUrl != "" { + var err error + externalHost, err = parseExternalUrlToHost(option.ExternalUrl) + if err != nil { + glog.Fatalf("failed to parse s3.externalUrl: %v", err) + } + glog.V(0).Infof("S3 signature verification will use external host: %q (from %q)", externalHost, option.ExternalUrl) + } + iam := &IdentityAccessManagement{ domain: option.DomainName, + externalHost: externalHost, hashes: make(map[string]*sync.Pool), hashCounters: make(map[string]*int32), filerClient: filerClient, diff --git a/weed/s3api/auth_credentials_test.go b/weed/s3api/auth_credentials_test.go index 80e22bb28..95611a7ff 100644 --- a/weed/s3api/auth_credentials_test.go +++ b/weed/s3api/auth_credentials_test.go @@ -805,3 +805,85 @@ func TestStaticIdentityProtection(t *testing.T) { iam.m.RUnlock() assert.False(t, ok, "Dynamic identity should have been removed") } + +func TestParseExternalUrlToHost(t *testing.T) { + tests := []struct { + name string + input string + expected string + expectErr bool + }{ + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "HTTPS with default port stripped", + input: "https://api.example.com:443", + expected: "api.example.com", + }, + { + name: "HTTP with default port stripped", + input: "http://api.example.com:80", + expected: "api.example.com", + }, + { + name: "HTTPS with non-standard port preserved", + input: "https://api.example.com:9000", + expected: "api.example.com:9000", + }, + { + name: "HTTP with non-standard port preserved", + input: "http://api.example.com:8080", + expected: "api.example.com:8080", + }, + { + name: "HTTPS without port", + input: "https://api.example.com", + expected: "api.example.com", + }, + { + name: "HTTP without port", + input: "http://api.example.com", + expected: "api.example.com", + }, + { + name: "IPv6 with non-standard port", + input: "https://[::1]:9000", + expected: "[::1]:9000", + }, + { + name: "IPv6 with default HTTPS port stripped", + input: "https://[::1]:443", + expected: "::1", + }, + { + name: "IPv6 without port", + input: "https://[::1]", + expected: "::1", + }, + { + name: "invalid URL", + input: "://not-a-url", + expectErr: true, + }, + { + name: "missing host", + input: "https://", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseExternalUrlToHost(tt.input) + if tt.expectErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/weed/s3api/auth_proxy_integration_test.go b/weed/s3api/auth_proxy_integration_test.go new file mode 100644 index 000000000..3310cbfc1 --- /dev/null +++ b/weed/s3api/auth_proxy_integration_test.go @@ -0,0 +1,212 @@ +package s3api + +import ( + "context" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "os" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReverseProxySignatureVerification is an integration test that exercises +// the full HTTP stack: real AWS SDK v4 signer -> real httputil.ReverseProxy -> +// real net/http server running IAM signature verification. +// +// This catches issues that unit tests miss: header normalization by net/http, +// proxy header injection, and real-world Host header handling. +func TestReverseProxySignatureVerification(t *testing.T) { + const ( + accessKey = "AKIAIOSFODNN7EXAMPLE" + secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + ) + + configJSON := `{ + "identities": [ + { + "name": "test_user", + "credentials": [ + { + "accessKey": "` + accessKey + `", + "secretKey": "` + secretKey + `" + } + ], + "actions": ["Admin", "Read", "Write", "List", "Tagging"] + } + ] +}` + + tests := []struct { + name string + externalUrl string // s3.externalUrl config for the backend + clientScheme string // scheme the client uses for signing + clientHost string // host the client signs against + proxyForwardsHost bool // whether proxy sets X-Forwarded-Host + expectSuccess bool + }{ + { + name: "non-standard port, externalUrl matches proxy address", + externalUrl: "", // filled dynamically with proxy address + clientScheme: "http", + clientHost: "", // filled dynamically + proxyForwardsHost: true, + expectSuccess: true, + }, + { + name: "externalUrl with non-standard port, client signs against external host", + externalUrl: "http://api.example.com:9000", + clientScheme: "http", + clientHost: "api.example.com:9000", + proxyForwardsHost: true, + expectSuccess: true, + }, + { + name: "externalUrl with HTTPS default port stripped, client signs without port", + externalUrl: "https://api.example.com:443", + clientScheme: "https", + clientHost: "api.example.com", + proxyForwardsHost: true, + expectSuccess: true, + }, + { + name: "externalUrl with HTTP default port stripped, client signs without port", + externalUrl: "http://api.example.com:80", + clientScheme: "http", + clientHost: "api.example.com", + proxyForwardsHost: true, + expectSuccess: true, + }, + { + name: "proxy forwards X-Forwarded-Host correctly, no externalUrl needed", + externalUrl: "", + clientScheme: "http", + clientHost: "api.example.com:9000", + proxyForwardsHost: true, + expectSuccess: true, + }, + { + name: "proxy without X-Forwarded-Host, no externalUrl: host mismatch", + externalUrl: "", + clientScheme: "http", + clientHost: "api.example.com:9000", + proxyForwardsHost: false, + expectSuccess: false, + }, + { + name: "proxy without X-Forwarded-Host, externalUrl saves the day", + externalUrl: "http://api.example.com:9000", + clientScheme: "http", + clientHost: "api.example.com:9000", + proxyForwardsHost: false, + expectSuccess: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // --- Write config to temp file --- + tmpFile := t.TempDir() + "/s3.json" + require.NoError(t, os.WriteFile(tmpFile, []byte(configJSON), 0644)) + + // --- Set up backend --- + var iam *IdentityAccessManagement + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, errCode := iam.authRequest(r, "Read") + if errCode != 0 { + w.WriteHeader(http.StatusForbidden) + fmt.Fprintf(w, "error: %d", int(errCode)) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "OK") + })) + defer backend.Close() + + // --- Set up reverse proxy --- + backendURL, _ := url.Parse(backend.URL) + proxy := httputil.NewSingleHostReverseProxy(backendURL) + + forwardsHost := tt.proxyForwardsHost + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalHost := req.Host + originalScheme := req.URL.Scheme + if originalScheme == "" { + originalScheme = "http" + } + originalDirector(req) + // Simulate real proxy behavior: rewrite Host to backend address + // (nginx proxy_pass and Kong both do this by default) + req.Host = backendURL.Host + if forwardsHost { + req.Header.Set("X-Forwarded-Host", originalHost) + req.Header.Set("X-Forwarded-Proto", originalScheme) + } + } + + proxyServer := httptest.NewServer(proxy) + defer proxyServer.Close() + + // --- Configure IAM --- + externalUrl := tt.externalUrl + clientHost := tt.clientHost + clientScheme := tt.clientScheme + if externalUrl == "" && clientHost == "" { + // Dynamic: use the proxy's actual address + proxyURL, _ := url.Parse(proxyServer.URL) + externalUrl = proxyServer.URL + clientHost = proxyURL.Host + clientScheme = proxyURL.Scheme + } + + option := &S3ApiServerOption{ + Config: tmpFile, + ExternalUrl: externalUrl, + } + iam = NewIdentityAccessManagementWithStore(option, nil, "memory") + require.True(t, iam.isEnabled()) + + // --- Sign the request using real AWS SDK v4 signer --- + clientURL := fmt.Sprintf("%s://%s/test-bucket/test-object", clientScheme, clientHost) + req, err := http.NewRequest(http.MethodGet, clientURL, nil) + require.NoError(t, err) + req.Host = clientHost + + signer := v4.NewSigner() + payloadHash := fmt.Sprintf("%x", sha256.Sum256([]byte{})) + err = signer.SignHTTP( + context.Background(), + aws.Credentials{AccessKeyID: accessKey, SecretAccessKey: secretKey}, + req, payloadHash, "s3", "us-east-1", time.Now(), + ) + require.NoError(t, err) + + // --- Send the signed request through the proxy --- + // Rewrite destination to the proxy, but keep signed headers and Host intact + proxyURL, _ := url.Parse(proxyServer.URL) + req.URL.Scheme = proxyURL.Scheme + req.URL.Host = proxyURL.Host + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + if tt.expectSuccess { + assert.Equal(t, http.StatusOK, resp.StatusCode, + "Expected signature verification to succeed through reverse proxy") + } else { + assert.Equal(t, http.StatusForbidden, resp.StatusCode, + "Expected signature verification to fail (host mismatch)") + } + }) + } +} diff --git a/weed/s3api/auth_security_test.go b/weed/s3api/auth_security_test.go index 906e843a9..f088fd2c1 100644 --- a/weed/s3api/auth_security_test.go +++ b/weed/s3api/auth_security_test.go @@ -179,3 +179,298 @@ func TestReproIssue7912(t *testing.T) { assert.Nil(t, identity) }) } + +// TestExternalUrlSignatureVerification tests that S3 signature verification works +// correctly when s3.externalUrl is configured. It uses the real AWS SDK v2 signer +// to prove correctness against actual S3 clients behind a reverse proxy. +func TestExternalUrlSignatureVerification(t *testing.T) { + configContent := `{ + "identities": [ + { + "name": "test_user", + "credentials": [ + { + "accessKey": "AKIAIOSFODNN7EXAMPLE", + "secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + ], + "actions": ["Admin", "Read", "Write", "List", "Tagging"] + } + ] +}` + tmpFile, err := os.CreateTemp("", "s3-config-*.json") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + _, err = tmpFile.Write([]byte(configContent)) + require.NoError(t, err) + tmpFile.Close() + + accessKey := "AKIAIOSFODNN7EXAMPLE" + secretKey := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + + tests := []struct { + name string + clientUrl string // URL the client signs against + backendHost string // Host header SeaweedFS sees (from proxy) + externalUrl string // s3.externalUrl config + expectSuccess bool + }{ + { + name: "non-standard port with externalUrl", + clientUrl: "https://api.example.com:9000/test-bucket/object", + backendHost: "backend:8333", + externalUrl: "https://api.example.com:9000", + expectSuccess: true, + }, + { + name: "HTTPS default port 443 with externalUrl (port stripped by SDK and parseExternalUrlToHost)", + clientUrl: "https://api.example.com/test-bucket/object", + backendHost: "backend:8333", + externalUrl: "https://api.example.com:443", + expectSuccess: true, + }, + { + name: "HTTPS without explicit port with externalUrl", + clientUrl: "https://api.example.com/test-bucket/object", + backendHost: "backend:8333", + externalUrl: "https://api.example.com", + expectSuccess: true, + }, + { + name: "HTTP default port 80 with externalUrl", + clientUrl: "http://api.example.com/test-bucket/object", + backendHost: "backend:8333", + externalUrl: "http://api.example.com:80", + expectSuccess: true, + }, + { + name: "without externalUrl, internal host causes mismatch", + clientUrl: "https://api.example.com:9000/test-bucket/object", + backendHost: "backend:8333", + externalUrl: "", + expectSuccess: false, + }, + { + name: "without externalUrl, matching host works", + clientUrl: "http://localhost:8333/test-bucket/object", + backendHost: "localhost:8333", + externalUrl: "", + expectSuccess: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create IAM with the externalUrl + option := &S3ApiServerOption{ + Config: tmpFile.Name(), + ExternalUrl: tt.externalUrl, + } + iam := NewIdentityAccessManagementWithStore(option, nil, "memory") + require.True(t, iam.isEnabled()) + + // Step 1: Sign a request targeting the client-facing URL using real AWS SDK signer + signReq, err := http.NewRequest(http.MethodGet, tt.clientUrl, nil) + require.NoError(t, err) + signReq.Host = signReq.URL.Host + + err = signRawHTTPRequest(context.Background(), signReq, accessKey, secretKey, "us-east-1") + require.NoError(t, err) + + // Step 2: Create a separate request as the proxy would deliver it + proxyReq := httptest.NewRequest(http.MethodGet, tt.clientUrl, nil) + proxyReq.Host = tt.backendHost + proxyReq.URL.Host = tt.backendHost + + // Copy the auth headers from the signed request + proxyReq.Header.Set("Authorization", signReq.Header.Get("Authorization")) + proxyReq.Header.Set("X-Amz-Date", signReq.Header.Get("X-Amz-Date")) + proxyReq.Header.Set("X-Amz-Content-Sha256", signReq.Header.Get("X-Amz-Content-Sha256")) + + // Step 3: Verify + identity, errCode := iam.authRequest(proxyReq, s3_constants.ACTION_LIST) + if tt.expectSuccess { + assert.Equal(t, s3err.ErrNone, errCode, "Expected successful signature verification") + require.NotNil(t, identity) + assert.Equal(t, "test_user", identity.Name) + } else { + assert.NotEqual(t, s3err.ErrNone, errCode, "Expected signature verification to fail") + } + }) + } +} + +// TestRealSDKSignerWithForwardedHeaders proves that extractHostHeader produces +// values that match the real AWS SDK v2 signer's SanitizeHostForHeader behavior. +// +// Unlike the self-referential tests in auto_signature_v4_test.go (which sign and +// verify with the same extractHostHeader function), this test uses the real AWS +// SDK v2 signer to sign the request. The SDK has its own host sanitization: +// - strips :80 for http +// - strips :443 for https +// - preserves all other ports +// +// If extractHostHeader disagrees with the SDK, the signature will not match. +func TestRealSDKSignerWithForwardedHeaders(t *testing.T) { + configContent := `{ + "identities": [ + { + "name": "test_user", + "credentials": [ + { + "accessKey": "AKIAIOSFODNN7EXAMPLE", + "secretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + } + ], + "actions": ["Admin", "Read", "Write", "List", "Tagging"] + } + ] +}` + tmpFile, err := os.CreateTemp("", "s3-config-*.json") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + _, err = tmpFile.Write([]byte(configContent)) + require.NoError(t, err) + tmpFile.Close() + + accessKey := "AKIAIOSFODNN7EXAMPLE" + secretKey := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + + // Each test case simulates: + // 1. A client connecting to clientUrl (SDK signs with the host from this URL) + // 2. A proxy forwarding to SeaweedFS with X-Forwarded-* headers + // 3. SeaweedFS extracting the host and verifying the signature + tests := []struct { + name string + clientUrl string // URL the client/SDK signs against + backendHost string // Host header SeaweedFS receives (proxy rewrites this) + forwardedHost string // X-Forwarded-Host set by proxy + forwardedPort string // X-Forwarded-Port set by proxy + forwardedProto string // X-Forwarded-Proto set by proxy + expectedHost string // what extractHostHeader should return (must match SDK) + }{ + { + name: "HTTPS non-standard port", + clientUrl: "https://api.example.com:9000/bucket/key", + backendHost: "seaweedfs:8333", + forwardedHost: "api.example.com", + forwardedPort: "9000", + forwardedProto: "https", + expectedHost: "api.example.com:9000", + }, + { + name: "HTTPS standard port 443 (SDK strips, we must too)", + clientUrl: "https://api.example.com/bucket/key", + backendHost: "seaweedfs:8333", + forwardedHost: "api.example.com", + forwardedPort: "443", + forwardedProto: "https", + expectedHost: "api.example.com", + }, + { + name: "HTTP standard port 80 (SDK strips, we must too)", + clientUrl: "http://api.example.com/bucket/key", + backendHost: "seaweedfs:8333", + forwardedHost: "api.example.com", + forwardedPort: "80", + forwardedProto: "http", + expectedHost: "api.example.com", + }, + { + name: "HTTP non-standard port 8080", + clientUrl: "http://api.example.com:8080/bucket/key", + backendHost: "seaweedfs:8333", + forwardedHost: "api.example.com", + forwardedPort: "8080", + forwardedProto: "http", + expectedHost: "api.example.com:8080", + }, + { + name: "X-Forwarded-Host with port (Traefik style), HTTPS 443", + clientUrl: "https://api.example.com/bucket/key", + backendHost: "seaweedfs:8333", + forwardedHost: "api.example.com:443", + forwardedPort: "443", + forwardedProto: "https", + expectedHost: "api.example.com", + }, + { + name: "X-Forwarded-Host with port (Traefik style), HTTP 80", + clientUrl: "http://api.example.com/bucket/key", + backendHost: "seaweedfs:8333", + forwardedHost: "api.example.com:80", + forwardedPort: "80", + forwardedProto: "http", + expectedHost: "api.example.com", + }, + { + name: "no forwarded headers, direct access", + clientUrl: "http://localhost:8333/bucket/key", + backendHost: "localhost:8333", + forwardedHost: "", + forwardedPort: "", + forwardedProto: "", + expectedHost: "localhost:8333", + }, + { + name: "empty proto with port 80 (defaults to http, strip)", + clientUrl: "http://api.example.com/bucket/key", + backendHost: "seaweedfs:8333", + forwardedHost: "api.example.com", + forwardedPort: "80", + forwardedProto: "", + expectedHost: "api.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + option := &S3ApiServerOption{Config: tmpFile.Name()} + iam := NewIdentityAccessManagementWithStore(option, nil, "memory") + require.True(t, iam.isEnabled()) + + // Step 1: Sign a request using the real AWS SDK v2 signer. + // The SDK applies its own SanitizeHostForHeader logic. + signReq, err := http.NewRequest(http.MethodGet, tt.clientUrl, nil) + require.NoError(t, err) + signReq.Host = signReq.URL.Host + + err = signRawHTTPRequest(context.Background(), signReq, accessKey, secretKey, "us-east-1") + require.NoError(t, err) + + // Step 2: Build the request as the proxy would deliver it to SeaweedFS. + proxyReq := httptest.NewRequest(http.MethodGet, tt.clientUrl, nil) + proxyReq.Host = tt.backendHost + + // Copy signed auth headers + proxyReq.Header.Set("Authorization", signReq.Header.Get("Authorization")) + proxyReq.Header.Set("X-Amz-Date", signReq.Header.Get("X-Amz-Date")) + proxyReq.Header.Set("X-Amz-Content-Sha256", signReq.Header.Get("X-Amz-Content-Sha256")) + + // Set forwarded headers + if tt.forwardedHost != "" { + proxyReq.Header.Set("X-Forwarded-Host", tt.forwardedHost) + } + if tt.forwardedPort != "" { + proxyReq.Header.Set("X-Forwarded-Port", tt.forwardedPort) + } + if tt.forwardedProto != "" { + proxyReq.Header.Set("X-Forwarded-Proto", tt.forwardedProto) + } + + // Step 3: Verify extractHostHeader returns the expected value. + // This is the critical correctness check: our extracted host must match + // what the SDK signed with, otherwise the signature will not match. + extractedHost := extractHostHeader(proxyReq, iam.externalHost) + assert.Equal(t, tt.expectedHost, extractedHost, + "extractHostHeader must return a value matching what AWS SDK signed with") + + // Step 4: Verify the full signature matches. + identity, errCode := iam.authRequest(proxyReq, s3_constants.ACTION_LIST) + assert.Equal(t, s3err.ErrNone, errCode, + "Signature from real AWS SDK signer must verify successfully") + require.NotNil(t, identity) + assert.Equal(t, "test_user", identity.Name) + }) + } +} diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go index 963cf12a0..716a3a757 100644 --- a/weed/s3api/auth_signature_v4.go +++ b/weed/s3api/auth_signature_v4.go @@ -284,7 +284,7 @@ func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCh } // 5. Extract headers that were part of the signature - extractedSignedHeaders, errCode := extractSignedHeaders(authInfo.SignedHeaders, r) + extractedSignedHeaders, errCode := extractSignedHeaders(authInfo.SignedHeaders, r, iam.externalHost) if errCode != s3err.ErrNone { return nil, nil, "", nil, errCode } @@ -760,7 +760,7 @@ func (iam *IdentityAccessManagement) doesPolicySignatureV4Match(formValues http. } // Verify if extracted signed headers are not properly signed. -func extractSignedHeaders(signedHeaders []string, r *http.Request) (http.Header, s3err.ErrorCode) { +func extractSignedHeaders(signedHeaders []string, r *http.Request, externalHost string) (http.Header, s3err.ErrorCode) { reqHeaders := r.Header // If no signed headers are provided, then return an error. if len(signedHeaders) == 0 { @@ -771,7 +771,7 @@ func extractSignedHeaders(signedHeaders []string, r *http.Request) (http.Header, // `host` is not a case-sensitive header, unlike other headers such as `x-amz-date`. if strings.ToLower(header) == "host" { // Get host value. - hostHeaderValue := extractHostHeader(r) + hostHeaderValue := extractHostHeader(r, externalHost) extractedSignedHeaders[header] = []string{hostHeaderValue} continue } @@ -784,17 +784,30 @@ func extractSignedHeaders(signedHeaders []string, r *http.Request) (http.Header, return extractedSignedHeaders, s3err.ErrNone } -// extractHostHeader returns the value of host header if available. -func extractHostHeader(r *http.Request) string { +// extractHostHeader returns the value of host header to use for signature verification. +// When externalHost is set (from s3.externalUrl), it is returned directly. +// Otherwise, the host is reconstructed from X-Forwarded-* headers or the request Host, +// with default port stripping to match AWS SDK SanitizeHostForHeader behavior. +func extractHostHeader(r *http.Request, externalHost string) string { + if externalHost != "" { + return externalHost + } + forwardedHost := r.Header.Get("X-Forwarded-Host") forwardedPort := r.Header.Get("X-Forwarded-Port") forwardedProto := r.Header.Get("X-Forwarded-Proto") - // Determine the effective scheme with correct order of precedence: - // 1. X-Forwarded-Proto (most authoritative, reflects client's original protocol) - // 2. r.TLS (authoritative for direct connection to server) - // 3. r.URL.Scheme (fallback, may not always be set correctly) - // 4. Default to "http" + // X-Forwarded-Proto and X-Forwarded-Port can be comma-separated lists when there are multiple proxies. + // Use only the first value (first-hop). + if comma := strings.Index(forwardedPort, ","); comma != -1 { + forwardedPort = strings.TrimSpace(forwardedPort[:comma]) + } + if comma := strings.Index(forwardedProto, ","); comma != -1 { + forwardedProto = strings.TrimSpace(forwardedProto[:comma]) + } + + // Determine effective scheme for default port stripping. + // Precedence: X-Forwarded-Proto > r.TLS > r.URL.Scheme > "http" scheme := "http" if r.URL.Scheme != "" { scheme = r.URL.Scheme @@ -815,51 +828,52 @@ func extractHostHeader(r *http.Request) string { } else { host = strings.TrimSpace(forwardedHost) } + // Baseline port from forwarded port if available + if forwardedPort != "" { + port = forwardedPort + } + // If the host itself contains a port, it should take precedence if h, p, err := net.SplitHostPort(host); err == nil { host = h port = p } - if forwardedPort != "" && isDefaultPort(scheme, port) { - port = forwardedPort - } } else { host = r.Host if host == "" { host = r.URL.Host } - if h, p, err := net.SplitHostPort(host); err == nil { + // Also apply X-Forwarded-Port in the fallback path + if forwardedPort != "" { + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + port = forwardedPort + } else if h, p, err := net.SplitHostPort(host); err == nil { host = h port = p } } - // If we have a non-default port, join it with the host. - // net.JoinHostPort will handle bracketing for IPv6. + // Strip default ports based on scheme to match AWS SDK SanitizeHostForHeader behavior. + // The AWS SDK strips port 80 for HTTP and port 443 for HTTPS before signing. if port != "" && !isDefaultPort(scheme, port) { // Strip existing brackets before calling JoinHostPort, which automatically adds // brackets for IPv6 addresses. This prevents double-bracketing like [[::1]]:8080. - // Using Trim handles both well-formed and malformed bracketed hosts. host = strings.Trim(host, "[]") return net.JoinHostPort(host, port) } - // No port or default port was stripped. According to AWS SDK behavior (aws-sdk-go-v2), - // when a default port is removed from an IPv6 address, the brackets should also be removed. - // This matches AWS S3 signature calculation requirements. + // Default port was stripped, or no port present. + // For IPv6 addresses, strip brackets to match AWS SDK behavior. // Reference: https://github.com/aws/aws-sdk-go-v2/blob/main/aws/signer/internal/v4/host.go - // The stripPort function returns IPv6 without brackets when port is stripped. if strings.Contains(host, ":") { - // This is an IPv6 address. Strip brackets to match AWS SDK behavior. return strings.Trim(host, "[]") } return host } +// isDefaultPort returns true if the given port is the default for the scheme. func isDefaultPort(scheme, port string) bool { - if port == "" { - return true - } - switch port { case "80": return strings.EqualFold(scheme, "http") diff --git a/weed/s3api/auth_signature_v4_test.go b/weed/s3api/auth_signature_v4_test.go index 782ce6a5b..2d8fd0a6e 100644 --- a/weed/s3api/auth_signature_v4_test.go +++ b/weed/s3api/auth_signature_v4_test.go @@ -160,6 +160,7 @@ func TestExtractHostHeader(t *testing.T) { forwardedHost string forwardedPort string forwardedProto string + externalHost string expected string }{ { @@ -227,6 +228,14 @@ func TestExtractHostHeader(t *testing.T) { forwardedProto: "https", expected: "127.0.0.1:8433", }, + { + name: "X-Forwarded-Host with standard port already included (HTTPS 443)", + hostHeader: "backend:8333", + forwardedHost: "example.com:443", + forwardedPort: "443", + forwardedProto: "https", + expected: "example.com", + }, { name: "X-Forwarded-Host with port, no X-Forwarded-Port header", hostHeader: "backend:8333", @@ -253,7 +262,7 @@ func TestExtractHostHeader(t *testing.T) { expected: "[::1]:8080", }, { - name: "IPv6 address without brackets and standard port, should strip brackets per AWS SDK", + name: "IPv6 address without brackets and standard port, should strip default port", hostHeader: "backend:8333", forwardedHost: "::1", forwardedPort: "80", @@ -261,7 +270,7 @@ func TestExtractHostHeader(t *testing.T) { expected: "::1", }, { - name: "IPv6 address without brackets and standard HTTPS port, should strip brackets per AWS SDK", + name: "IPv6 address without brackets and standard HTTPS port, should strip default port", hostHeader: "backend:8333", forwardedHost: "2001:db8::1", forwardedPort: "443", @@ -277,7 +286,7 @@ func TestExtractHostHeader(t *testing.T) { expected: "[2001:db8::1]:8080", }, { - name: "IPv6 full address with brackets and default port (should strip port and brackets)", + name: "IPv6 full address with brackets and default port (should strip default port)", hostHeader: "backend:8333", forwardedHost: "[2001:db8:85a3::8a2e:370:7334]:443", forwardedPort: "443", @@ -333,6 +342,28 @@ func TestExtractHostHeader(t *testing.T) { forwardedProto: "http", expected: "bucket.domain.com:442", }, + // externalHost override tests + { + name: "externalHost overrides everything", + hostHeader: "backend:8333", + externalHost: "api.example.com:9000", + expected: "api.example.com:9000", + }, + { + name: "externalHost overrides X-Forwarded-Host", + hostHeader: "backend:8333", + forwardedHost: "proxy.example.com", + forwardedPort: "443", + forwardedProto: "https", + externalHost: "api.example.com", + expected: "api.example.com", + }, + { + name: "externalHost with IPv6", + hostHeader: "backend:8333", + externalHost: "[::1]:9000", + expected: "[::1]:9000", + }, } for _, tt := range tests { @@ -356,7 +387,7 @@ func TestExtractHostHeader(t *testing.T) { } // Test the function - result := extractHostHeader(req) + result := extractHostHeader(req, tt.externalHost) if result != tt.expected { t.Errorf("extractHostHeader() = %q, want %q", result, tt.expected) } @@ -389,7 +420,7 @@ func TestExtractSignedHeadersCase(t *testing.T) { t.Run(tt.name, func(t *testing.T) { r, _ := http.NewRequest("GET", "http://"+tt.host+"/", nil) r.Host = tt.host - extracted, errCode := extractSignedHeaders(tt.signedHeads, r) + extracted, errCode := extractSignedHeaders(tt.signedHeads, r, "") if errCode != s3err.ErrNone { t.Fatalf("extractSignedHeaders failed: %v", errCode) } diff --git a/weed/s3api/auto_signature_v4_test.go b/weed/s3api/auto_signature_v4_test.go index 7079273ee..498766d52 100644 --- a/weed/s3api/auto_signature_v4_test.go +++ b/weed/s3api/auto_signature_v4_test.go @@ -493,7 +493,7 @@ func TestSignatureV4WithoutProxy(t *testing.T) { r.Header.Set("Host", tt.host) // First, verify that extractHostHeader returns the expected value - extractedHost := extractHostHeader(r) + extractedHost := extractHostHeader(r, "") if extractedHost != tt.expectedHost { t.Errorf("extractHostHeader() = %q, want %q", extractedHost, tt.expectedHost) } @@ -562,12 +562,12 @@ func TestSignatureV4WithForwardedPort(t *testing.T) { expectedHost: "example.com:8080", }, { - name: "empty proto with standard http port", + name: "empty proto with port 80 (scheme defaults to https from URL, so 80 is NOT default)", host: "backend:8333", forwardedHost: "example.com", forwardedPort: "80", forwardedProto: "", - expectedHost: "example.com", + expectedHost: "example.com:80", }, // Test cases for issue #6649: X-Forwarded-Host already contains port { @@ -674,8 +674,16 @@ func TestSignatureV4WithForwardedPort(t *testing.T) { r.Header.Set("X-Forwarded-Port", tt.forwardedPort) r.Header.Set("X-Forwarded-Proto", tt.forwardedProto) - // Sign the request with the expected host header - // We need to temporarily modify the Host header for signing + // Validate that extractHostHeader returns the expected host value. + // This is critical: the expectedHost must match what the AWS SDK would + // use for signing. Without this check, the test is self-referential + // (signing and verifying with the same function always agrees). + extractedHost := extractHostHeader(r, "") + if extractedHost != tt.expectedHost { + t.Errorf("extractHostHeader() = %q, want %q", extractedHost, tt.expectedHost) + } + + // Sign the request (note: signV4WithPath uses extractHostHeader internally) signV4WithPath(r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", r.URL.Path) // Test signature verification @@ -922,7 +930,7 @@ func preSignV4WithPath(iam *IdentityAccessManagement, req *http.Request, accessK // Extract signed headers extractedSignedHeaders := make(http.Header) - extractedSignedHeaders["host"] = []string{extractHostHeader(req)} + extractedSignedHeaders["host"] = []string{extractHostHeader(req, "")} // Get canonical request with custom path canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, req.URL.RawQuery, urlPath, req.Method) @@ -961,7 +969,7 @@ func signV4WithPath(req *http.Request, accessKey, secretKey, urlPath string) { // Extract signed headers extractedSignedHeaders := make(http.Header) - extractedSignedHeaders["host"] = []string{extractHostHeader(req)} + extractedSignedHeaders["host"] = []string{extractHostHeader(req, "")} extractedSignedHeaders["x-amz-date"] = []string{dateStr} // Get the payload hash diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index 5193199b2..c2d25b4f8 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -58,6 +58,7 @@ type S3ApiServerOption struct { Cipher bool // encrypt data on volume servers BindIp string GrpcPort int + ExternalUrl string // external URL clients use, for signature verification behind a reverse proxy } type S3ApiServer struct {