diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go index 79e19c33a..799962f71 100644 --- a/weed/s3api/auth_signature_v4.go +++ b/weed/s3api/auth_signature_v4.go @@ -599,15 +599,14 @@ func extractV4AuthInfoFromQuery(r *http.Request) (*v4AuthInfo, s3err.ErrorCode) } func getCanonicalQueryString(r *http.Request, isPresigned bool) string { - var queryToEncode string - if !isPresigned { - queryToEncode = r.URL.Query().Encode() - } else { - queryForCanonical := r.URL.Query() + queryForCanonical := r.URL.Query() + if isPresigned { queryForCanonical.Del("X-Amz-Signature") - queryToEncode = queryForCanonical.Encode() } - return queryToEncode + for key := range queryForCanonical { + sort.Strings(queryForCanonical[key]) + } + return queryForCanonical.Encode() } func checkPresignedRequestExpiry(r *http.Request, t time.Time) s3err.ErrorCode { diff --git a/weed/s3api/auth_signature_v4_test.go b/weed/s3api/auth_signature_v4_test.go index a13740a8b..0fc65f3ee 100644 --- a/weed/s3api/auth_signature_v4_test.go +++ b/weed/s3api/auth_signature_v4_test.go @@ -111,6 +111,56 @@ func TestExtractV4AuthInfoFromQueryRejectsEmptySignedHeaderNames(t *testing.T) { } } +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