From 3d81d5bef723eed7958af6d94e97fef88884ff9f Mon Sep 17 00:00:00 2001 From: blitt001 <51137523+blitt001@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:20:42 -0800 Subject: [PATCH] Fix S3 signature verification behind reverse proxies (#8444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix S3 signature verification behind reverse proxies When SeaweedFS is deployed behind a reverse proxy (e.g. nginx, Kong, Traefik), AWS S3 Signature V4 verification fails because the Host header the client signed with (e.g. "localhost:9000") differs from the Host header SeaweedFS receives on the backend (e.g. "seaweedfs:8333"). This commit adds a new -s3.externalUrl parameter (and S3_EXTERNAL_URL environment variable) that tells SeaweedFS what public-facing URL clients use to connect. When set, SeaweedFS uses this host value for signature verification instead of the Host header from the incoming request. New parameter: -s3.externalUrl (flag) or S3_EXTERNAL_URL (environment variable) Example: -s3.externalUrl=http://localhost:9000 Example: S3_EXTERNAL_URL=https://s3.example.com The environment variable is particularly useful in Docker/Kubernetes deployments where the external URL is injected via container config. The flag takes precedence over the environment variable when both are set. At startup, the URL is parsed and default ports are stripped to match AWS SDK behavior (port 80 for HTTP, port 443 for HTTPS), so "http://s3.example.com:80" and "http://s3.example.com" are equivalent. Bugs fixed: - Default port stripping was removed by a prior PR, causing signature mismatches when clients connect on standard ports (80/443) - X-Forwarded-Port was ignored when X-Forwarded-Host was not present - Scheme detection now uses proper precedence: X-Forwarded-Proto > TLS connection > URL scheme > "http" - Test expectations for standard port stripping were incorrect - expectedHost field in TestSignatureV4WithForwardedPort was declared but never actually checked (self-referential test) * Add Docker integration test for S3 proxy signature verification Docker Compose setup with nginx reverse proxy to validate that the -s3.externalUrl parameter (or S3_EXTERNAL_URL env var) correctly resolves S3 signature verification when SeaweedFS runs behind a proxy. The test uses nginx proxying port 9000 to SeaweedFS on port 8333, with X-Forwarded-Host/Port/Proto headers set. SeaweedFS is configured with -s3.externalUrl=http://localhost:9000 so it uses "localhost:9000" for signature verification, matching what the AWS CLI signs with. The test can be run with aws CLI on the host or without it by using the amazon/aws-cli Docker image with --network host. Test covers: create-bucket, list-buckets, put-object, head-object, list-objects-v2, get-object, content round-trip integrity, delete-object, and delete-bucket — all through the reverse proxy. * Create s3-proxy-signature-tests.yml * fix CLI * fix CI * Update s3-proxy-signature-tests.yml * address comments * Update Dockerfile * add user * no need for fuse * Update s3-proxy-signature-tests.yml * debug * weed mini * fix health check * health check * fix health checking --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Chris Lu --- .../workflows/s3-proxy-signature-tests.yml | 122 ++++++++ test/s3/proxy_signature/Dockerfile | 12 + test/s3/proxy_signature/README.md | 79 +++++ test/s3/proxy_signature/docker-compose.yml | 28 ++ test/s3/proxy_signature/nginx.conf | 23 ++ test/s3/proxy_signature/s3.json | 20 ++ test/s3/proxy_signature/test.sh | 132 ++++++++ weed/command/filer.go | 1 + weed/command/mini.go | 1 + weed/command/s3.go | 11 + weed/command/server.go | 1 + weed/s3api/auth_credentials.go | 46 +++ weed/s3api/auth_credentials_test.go | 82 +++++ weed/s3api/auth_proxy_integration_test.go | 212 +++++++++++++ weed/s3api/auth_security_test.go | 295 ++++++++++++++++++ weed/s3api/auth_signature_v4.go | 66 ++-- weed/s3api/auth_signature_v4_test.go | 41 ++- weed/s3api/auto_signature_v4_test.go | 22 +- weed/s3api/s3api_server.go | 1 + 19 files changed, 1157 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/s3-proxy-signature-tests.yml create mode 100644 test/s3/proxy_signature/Dockerfile create mode 100644 test/s3/proxy_signature/README.md create mode 100644 test/s3/proxy_signature/docker-compose.yml create mode 100644 test/s3/proxy_signature/nginx.conf create mode 100644 test/s3/proxy_signature/s3.json create mode 100755 test/s3/proxy_signature/test.sh create mode 100644 weed/s3api/auth_proxy_integration_test.go 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 {