diff --git a/cmd/auth-handler.go b/cmd/auth-handler.go index 884fa9c83..fa35d9058 100644 --- a/cmd/auth-handler.go +++ b/cmd/auth-handler.go @@ -632,18 +632,11 @@ func isReqAuthenticated(ctx context.Context, r *http.Request, region string, sty return ErrInvalidDigest } - // Extract either 'X-Amz-Content-Sha256' header or 'X-Amz-Content-Sha256' query parameter (if V4 presigned) - // Do not verify 'X-Amz-Content-Sha256' if skipSHA256. + // Honor the selected header/query checksum, including the header fallback + // for a presigned request. STS separately hashes its body for its signature. var contentSHA256 []byte - if skipSHA256 := skipContentSha256Cksum(r); !skipSHA256 && isRequestPresignedSignatureV4(r) { - if sha256Sum, ok := r.Form[xhttp.AmzContentSha256]; ok && len(sha256Sum) > 0 { - contentSHA256, err = hex.DecodeString(sha256Sum[0]) - if err != nil { - return ErrContentSHA256Mismatch - } - } - } else if _, ok := r.Header[xhttp.AmzContentSha256]; !skipSHA256 && ok { - contentSHA256, err = hex.DecodeString(r.Header.Get(xhttp.AmzContentSha256)) + if !skipContentSha256Cksum(r) { + contentSHA256, err = hex.DecodeString(getContentSha256Cksum(r, serviceS3)) if err != nil || len(contentSHA256) == 0 { return ErrContentSHA256Mismatch } diff --git a/cmd/presigned-payload_test.go b/cmd/presigned-payload_test.go new file mode 100644 index 000000000..ef39a0a84 --- /dev/null +++ b/cmd/presigned-payload_test.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 PGSTY +// SPDX-License-Identifier: AGPL-3.0-only + +package cmd + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/minio/minio/internal/auth" + xhttp "github.com/minio/minio/internal/http" +) + +func TestPresignedHeaderPayloadHashIsEnforced(t *testing.T) { + setupSignatureBoundaryTest(t) + digest := getSHA256Hash([]byte("expected")) + for _, tc := range []struct { + name, query, header string + unsigned bool + }{ + {name: "header only", header: digest}, + {name: "query only", query: digest}, + {name: "query wins", query: digest, header: getSHA256Hash([]byte("different"))}, + {name: "unsigned query wins", query: unsignedPayload, header: digest, unsigned: true}, + {name: "unsigned header", header: unsignedPayload, unsigned: true}, + {name: "absent", unsigned: true}, + } { + for _, body := range []string{"expected", "modified"} { + t.Run(tc.name+"/"+body, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPut, "http://minio.local/bucket/object", strings.NewReader(body)) + if tc.header != "" { + r.Header.Set(xhttp.AmzContentSha256, tc.header) + } + if tc.query != "" { + query := r.URL.Query() + query.Set(xhttp.AmzContentSha256, tc.query) + r.URL.RawQuery = query.Encode() + } + presignBoundaryRequest(t, r, UTCNow(), []string{"host"}, globalActiveCred) + if code := isReqAuthenticated(t.Context(), r, globalSite.Region(), serviceS3); code != ErrNone { + t.Fatal(niceError(code)) + } + _, err := io.ReadAll(r.Body) + wantMismatch := body == "modified" && !tc.unsigned + if wantMismatch { + if toAPIErrorCode(t.Context(), err) != ErrContentSHA256Mismatch { + t.Errorf("modified signed payload: got %v, want SHA-256 mismatch", err) + } + } else if err != nil { + t.Errorf("valid payload: %v", err) + } + }) + } + } +} + +func TestAPIPresignedBucketPolicyPayloadHash(t *testing.T) { + defer DetectTestLeak(t)() + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: func(objectAPI ObjectLayer, instanceType, bucket string, apiRouter http.Handler, credentials auth.Credentials, t *testing.T) { + server := httptest.NewServer(apiRouter) + defer server.Close() + client := server.Client() + client.Timeout = 10 * time.Second + expected := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, bucket) + modified := strings.Replace(expected, "s3:GetObject", "s3:PutObject", 1) + for _, tc := range []struct { + name, body string + status int + }{ + {"original", expected, http.StatusNoContent}, + {"modified", modified, http.StatusBadRequest}, + } { + t.Run(instanceType+"/"+tc.name, func(t *testing.T) { + r, err := http.NewRequestWithContext(t.Context(), http.MethodPut, server.URL+"/"+bucket+"?policy=", strings.NewReader(tc.body)) + if err != nil { + t.Fatal(err) + } + r.Header.Set(xhttp.AmzContentSha256, getSHA256Hash([]byte(expected))) + presignBoundaryRequest(t, r, UTCNow(), []string{"host"}, credentials) + response, err := client.Do(r) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != tc.status { + t.Fatalf("PutBucketPolicy: got %d %s, want %d", response.StatusCode, body, tc.status) + } + if tc.status == http.StatusBadRequest && !strings.Contains(string(body), "XAmzContentSHA256Mismatch") { + t.Fatalf("expected payload checksum rejection, got %s", body) + } + }) + } + meta, err := globalBucketMetadataSys.Get(bucket) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(meta.PolicyConfigJSON), "s3:PutObject") { + t.Fatal("tampered presigned request replaced the bucket policy") + } + }, + endpoints: []string{"PutBucketPolicy"}, + }) +}