s3: keep verifying the request host when externalUrl is set (#10970)

* s3: keep verifying the request host when externalUrl is set

externalUrl was the only host candidate once set, so a client that dialed
the gateway directly instead of through the proxy always got
SignatureDoesNotMatch. Make it lead the candidate walk instead: every
candidate still needs a valid signature, and the request-derived hosts are
already trusted when the flag is unset, so a mixed proxy plus in-cluster
topology can now advertise a public endpoint and verify both planes.

* s3: cover virtual-hosted addressing behind externalUrl

The old pin also rejected an external client that signed
bucket.api.example.com, since only the bare externalUrl host was ever
tried. The candidate walk covers it; pin the case down.
This commit is contained in:
Chris Lu
2026-08-26 10:09:05 -07:00
committed by GitHub
parent 3431bdcb74
commit f5f1dcbd8c
10 changed files with 46 additions and 17 deletions
+5 -5
View File
@@ -26,16 +26,16 @@ nginx (:9000)
v
SeaweedFS S3 (:8333, -s3.externalUrl=http://localhost:9000)
| externalHost = "localhost:9000" (parsed at startup)
| extractHostHeader() returns "localhost:9000"
| extractHostHeaderCandidates() tries "localhost:9000" first
| 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.
**Note:** `-s3.externalUrl` is tried first, not exclusively. A client that
dials the backend port (8333) directly still verifies against the host it
actually signed, so a mixed topology of proxied and in-cluster clients works
with the flag set.
## Prerequisites
+1 -1
View File
@@ -158,7 +158,7 @@ func init() {
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.portLance = cmdFiler.Flag.Int("s3.port.lance", 9101, "Lance Namespace 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.")
filerS3Options.externalUrl = cmdFiler.Flag.String("s3.externalUrl", "", "the external URL clients use to connect (e.g. https://api.example.com:9000). Advertised to Iceberg and Lance clients, and tried first when verifying S3 signatures behind a reverse proxy. Falls back to S3_EXTERNAL_URL env var.")
filerS3Options.defaultFileMode = cmdFiler.Flag.String("s3.defaultFileMode", "", "default file mode for S3 uploaded objects, e.g. 0660, 0644, 0666")
filerS3Options.cacheSizeMB = cmdFiler.Flag.Int64("s3.cacheCapacityMB", 0, "in-memory chunk cache capacity in MB for S3 GETs shared across requests (0 disables)")
+1 -1
View File
@@ -526,7 +526,7 @@ func initMiniS3Flags() {
miniS3Options.auditLogConfig = cmdMini.Flag.String("s3.auditLogConfig", "", "path to the audit log config file")
miniS3Options.allowDeleteBucketNotEmpty = miniS3AllowDeleteBucketNotEmpty
miniS3Options.autoCreateBucket = miniS3AutoCreateBucket
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.")
miniS3Options.externalUrl = cmdMini.Flag.String("s3.externalUrl", "", "the external URL clients use to connect (e.g. https://api.example.com:9000). Advertised to Iceberg and Lance clients, and tried first when verifying S3 signatures behind a reverse proxy. Falls back to S3_EXTERNAL_URL env var.")
miniS3Options.defaultFileMode = cmdMini.Flag.String("s3.defaultFileMode", "", "default file mode for S3 uploaded objects, e.g. 0660, 0644, 0666")
miniS3Options.cacheSizeMB = cmdMini.Flag.Int64("s3.cacheCapacityMB", 0, "in-memory chunk cache capacity in MB for S3 GETs shared across requests (0 disables)")
// In mini mode, S3 uses the shared debug server started at line 681, not its own separate debug server
+1 -1
View File
@@ -124,7 +124,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.")
s3StandaloneOptions.externalUrl = cmdS3.Flag.String("externalUrl", "", "the external URL clients use to connect (e.g. https://api.example.com:9000). Advertised to Iceberg and Lance clients, and tried first when verifying S3 signatures behind a reverse proxy. Falls back to S3_EXTERNAL_URL env var.")
s3StandaloneOptions.defaultFileMode = cmdS3.Flag.String("defaultFileMode", "", "default file mode for S3 uploaded objects, e.g. 0660, 0644, 0666")
s3StandaloneOptions.cacheSizeMB = cmdS3.Flag.Int64("cacheCapacityMB", 0, "in-memory chunk cache capacity in MB for S3 GETs shared across requests (0 disables)")
}
+1 -1
View File
@@ -187,7 +187,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.")
s3Options.externalUrl = cmdServer.Flag.String("s3.externalUrl", "", "the external URL clients use to connect (e.g. https://api.example.com:9000). Advertised to Iceberg and Lance clients, and tried first when verifying S3 signatures behind a reverse proxy. Falls back to S3_EXTERNAL_URL env var.")
s3Options.defaultFileMode = cmdServer.Flag.String("s3.defaultFileMode", "", "default file mode for S3 uploaded objects, e.g. 0660, 0644, 0666")
s3Options.cacheSizeMB = cmdServer.Flag.Int64("s3.cacheCapacityMB", 0, "in-memory chunk cache capacity in MB for S3 GETs shared across requests (0 disables)")
+2 -2
View File
@@ -60,7 +60,7 @@ type IdentityAccessManagement struct {
hashCounters map[string]*int32
identityAnonymous *Identity
domain string
externalHost string // pre-computed host for S3 signature verification (from ExternalUrl)
externalHost string // pre-computed host tried first during S3 signature verification (from ExternalUrl)
isAuthEnabled bool
credentialManager *credential.CredentialManager
filerClient *wdclient.FilerClient
@@ -315,7 +315,7 @@ func NewIdentityAccessManagementWithStore(option *S3ApiServerOption, filerClient
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)
glog.V(0).Infof("S3 signature verification will try external host %q (from %q) first", externalHost, option.ExternalUrl)
}
iam := &IdentityAccessManagement{
+14
View File
@@ -312,6 +312,20 @@ func TestExternalUrlSignatureVerification(t *testing.T) {
externalUrl: "http://api.example.com:80",
expectSuccess: true,
},
{
name: "externalUrl set, in-cluster client signs the service host",
clientUrl: "http://seaweedfs-s3:8333/test-bucket/object",
backendHost: "seaweedfs-s3:8333",
externalUrl: "https://api.example.com",
expectSuccess: true,
},
{
name: "externalUrl set, external client signs a virtual-hosted bucket name",
clientUrl: "https://test-bucket.api.example.com/object",
backendHost: "test-bucket.api.example.com",
externalUrl: "https://api.example.com",
expectSuccess: true,
},
{
name: "without externalUrl, internal host causes mismatch",
clientUrl: "https://api.example.com:9000/test-bucket/object",
+5 -4
View File
@@ -888,14 +888,16 @@ func extractHostHeader(r *http.Request, externalHost string) string {
}
// extractHostHeaderCandidates returns the host values the client may have signed, most
// likely first. When externalHost is set (from s3.externalUrl), it is the only candidate.
// Otherwise, the host is reconstructed from X-Forwarded-* headers or the request Host.
// likely first. externalHost (from s3.externalUrl) leads when set, but the hosts derived
// from X-Forwarded-* headers or the request Host still follow it, so clients that reach
// the gateway directly rather than through the proxy keep verifying.
// When X-Forwarded-Host carries no port, the true client port is ambiguous: a proxy that
// kept the Host header makes the r.Host port right, one that rewrote it makes
// X-Forwarded-Port right, and a client on the scheme's default port signed no port at all.
func extractHostHeaderCandidates(r *http.Request, externalHost string) []string {
var candidates []string
if externalHost != "" {
return []string{externalHost}
candidates = append(candidates, externalHost)
}
forwardedHost := r.Header.Get("X-Forwarded-Host")
@@ -968,7 +970,6 @@ func extractHostHeaderCandidates(r *http.Request, externalHost string) []string
}
}
var candidates []string
for _, port := range ports {
candidate := joinSignedHost(host, port, scheme)
if !slices.Contains(candidates, candidate) {
+15 -1
View File
@@ -535,9 +535,23 @@ func TestExtractHostHeaderCandidates(t *testing.T) {
expected []string
}{
{
name: "externalHost is the only candidate",
name: "externalHost leads, request host still follows",
hostHeader: "backend:8333",
externalHost: "api.example.com:9000",
expected: []string{"api.example.com:9000", "backend:8333"},
},
{
name: "externalHost leads the forwarded candidates",
hostHeader: "backend:8333",
forwardedHost: "example.com",
forwardedPort: "9000",
externalHost: "api.example.com",
expected: []string{"api.example.com", "example.com:9000", "example.com"},
},
{
name: "externalHost equal to the request host is not repeated",
hostHeader: "api.example.com:9000",
externalHost: "api.example.com:9000",
expected: []string{"api.example.com:9000"},
},
{
+1 -1
View File
@@ -65,7 +65,7 @@ type S3ApiServerOption struct {
Ip string // address advertised to the cluster; empty falls back to BindIp
BindIp string
GrpcPort int
ExternalUrl string // external URL clients use, for signature verification behind a reverse proxy
ExternalUrl string // external URL clients use, tried first during signature verification behind a reverse proxy
DefaultFileMode uint32 // default file permission mode for S3 uploads (e.g. 0660, 0644)
CacheSizeMB int64 // in-memory chunk cache capacity in MB for the shared ReaderCache; 0 disables
MaxMB int32 // filer's -maxMB, read from the filer configuration at startup