Fix S3 signature verification behind reverse proxies (#8444)

* 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 <noreply@anthropic.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
This commit is contained in:
blitt001
2026-02-26 14:20:42 -08:00
committed by GitHub
parent ae02d47433
commit 3d81d5bef7
19 changed files with 1157 additions and 38 deletions

View File

@@ -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")

View File

@@ -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)")

View File

@@ -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)

View File

@@ -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")

View File

@@ -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,

View File

@@ -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)
})
}
}

View File

@@ -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)")
}
})
}
}

View File

@@ -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)
})
}
}

View File

@@ -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")

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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 {