package s3api import ( "bytes" "crypto/sha256" "encoding/hex" "fmt" "net/http" "net/url" "slices" "sync" "testing" "time" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" ) func TestExtractV4AuthInfoFromHeader_S3Tables(t *testing.T) { now := time.Now().UTC() dateStr := now.Format(iso8601Format) tests := []struct { name string service string body string expectAutoHash bool }{ { name: "s3 service should not auto-hash", service: "s3", body: "hello", expectAutoHash: false, }, { name: "s3tables service should auto-hash", service: "s3tables", body: "hello", expectAutoHash: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body := bytes.NewReader([]byte(tt.body)) req, _ := http.NewRequest(http.MethodPost, "http://localhost/", body) authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/%s/us-east-1/%s/aws4_request, SignedHeaders=host, Signature=dummy", now.Format(yyyymmdd), tt.service) req.Header.Set("Authorization", authHeader) req.Header.Set("x-amz-date", dateStr) authInfo, errCode := extractV4AuthInfoFromHeader(req) if errCode != s3err.ErrNone { t.Fatalf("extractV4AuthInfoFromHeader failed: %v", errCode) } if tt.expectAutoHash { expectedHash := sha256.Sum256([]byte(tt.body)) expectedHashStr := hex.EncodeToString(expectedHash[:]) if authInfo.HashedPayload != expectedHashStr { t.Errorf("Expected auto-hashed payload %s, got %s", expectedHashStr, authInfo.HashedPayload) } } else { if authInfo.HashedPayload != emptySHA256 { t.Errorf("Expected non-auto-hashed payload %s (emptySHA256), got %s", emptySHA256, authInfo.HashedPayload) } } }) } } func TestParseSignedHeaderRejectsEmptyHeaderNames(t *testing.T) { cases := []string{ "SignedHeaders=host;", "SignedHeaders=;host", "SignedHeaders=host;;x-amz-date", } for _, tc := range cases { if _, errCode := parseSignedHeader(tc); errCode != s3err.ErrMissingFields { t.Fatalf("parseSignedHeader(%q) errCode = %v, want %v", tc, errCode, s3err.ErrMissingFields) } } } func TestExtractV4AuthInfoFromQueryRejectsEmptySignedHeaderNames(t *testing.T) { now := time.Now().UTC() cases := []string{ "host;", ";host", "host;;x-amz-date", } for _, tc := range cases { t.Run(tc, func(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "http://localhost/bucket/object", nil) if err != nil { t.Fatalf("NewRequest: %v", err) } query := req.URL.Query() query.Set("X-Amz-Algorithm", signV4Algorithm) query.Set("X-Amz-Credential", fmt.Sprintf("AKIAIOSFODNN7EXAMPLE/%s/us-east-1/s3/aws4_request", now.Format(yyyymmdd))) query.Set("X-Amz-Date", now.Format(iso8601Format)) query.Set("X-Amz-Expires", "60") query.Set("X-Amz-Signature", "dummy") query.Set("X-Amz-SignedHeaders", tc) req.URL.RawQuery = query.Encode() if _, errCode := extractV4AuthInfoFromQuery(req); errCode != s3err.ErrMissingFields { t.Fatalf("extractV4AuthInfoFromQuery(%q) errCode = %v, want %v", tc, errCode, s3err.ErrMissingFields) } }) } } func TestGetCanonicalQueryString(t *testing.T) { tests := []struct { name string target string isPresigned bool want string }{ { name: "sorts repeated values", target: "http://localhost/bucket/key?partNumber=2&partNumber=10&uploadId=z", want: "partNumber=10&partNumber=2&uploadId=z", }, { name: "removes presigned signature", target: "http://localhost/bucket/key?X-Amz-Date=20260618T000000Z&X-Amz-Signature=dummy&X-Amz-SignedHeaders=host", isPresigned: true, want: "X-Amz-Date=20260618T000000Z&X-Amz-SignedHeaders=host", }, { name: "sorts mixed single and repeated parameters", target: "http://localhost/bucket/key?z=last&a=2&bucket=b&a=1", want: "a=1&a=2&bucket=b&z=last", }, { name: "encodes query parameter values", target: "http://localhost/bucket/key?prefix=photos/2026&marker=a%2Bb", want: "marker=a%2Bb&prefix=photos%2F2026", }, { name: "empty query string", target: "http://localhost/bucket/key", want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, err := http.NewRequest(http.MethodGet, tt.target, nil) if err != nil { t.Fatalf("NewRequest: %v", err) } got := getCanonicalQueryString(req, tt.isPresigned) if got != tt.want { t.Fatalf("canonical query = %q, want %q", got, tt.want) } }) } } func TestBuildPathWithForwardedPrefix(t *testing.T) { tests := []struct { name string forwardedPrefix string urlPath string expected string }{ { name: "empty prefix returns urlPath", forwardedPrefix: "", urlPath: "/bucket/obj", expected: "/bucket/obj", }, { name: "prefix without trailing slash", forwardedPrefix: "/storage", urlPath: "/bucket/obj", expected: "/storage/bucket/obj", }, { name: "prefix with trailing slash", forwardedPrefix: "/storage/", urlPath: "/bucket/obj", expected: "/storage/bucket/obj", }, { name: "prefix without leading slash", forwardedPrefix: "storage", urlPath: "/bucket/obj", expected: "/storage/bucket/obj", }, { name: "prefix without leading slash and with trailing slash", forwardedPrefix: "storage/", urlPath: "/bucket/obj", expected: "/storage/bucket/obj", }, { name: "preserve double slashes in key", forwardedPrefix: "/storage", urlPath: "/bucket//obj", expected: "/storage/bucket//obj", }, { name: "preserve trailing slash in urlPath", forwardedPrefix: "/storage", urlPath: "/bucket/folder/", expected: "/storage/bucket/folder/", }, { name: "preserve trailing slash with prefix having trailing slash", forwardedPrefix: "/storage/", urlPath: "/bucket/folder/", expected: "/storage/bucket/folder/", }, { name: "root path", forwardedPrefix: "/storage", urlPath: "/", expected: "/storage/", }, { name: "complex key with multiple slashes", forwardedPrefix: "/api/v1", urlPath: "/bucket/path//with///slashes", expected: "/api/v1/bucket/path//with///slashes", }, { name: "urlPath without leading slash", forwardedPrefix: "/storage", urlPath: "bucket/obj", expected: "/storage/bucket/obj", }, { name: "empty urlPath with prefix", forwardedPrefix: "/s3", urlPath: "", expected: "/s3", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := buildPathWithForwardedPrefix(tt.forwardedPrefix, tt.urlPath) if result != tt.expected { t.Errorf("buildPathWithForwardedPrefix(%q, %q) = %q, want %q", tt.forwardedPrefix, tt.urlPath, result, tt.expected) } }) } } // TestExtractHostHeader tests the extractHostHeader function with various scenarios func TestExtractHostHeader(t *testing.T) { tests := []struct { name string hostHeader string forwardedHost string forwardedPort string forwardedProto string externalHost string expected string }{ { name: "basic host without forwarding", hostHeader: "example.com", forwardedHost: "", forwardedPort: "", forwardedProto: "", expected: "example.com", }, { name: "host with port without forwarding", hostHeader: "example.com:8080", forwardedHost: "", forwardedPort: "", forwardedProto: "", expected: "example.com:8080", }, { name: "X-Forwarded-Host without port", hostHeader: "backend:8333", forwardedHost: "example.com", forwardedPort: "", forwardedProto: "", expected: "example.com", }, { name: "X-Forwarded-Host with X-Forwarded-Port (HTTP non-standard)", hostHeader: "backend:8333", forwardedHost: "example.com", forwardedPort: "8080", forwardedProto: "http", expected: "example.com:8080", }, { name: "X-Forwarded-Host with X-Forwarded-Port (HTTPS non-standard)", hostHeader: "backend:8333", forwardedHost: "example.com", forwardedPort: "8443", forwardedProto: "https", expected: "example.com:8443", }, { name: "X-Forwarded-Host with X-Forwarded-Port (HTTP standard port 80)", hostHeader: "backend:8333", forwardedHost: "example.com", forwardedPort: "80", forwardedProto: "http", expected: "example.com", }, { name: "X-Forwarded-Host with X-Forwarded-Port (HTTPS standard port 443)", hostHeader: "backend:8333", forwardedHost: "example.com", forwardedPort: "443", forwardedProto: "https", expected: "example.com", }, // Issue #6649: X-Forwarded-Host already contains port (Traefik/HAProxy style) { name: "X-Forwarded-Host with port already included (should not add port again)", hostHeader: "backend:8333", forwardedHost: "127.0.0.1:8433", forwardedPort: "8433", 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", forwardedHost: "example.com:9000", forwardedPort: "", forwardedProto: "http", expected: "example.com:9000", }, // IPv6 test cases { name: "IPv6 address with brackets and port in X-Forwarded-Host", hostHeader: "backend:8333", forwardedHost: "[::1]:8080", forwardedPort: "8080", forwardedProto: "http", expected: "[::1]:8080", }, { name: "IPv6 address without brackets, should add brackets with port", hostHeader: "backend:8333", forwardedHost: "::1", forwardedPort: "8080", forwardedProto: "http", expected: "[::1]:8080", }, { name: "IPv6 address without brackets and standard port, should strip default port", hostHeader: "backend:8333", forwardedHost: "::1", forwardedPort: "80", forwardedProto: "http", expected: "::1", }, { name: "IPv6 address without brackets and standard HTTPS port, should strip default port", hostHeader: "backend:8333", forwardedHost: "2001:db8::1", forwardedPort: "443", forwardedProto: "https", expected: "2001:db8::1", }, { name: "IPv6 address with brackets but no port, should add port", hostHeader: "backend:8333", forwardedHost: "[2001:db8::1]", forwardedPort: "8080", forwardedProto: "http", expected: "[2001:db8::1]:8080", }, { 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", forwardedProto: "https", expected: "2001:db8:85a3::8a2e:370:7334", }, { name: "IPv4-mapped IPv6 address without brackets, should add brackets with port", hostHeader: "backend:8333", forwardedHost: "::ffff:127.0.0.1", forwardedPort: "8080", forwardedProto: "http", expected: "[::ffff:127.0.0.1]:8080", }, { name: "Simple port 442", hostHeader: "bucket.domain.com:442", expected: "bucket.domain.com:442", }, { name: "Port 442 with X-Forwarded-Host", hostHeader: "backend:8333", forwardedHost: "bucket.domain.com:442", expected: "bucket.domain.com:442", }, { name: "Port 442 with X-Forwarded-Port", hostHeader: "backend:8333", forwardedHost: "bucket.domain.com", forwardedPort: "442", expected: "bucket.domain.com:442", }, { name: "HTTPS with port 442 (should NOT strip)", hostHeader: "bucket.domain.com:442", forwardedProto: "https", expected: "bucket.domain.com:442", }, { name: "X-Forwarded-Host with multiple hosts (including port)", forwardedHost: "bucket.domain.com:442, internal.proxy", expected: "bucket.domain.com:442", }, { name: "IPv6 with port", hostHeader: "[2001:db8::1]:442", expected: "[2001:db8::1]:442", }, { name: "X-Forwarded-Host with port 442, but X-Forwarded-Port is 80 (should PREFER 442)", forwardedHost: "bucket.domain.com:442", forwardedPort: "80", 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", }, // Bug fix: X-Forwarded-Port should not override more specific ports in other headers { name: "User reported case: X-Forwarded-Port misreports 443 but Host has 30007", hostHeader: "storage-stgops.mt.mtnet:30007", forwardedHost: "storage-stgops.mt.mtnet", forwardedPort: "443", forwardedProto: "https", expected: "storage-stgops.mt.mtnet:30007", }, { name: "X-Forwarded-Host already contains correct port, ignore misaligned X-Forwarded-Port", hostHeader: "backend:8333", forwardedHost: "storage-stgops.mt.mtnet:30007", forwardedPort: "443", forwardedProto: "https", expected: "storage-stgops.mt.mtnet:30007", }, { name: "X-Forwarded-Host has no port, match r.Host hostname and take its port", hostHeader: "example.com:8080", forwardedHost: "example.com", forwardedPort: "80", forwardedProto: "http", expected: "example.com:8080", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a mock request req, err := http.NewRequest("GET", "http://"+tt.hostHeader+"/bucket/object", nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } // Set headers req.Host = tt.hostHeader if tt.forwardedHost != "" { req.Header.Set("X-Forwarded-Host", tt.forwardedHost) } if tt.forwardedPort != "" { req.Header.Set("X-Forwarded-Port", tt.forwardedPort) } if tt.forwardedProto != "" { req.Header.Set("X-Forwarded-Proto", tt.forwardedProto) } // Test the function result := extractHostHeader(req, tt.externalHost) if result != tt.expected { t.Errorf("extractHostHeader() = %q, want %q", result, tt.expected) } }) } } // TestExtractHostHeaderCandidates tests the alternate host values tried during verification // when the true client-facing host is ambiguous behind a reverse proxy. func TestExtractHostHeaderCandidates(t *testing.T) { tests := []struct { name string hostHeader string forwardedHost string forwardedPort string forwardedProto string externalHost string expected []string }{ { 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"}, }, { name: "X-Forwarded-Host with port is trusted as-is", hostHeader: "backend:8333", forwardedHost: "example.com:9000", forwardedPort: "443", expected: []string{"example.com:9000"}, }, { name: "plain Host with port is the only candidate", hostHeader: "example.com:8080", expected: []string{"example.com:8080"}, }, { name: "portless X-Forwarded-Host, hostnames match: r.Host port first, then X-Forwarded-Port, then bare", hostHeader: "example.com:8333", forwardedHost: "example.com", forwardedPort: "9000", forwardedProto: "http", expected: []string{"example.com:8333", "example.com:9000", "example.com"}, }, { name: "portless X-Forwarded-Host, hostnames match, no X-Forwarded-Port: bare host as fallback", hostHeader: "example.com:8333", forwardedHost: "example.com", expected: []string{"example.com:8333", "example.com"}, }, { name: "portless X-Forwarded-Host, hostnames differ: X-Forwarded-Port, then bare", hostHeader: "backend:8333", forwardedHost: "example.com", forwardedPort: "9000", expected: []string{"example.com:9000", "example.com"}, }, { name: "default X-Forwarded-Port collapses into the bare candidate", hostHeader: "example.com:8333", forwardedHost: "example.com", forwardedPort: "443", forwardedProto: "https", expected: []string{"example.com:8333", "example.com"}, }, { name: "portless Host with X-Forwarded-Port: forwarded port, then bare", hostHeader: "example.com", forwardedPort: "9000", expected: []string{"example.com:9000", "example.com"}, }, { name: "bracketed portless IPv6 X-Forwarded-Host matches the request host", hostHeader: "[::1]:8333", forwardedHost: "[::1]", expected: []string{"[::1]:8333", "::1"}, }, { name: "unbracketed portless IPv6 X-Forwarded-Host with X-Forwarded-Port", hostHeader: "backend:8333", forwardedHost: "::1", forwardedPort: "8080", expected: []string{"[::1]:8080", "::1"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, err := http.NewRequest("GET", "http://"+tt.hostHeader+"/bucket/object", nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Host = tt.hostHeader if tt.forwardedHost != "" { req.Header.Set("X-Forwarded-Host", tt.forwardedHost) } if tt.forwardedPort != "" { req.Header.Set("X-Forwarded-Port", tt.forwardedPort) } if tt.forwardedProto != "" { req.Header.Set("X-Forwarded-Proto", tt.forwardedProto) } result := extractHostHeaderCandidates(req, tt.externalHost) if !slices.Equal(result, tt.expected) { t.Errorf("extractHostHeaderCandidates() = %v, want %v", result, tt.expected) } }) } } func TestExtractSignedHeadersCase(t *testing.T) { tests := []struct { name string host string signedHeads []string expected string }{ { name: "lowercase host", host: "bucket.domain.com:442", signedHeads: []string{"host"}, expected: "host:bucket.domain.com:442\n", }, { name: "uppercase Host", host: "bucket.domain.com:442", signedHeads: []string{"Host"}, expected: "host:bucket.domain.com:442\n", }, } for _, tt := range tests { 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, "") if errCode != s3err.ErrNone { t.Fatalf("extractSignedHeaders failed: %v", errCode) } actual := getCanonicalHeaders(extracted) if actual != tt.expected { t.Errorf("%s: expected %q, got %q", tt.name, tt.expected, actual) } }) } } // A client may sign content-length even when the request carries no body // (aws-cli's checksum HEAD does). The Go server strips the header into // r.ContentLength, so extraction rebuilds it rather than dropping it. func TestExtractSignedHeadersContentLength(t *testing.T) { r, _ := http.NewRequest("HEAD", "http://bucket.domain.com/bucket/key", nil) r.Host = "bucket.domain.com" r.ContentLength = 0 extracted, errCode := extractSignedHeaders([]string{"host", "content-length"}, r, "") if errCode != s3err.ErrNone { t.Fatalf("extractSignedHeaders failed: %v", errCode) } if got := getCanonicalHeaders(extracted); got != "content-length:0\nhost:bucket.domain.com\n" { t.Fatalf("unexpected canonical headers %q", got) } } // A router that internally rewrites the request path (the Iceberg catalog // collapses a doubled /v1 prefix) must still verify the signature against // the path the client signed, which is kept in the request context. func TestVerifyV4SignatureUsesOriginalRequestPath(t *testing.T) { iam := &IdentityAccessManagement{ hashes: make(map[string]*sync.Pool), hashCounters: make(map[string]*int32), } _ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{ Identities: []*iam_pb.Identity{ { Name: "someone", Credentials: []*iam_pb.Credential{ { AccessKey: "access_key_1", SecretKey: "secret_key_1", }, }, Actions: []string{"Admin", "Read", "Write"}, }, }, }) req := mustNewSignedRequest(http.MethodGet, "http://127.0.0.1:9000/v1/v1/bucket/namespaces", 0, nil, t) // Simulate the rewrite: the routed path is collapsed while the signature // still covers the doubled path the client sent. rewritten := req.Clone(req.Context()) rewritten.URL.Path = "/v1/bucket/namespaces" if _, errCode := iam.reqSignatureV4Verify(rewritten); errCode == s3err.ErrNone { t.Fatal("rewritten request verified without the original path; expected a signature mismatch") } rewritten = rewritten.WithContext(s3_constants.SetOriginalRequestPathInContext(rewritten.Context(), "/v1/v1/bucket/namespaces")) if _, errCode := iam.reqSignatureV4Verify(rewritten); errCode != s3err.ErrNone { t.Fatalf("signed request rejected after path rewrite: %d", errCode) } } // TestSignatureV4WithForwardedPrefixUnicodeKey guards against a signature // mismatch when an object key contains non-ASCII characters and the request is // received through a trusted reverse proxy that strips a path prefix. The // client signs the single percent-encoded path including the prefix, so the // server must decode the forwarded (already encoded) path before re-encoding it // together with the prefix. func TestSignatureV4WithForwardedPrefixUnicodeKey(t *testing.T) { iam := newTestIAMWithTrustedProxy() bucket := "test-bucket" key := "たあいまd·.jpg" // The proxy strips the X-Forwarded-Prefix and forwards the percent-encoded // object path (the server sees the encoded form, not the raw Unicode key). r, err := newTestRequest("GET", "https://example.com/"+bucket+"/"+url.PathEscape(key), 0, nil) if err != nil { t.Fatalf("failed to create test request: %v", err) } r.Header.Set("X-Forwarded-Prefix", "/s3") r.Header.Set("Host", "example.com") r.RemoteAddr = "10.0.0.1:1234" // The client signs the full URL path including the prefix, using the single // percent-encoded form of the Unicode key. signV4WithPath(r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "/s3/"+bucket+"/"+key) if _, _, errCode := iam.doesSignatureMatch(r); errCode != s3err.ErrNone { t.Fatalf("signature verification failed for unicode key behind forwarded prefix: code %d", int(errCode)) } }