package s3api import ( "net/http" "sort" "strconv" "strings" "testing" "time" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" ) func setupTestIAMForV2Auth() *IdentityAccessManagement { iam := &IdentityAccessManagement{ identities: []*Identity{}, accessKeyIdent: make(map[string]*Identity), } testCred := &Credential{ AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", } testIdentity := &Identity{ Name: "testUser", Account: &AccountAdmin, Credentials: []*Credential{testCred}, Actions: []Action{ s3_constants.ACTION_ADMIN, }, } iam.identities = append(iam.identities, testIdentity) iam.accessKeyIdent[testCred.AccessKey] = testIdentity return iam } func TestValidateV2AuthHeader(t *testing.T) { tests := []struct { name string authHeader string expectedAccessKey string expectedError s3err.ErrorCode }{ { name: "valid auth header with space", authHeader: "AWS AKIAIOSFODNN7EXAMPLE:frJIUN8DYpKDtOLCwo//yllqDzg=", expectedAccessKey: "AKIAIOSFODNN7EXAMPLE", expectedError: s3err.ErrNone, }, { name: "empty auth header", authHeader: "", expectedError: s3err.ErrAuthHeaderEmpty, }, { name: "wrong algorithm prefix", authHeader: "HMAC AKIAIOSFODNN7EXAMPLE:signature", expectedError: s3err.ErrSignatureVersionNotSupported, }, { name: "algorithm prefix without space", authHeader: "AWSXAKIAIOSFODNN7EXAMPLE:signature", expectedError: s3err.ErrSignatureVersionNotSupported, }, { name: "missing colon separator", authHeader: "AWS AKIAIOSFODNN7EXAMPLE", expectedError: s3err.ErrMissingFields, }, { name: "empty access key", authHeader: "AWS :signature", expectedError: s3err.ErrInvalidAccessKeyID, }, { name: "empty signature", authHeader: "AWS AKIAIOSFODNN7EXAMPLE:", expectedError: s3err.ErrMissingFields, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { accessKey, errCode := validateV2AuthHeader(tt.authHeader) if errCode != tt.expectedError { t.Errorf("validateV2AuthHeader() error = %v, want %v", errCode, tt.expectedError) } if errCode == s3err.ErrNone && accessKey != tt.expectedAccessKey { t.Errorf("validateV2AuthHeader() accessKey = %q, want %q", accessKey, tt.expectedAccessKey) } }) } } func TestSignatureV2Format(t *testing.T) { cred := &Credential{ AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", } headers := http.Header{} headers.Set("Date", "Mon, 09 Sep 2011 23:36:00 GMT") signature := signatureV2(cred, "GET", "/bucket/object", "", headers) // Verify format: "AWS :" with space after AWS expectedPrefix := "AWS " + cred.AccessKey + ":" if len(signature) < len(expectedPrefix) { t.Fatalf("Signature too short: %s", signature) } actualPrefix := signature[:len(expectedPrefix)] if actualPrefix != expectedPrefix { t.Errorf("Signature prefix = %q, want %q", actualPrefix, expectedPrefix) } } func TestDoesSignV2Match(t *testing.T) { iam := setupTestIAMForV2Auth() cred := &Credential{ AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", } tests := []struct { name string method string path string query string headers map[string]string authOverride string expectedError s3err.ErrorCode expectIdent bool }{ { name: "valid GET request", method: "GET", path: "/bucket/object", query: "", headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"}, expectedError: s3err.ErrNone, expectIdent: true, }, { name: "valid PUT request with content headers", method: "PUT", path: "/bucket/object", query: "", headers: map[string]string{ "Date": "Mon, 09 Sep 2011 23:36:00 GMT", "Content-Type": "text/plain", "Content-Md5": "c8fdb181845a4ca6b8fec737b3581d76", }, expectedError: s3err.ErrNone, expectIdent: true, }, { name: "request with query parameters", method: "GET", path: "/bucket/object", query: "acl&versionId=123", headers: map[string]string{ "Date": "Mon, 09 Sep 2011 23:36:00 GMT", }, expectedError: s3err.ErrNone, expectIdent: true, }, { name: "request with x-amz headers", method: "PUT", path: "/bucket/object", query: "", headers: map[string]string{ "Date": "Mon, 09 Sep 2011 23:36:00 GMT", "x-amz-storage-class": "REDUCED_REDUNDANCY", "x-amz-meta-custom": "value", }, expectedError: s3err.ErrNone, expectIdent: true, }, { name: "invalid signature", method: "GET", path: "/bucket/object", query: "", headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"}, authOverride: "AWS AKIAIOSFODNN7EXAMPLE:invalidSignature123456==", expectedError: s3err.ErrSignatureDoesNotMatch, expectIdent: false, }, { name: "non-existent access key", method: "GET", path: "/bucket/object", query: "", headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"}, authOverride: "AWS NONEXISTENTKEY:signature==", expectedError: s3err.ErrInvalidAccessKeyID, expectIdent: false, }, { name: "empty authorization header", method: "GET", path: "/bucket/object", query: "", headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"}, authOverride: "", expectedError: s3err.ErrAuthHeaderEmpty, expectIdent: false, }, { name: "malformed auth - missing signature", method: "GET", path: "/bucket/object", query: "", headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"}, authOverride: "AWS AKIAIOSFODNN7EXAMPLE", expectedError: s3err.ErrMissingFields, expectIdent: false, }, { name: "malformed auth - wrong prefix", method: "GET", path: "/bucket/object", query: "", headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"}, authOverride: "HMAC AKIAIOSFODNN7EXAMPLE:sig", expectedError: s3err.ErrSignatureVersionNotSupported, expectIdent: false, }, { name: "malformed auth - no space after AWS", method: "GET", path: "/bucket/object", query: "", headers: map[string]string{"Date": "Mon, 09 Sep 2011 23:36:00 GMT"}, authOverride: "AWSAKIAIOSFODNN7EXAMPLE:signature==", expectedError: s3err.ErrSignatureVersionNotSupported, expectIdent: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { url := "http://example.com" + tt.path if tt.query != "" { url += "?" + tt.query } req, err := http.NewRequest(tt.method, url, nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } for key, value := range tt.headers { req.Header.Set(key, value) } var authHeader string if tt.authOverride != "" { authHeader = tt.authOverride } else { authHeader = signatureV2(cred, req.Method, req.URL.Path, req.URL.Query().Encode(), req.Header) } if tt.name != "empty authorization header" { req.Header.Set("Authorization", authHeader) } identity, errCode := iam.doesSignV2Match(req) if errCode != tt.expectedError { t.Errorf("doesSignV2Match() error = %v, want %v", errCode, tt.expectedError) } if tt.expectIdent && identity == nil { t.Error("Expected non-nil identity") } if !tt.expectIdent && identity != nil { t.Error("Expected nil identity") } if identity != nil && identity.Name != "testUser" { t.Errorf("Identity name = %q, want %q", identity.Name, "testUser") } }) } } func TestDoesSignV2MatchUnicodePath(t *testing.T) { iam := setupTestIAMForV2Auth() cred := &Credential{ AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", } tests := []struct { name string path string }{ { name: "CJK conversation key from ai-dial-core", path: "/dialcore/core/Users/test-user/conversations/qwen3.5-35b-a3b__%E5%A4%9A%E7%9D%87%E3%80%82", }, { name: "Japanese filename from issue 8598", path: "/voyage-dev1/public/1773200112088-998898991/%E3%81%9F%E3%81%82%E3%81%84%E3%81%BEd%C2%B7.jpg", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.path, nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Date", "Mon, 09 Sep 2011 23:36:00 GMT") // Clients (jclouds, AWS SDK) sign percent-encoded paths, not decoded Path. authHeader := signatureV2(cred, req.Method, tt.path, req.URL.Query().Encode(), req.Header) req.Header.Set("Authorization", authHeader) identity, errCode := iam.doesSignV2Match(req) if errCode != s3err.ErrNone { t.Fatalf("doesSignV2Match() error = %v, want %v; path=%q escaped=%q", errCode, s3err.ErrNone, req.URL.Path, req.URL.EscapedPath()) } if identity == nil { t.Fatal("Expected non-nil identity") } }) } } func TestDoesPresignV2MatchUnicodePath(t *testing.T) { iam := setupTestIAMForV2Auth() cred := &Credential{ AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", } path := "/dialcore/core/Users/test-user/conversations/qwen3.5-35b-a3b__%E5%A4%9A%E7%9D%87%E3%80%82" expires := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) req, err := http.NewRequest(http.MethodGet, "http://example.com"+path, nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } query := req.URL.Query() query.Set("AWSAccessKeyId", cred.AccessKey) query.Set("Expires", expires) query.Set("Signature", preSignatureV2(cred, req.Method, path, query.Encode(), req.Header, expires)) req.URL.RawQuery = query.Encode() identity, errCode := iam.doesPresignV2SignatureMatch(req) if errCode != s3err.ErrNone { t.Fatalf("doesPresignV2SignatureMatch() error = %v, want %v; path=%q escaped=%q", errCode, s3err.ErrNone, req.URL.Path, req.URL.EscapedPath()) } if identity == nil { t.Fatal("Expected non-nil identity") } } func presignV2ForTest(t *testing.T, cred *Credential, method, path, signedQuery string) *http.Request { t.Helper() req, err := http.NewRequest(method, "http://example.com"+path, nil) if err != nil { t.Fatalf("new request: %v", err) } req.URL.RawQuery = signedQuery expires := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) query := req.URL.Query() query.Set("AWSAccessKeyId", cred.AccessKey) query.Set("Expires", expires) query.Set("Signature", preSignatureV2(cred, method, req.URL.EscapedPath(), req.URL.Query().Encode(), req.Header, expires)) req.URL.RawQuery = query.Encode() return req } func TestSignatureV2CoversOperationSubresources(t *testing.T) { if !sort.StringsAreSorted(resourceList) { t.Fatal("resourceList must stay in byte order") } signed := make(map[string]bool, len(resourceList)) for _, key := range resourceList { signed[key] = true } // list-type picks ListObjectsV2 over V1, the same action, and V2 clients never sign it. for key := range operationSubresources { if key != "list-type" && !signed[key] { t.Errorf("operation subresource %q is not covered by signature V2", key) } } for key := range bucketQueryActions { if !signed[key] { t.Errorf("bucket subresource %q is not covered by signature V2", key) } } } func TestSignatureV2RejectsAppendedSubresource(t *testing.T) { iam := setupTestIAMForV2Auth() cred := &Credential{ AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", } if _, errCode := iam.doesPresignV2SignatureMatch(presignV2ForTest(t, cred, http.MethodPut, "/bucket/key", "")); errCode != s3err.ErrNone { t.Fatalf("presigned PUT as issued: %v", errCode) } for key := range operationSubresources { if key == "list-type" { continue } t.Run("presigned_"+key, func(t *testing.T) { req := presignV2ForTest(t, cred, http.MethodPut, "/bucket/key", "") req.URL.RawQuery += "&" + key if _, errCode := iam.doesPresignV2SignatureMatch(req); errCode != s3err.ErrSignatureDoesNotMatch { t.Fatalf("presigned PUT with unsigned %q: got %v, want %v", key, errCode, s3err.ErrSignatureDoesNotMatch) } }) t.Run("header_"+key, func(t *testing.T) { req, err := http.NewRequest(http.MethodPut, "http://example.com/bucket/key", nil) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) req.Header.Set("Authorization", signatureV2(cred, req.Method, req.URL.EscapedPath(), req.URL.Query().Encode(), req.Header)) req.URL.RawQuery = key if _, errCode := iam.doesSignV2Match(req); errCode != s3err.ErrSignatureDoesNotMatch { t.Fatalf("signed PUT with unsigned %q: got %v, want %v", key, errCode, s3err.ErrSignatureDoesNotMatch) } }) } } func TestPresignV2AcceptsClientSignedSubresource(t *testing.T) { iam := setupTestIAMForV2Auth() cred := &Credential{ AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", } expires := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) tests := []struct { method, rawQuery, resource string }{ {http.MethodPut, "tagging", "/bucket/key?tagging"}, {http.MethodGet, "tagging&versionId=v1", "/bucket/key?tagging&versionId=v1"}, {http.MethodGet, "cors", "/bucket?cors"}, {http.MethodPut, "retention", "/bucket/key?retention"}, } for _, tt := range tests { t.Run(tt.method+"_"+tt.rawQuery, func(t *testing.T) { // The string to sign a V2 client builds: verb, MD5, type, expiry, resource. signature := calculateSignatureV2(tt.method+"\n\n\n"+expires+"\n"+tt.resource, cred.SecretKey) path := tt.resource[:strings.Index(tt.resource, "?")] req, err := http.NewRequest(tt.method, "http://example.com"+path+"?"+tt.rawQuery, nil) if err != nil { t.Fatalf("new request: %v", err) } query := req.URL.Query() query.Set("AWSAccessKeyId", cred.AccessKey) query.Set("Expires", expires) query.Set("Signature", signature) req.URL.RawQuery = query.Encode() if _, errCode := iam.doesPresignV2SignatureMatch(req); errCode != s3err.ErrNone { t.Fatalf("client-signed %s: got %v", tt.resource, errCode) } }) } } func TestSignatureV2RejectsRepeatedSubresource(t *testing.T) { iam := setupTestIAMForV2Auth() cred := &Credential{ AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", } if _, errCode := iam.doesPresignV2SignatureMatch(presignV2ForTest(t, cred, http.MethodPut, "/bucket/key", "retention&versionId=v1")); errCode != s3err.ErrNone { t.Fatalf("presigned PUT as issued: %v", errCode) } for name, edit := range map[string]func(string) string{ "prepended": func(q string) string { return "versionId=v2&" + q }, "appended": func(q string) string { return q + "&versionId=v2" }, } { t.Run("presigned_"+name, func(t *testing.T) { req := presignV2ForTest(t, cred, http.MethodPut, "/bucket/key", "retention&versionId=v1") req.URL.RawQuery = edit(req.URL.RawQuery) if _, errCode := iam.doesPresignV2SignatureMatch(req); errCode != s3err.ErrSignatureDoesNotMatch { t.Fatalf("presigned PUT with a second versionId: got %v, want %v", errCode, s3err.ErrSignatureDoesNotMatch) } }) t.Run("header_"+name, func(t *testing.T) { req, err := http.NewRequest(http.MethodPut, "http://example.com/bucket/key?retention&versionId=v1", nil) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) req.Header.Set("Authorization", signatureV2(cred, req.Method, req.URL.EscapedPath(), req.URL.Query().Encode(), req.Header)) if _, errCode := iam.doesSignV2Match(req); errCode != s3err.ErrNone { t.Fatalf("signed PUT as issued: %v", errCode) } req.URL.RawQuery = edit(req.URL.RawQuery) if _, errCode := iam.doesSignV2Match(req); errCode != s3err.ErrSignatureDoesNotMatch { t.Fatalf("signed PUT with a second versionId: got %v, want %v", errCode, s3err.ErrSignatureDoesNotMatch) } }) } }