From 98d89ffad7af3abe482750e7b0d4e87e5df337e1 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 24 Feb 2026 13:09:40 -0800 Subject: [PATCH] s3api: preserve Host header port in signature verification (#8434) Avoid stripping default ports (80/443) from the Host header in extractHostHeader. This fixes SignatureDoesNotMatch errors when SeaweedFS is accessed via a proxy (like Kong Ingress) that explicitly includes the port in the Host header or X-Forwarded-Host, which S3 clients sign. Also cleaned up unused variables and logic after refactoring. --- weed/s3api/auth_signature_v4.go | 50 +++++++--------------------- weed/s3api/auth_signature_v4_test.go | 24 ++++++++----- weed/s3api/auto_signature_v4_test.go | 8 ++--- 3 files changed, 32 insertions(+), 50 deletions(-) diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go index 963cf12a0..f9e125816 100644 --- a/weed/s3api/auth_signature_v4.go +++ b/weed/s3api/auth_signature_v4.go @@ -788,25 +788,9 @@ func extractSignedHeaders(signedHeaders []string, r *http.Request) (http.Header, func extractHostHeader(r *http.Request) string { 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" - scheme := "http" - if r.URL.Scheme != "" { - scheme = r.URL.Scheme - } - if r.TLS != nil { - scheme = "https" - } - if forwardedProto != "" { - scheme = forwardedProto - } var host, port string + explicitPort := false if forwardedHost != "" { // X-Forwarded-Host can be a comma-separated list of hosts when there are multiple proxies. // Use only the first host in the list and trim spaces for robustness. @@ -815,12 +799,16 @@ func extractHostHeader(r *http.Request) string { } else { host = strings.TrimSpace(forwardedHost) } + // Baseline port from forwarded port if available + if forwardedPort != "" { + port = forwardedPort + explicitPort = true + } + // 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 + explicitPort = true } } else { host = r.Host @@ -830,12 +818,13 @@ func extractHostHeader(r *http.Request) string { if h, p, err := net.SplitHostPort(host); err == nil { host = h port = p + explicitPort = true } } - // If we have a non-default port, join it with the host. + // If a port was explicitly provided, join it with the host. // net.JoinHostPort will handle bracketing for IPv6. - if port != "" && !isDefaultPort(scheme, port) { + if explicitPort && 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. @@ -843,7 +832,7 @@ func extractHostHeader(r *http.Request) string { return net.JoinHostPort(host, port) } - // No port or default port was stripped. According to AWS SDK behavior (aws-sdk-go-v2), + // No explicit port was provided (or port was empty). 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. // Reference: https://github.com/aws/aws-sdk-go-v2/blob/main/aws/signer/internal/v4/host.go @@ -855,21 +844,6 @@ func extractHostHeader(r *http.Request) string { return host } -func isDefaultPort(scheme, port string) bool { - if port == "" { - return true - } - - switch port { - case "80": - return strings.EqualFold(scheme, "http") - case "443": - return strings.EqualFold(scheme, "https") - default: - return false - } -} - // getScope generate a string of a specific date, an AWS region, and a service. func getScope(t time.Time, region string, service string) string { scope := strings.Join([]string{ diff --git a/weed/s3api/auth_signature_v4_test.go b/weed/s3api/auth_signature_v4_test.go index 782ce6a5b..dc6f1b462 100644 --- a/weed/s3api/auth_signature_v4_test.go +++ b/weed/s3api/auth_signature_v4_test.go @@ -208,7 +208,7 @@ func TestExtractHostHeader(t *testing.T) { forwardedHost: "example.com", forwardedPort: "80", forwardedProto: "http", - expected: "example.com", + expected: "example.com:80", }, { name: "X-Forwarded-Host with X-Forwarded-Port (HTTPS standard port 443)", @@ -216,7 +216,7 @@ func TestExtractHostHeader(t *testing.T) { forwardedHost: "example.com", forwardedPort: "443", forwardedProto: "https", - expected: "example.com", + expected: "example.com:443", }, // Issue #6649: X-Forwarded-Host already contains port (Traefik/HAProxy style) { @@ -227,6 +227,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:443", + }, { name: "X-Forwarded-Host with port, no X-Forwarded-Port header", hostHeader: "backend:8333", @@ -253,20 +261,20 @@ 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 include brackets and port when explicit", hostHeader: "backend:8333", forwardedHost: "::1", forwardedPort: "80", forwardedProto: "http", - expected: "::1", + expected: "[::1]:80", }, { - 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 include brackets and port when explicit", hostHeader: "backend:8333", forwardedHost: "2001:db8::1", forwardedPort: "443", forwardedProto: "https", - expected: "2001:db8::1", + expected: "[2001:db8::1]:443", }, { name: "IPv6 address with brackets but no port, should add port", @@ -277,12 +285,12 @@ 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 preserve port if explicit)", hostHeader: "backend:8333", forwardedHost: "[2001:db8:85a3::8a2e:370:7334]:443", forwardedPort: "443", forwardedProto: "https", - expected: "2001:db8:85a3::8a2e:370:7334", + expected: "[2001:db8:85a3::8a2e:370:7334]:443", }, { name: "IPv4-mapped IPv6 address without brackets, should add brackets with port", diff --git a/weed/s3api/auto_signature_v4_test.go b/weed/s3api/auto_signature_v4_test.go index 7079273ee..a3f032353 100644 --- a/weed/s3api/auto_signature_v4_test.go +++ b/weed/s3api/auto_signature_v4_test.go @@ -415,13 +415,13 @@ func TestSignatureV4WithoutProxy(t *testing.T) { name: "HTTP with standard port", host: "backend:80", proto: "http", - expectedHost: "backend", + expectedHost: "backend:80", }, { name: "HTTPS with standard port", host: "backend:443", proto: "https", - expectedHost: "backend", + expectedHost: "backend:443", }, { name: "HTTP without port", @@ -451,13 +451,13 @@ func TestSignatureV4WithoutProxy(t *testing.T) { name: "IPv6 HTTP with standard port", host: "[::1]:80", proto: "http", - expectedHost: "::1", + expectedHost: "[::1]:80", }, { name: "IPv6 HTTPS with standard port", host: "[::1]:443", proto: "https", - expectedHost: "::1", + expectedHost: "[::1]:443", }, { name: "IPv6 HTTP without port",