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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user