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:
@@ -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")
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
212
weed/s3api/auth_proxy_integration_test.go
Normal file
212
weed/s3api/auth_proxy_integration_test.go
Normal 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)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user