Files
seaweedfs/weed/s3api/auth_signature_v4_test.go
T
7y-9andCodex 7589429b43 fix(s3api): sort repeated SigV4 query values (#10031)
* fix(s3api): sort repeated SigV4 query values

Problem
SigV4 canonical query strings must sort repeated parameter values. SeaweedFS preserved the incoming value order, so a correctly signed request with repeated query parameters could fail verification when the request order differed from canonical order.

Root cause
getCanonicalQueryString delegated directly to url.Values.Encode(), which sorts parameter names but preserves each key's value slice order.

Fix
Sort every query value slice before encoding the canonical query string, while still excluding X-Amz-Signature for presigned URLs.

Reproduction
go test ./weed/s3api -run TestGetCanonicalQueryStringSortsRepeatedValues -count=1 failed before the fix with partNumber=2 before partNumber=10.

Co-authored-by: Codex <noreply@openai.com>

* test(s3api): expand canonical query coverage

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-06-22 02:00:27 -07:00

560 lines
16 KiB
Go

package s3api
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"testing"
"time"
"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)
}
})
}
}
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)
}
})
}
}