Skip to content

Commit 7c10338

Browse files
committed
fix: reject unsupported checksum assertions
Reject unimplemented x-amz-checksum value and trailer names instead of accepting uploads without verification. Apply the same contract to PutObject, multipart initiation and parts, CopyObject, and UploadPartCopy while preserving the five supported algorithms. Signed-off-by: Feng Ruohang <rh@vonng.com>
1 parent 04d3d31 commit 7c10338

5 files changed

Lines changed: 227 additions & 9 deletions

File tree

cmd/object-api-options.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,9 @@ func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[strin
439439

440440
// get ObjectOptions for Copy calls with encryption headers provided on the target side and source side metadata
441441
func copyDstOpts(ctx context.Context, r *http.Request, bucket, object string, metadata map[string]string) (opts ObjectOptions, err error) {
442+
if _, err := hash.GetContentChecksum(r.Header); err != nil {
443+
return opts, err
444+
}
442445
return putOptsFromReq(ctx, r, bucket, object, metadata)
443446
}
444447

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (c) 2015-2026 MinIO, Inc.
2+
//
3+
// This file is part of MinIO Object Storage stack
4+
//
5+
// This program is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU Affero General Public License as published by
7+
// the Free Software Foundation, either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// This program is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU Affero General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU Affero General Public License
16+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
18+
package cmd
19+
20+
import (
21+
"bytes"
22+
"encoding/base64"
23+
"encoding/xml"
24+
"net/http"
25+
"net/http/httptest"
26+
"strings"
27+
"testing"
28+
29+
"github.com/minio/minio/internal/auth"
30+
xhttp "github.com/minio/minio/internal/http"
31+
)
32+
33+
func TestAPIRejectsUnsupportedChecksumHeaders(t *testing.T) {
34+
defer DetectTestLeak(t)()
35+
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
36+
t: t,
37+
objAPITest: testAPIRejectsUnsupportedChecksumHeaders,
38+
endpoints: []string{"CopyObject", "NewMultipart", "PutObject", "PutObjectPart"},
39+
})
40+
}
41+
42+
func testAPIRejectsUnsupportedChecksumHeaders(obj ObjectLayer, instanceType, bucketName string,
43+
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
44+
) {
45+
data := []byte("unsupported-checksum")
46+
unsupportedValue := base64.StdEncoding.EncodeToString(make([]byte, 64))
47+
48+
put := func(object string, headers map[string]string) *httptest.ResponseRecorder {
49+
t.Helper()
50+
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object),
51+
int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey, headers)
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
rec := httptest.NewRecorder()
56+
apiRouter.ServeHTTP(rec, req)
57+
return rec
58+
}
59+
assertRejected := func(name string, rec *httptest.ResponseRecorder) {
60+
t.Helper()
61+
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "<Code>InvalidArgument</Code>") {
62+
t.Fatalf("%s: %s returned %d, want InvalidArgument: %s", instanceType, name, rec.Code, rec.Body.String())
63+
}
64+
}
65+
66+
for _, algorithm := range []string{"md5", "sha512", "xxhash64", "xxhash3", "xxhash128", "future"} {
67+
object := "checksums/unsupported-" + algorithm
68+
assertRejected(algorithm, put(object, map[string]string{
69+
"x-amz-sdk-checksum-algorithm": "SHA512",
70+
"x-amz-checksum-" + algorithm: unsupportedValue,
71+
}))
72+
if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); !isErrObjectNotFound(err) {
73+
t.Fatalf("%s: rejected %s checksum stored an object: %v", instanceType, algorithm, err)
74+
}
75+
}
76+
77+
assertRejected("unsupported trailer", put("checksums/unsupported-trailer", map[string]string{
78+
xhttp.AmzTrailer: "x-amz-checksum-sha512",
79+
}))
80+
81+
newMultipart := func(name string, headers map[string]string) *httptest.ResponseRecorder {
82+
t.Helper()
83+
req, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, name),
84+
0, nil, credentials.AccessKey, credentials.SecretKey, headers)
85+
if err != nil {
86+
t.Fatal(err)
87+
}
88+
rec := httptest.NewRecorder()
89+
apiRouter.ServeHTTP(rec, req)
90+
return rec
91+
}
92+
assertRejected("NewMultipartUpload value header", newMultipart("checksums/mp-value", map[string]string{
93+
"x-amz-checksum-sha512": unsupportedValue,
94+
}))
95+
assertRejected("NewMultipartUpload trailer", newMultipart("checksums/mp-trailer", map[string]string{
96+
xhttp.AmzTrailer: "x-amz-checksum-sha512",
97+
}))
98+
99+
rec := newMultipart("checksums/mp-part", nil)
100+
if rec.Code != http.StatusOK {
101+
t.Fatalf("%s: NewMultipartUpload setup returned %d: %s", instanceType, rec.Code, rec.Body.String())
102+
}
103+
var initiated InitiateMultipartUploadResponse
104+
if err := xml.Unmarshal(rec.Body.Bytes(), &initiated); err != nil {
105+
t.Fatal(err)
106+
}
107+
req, err := newTestSignedRequestV4(http.MethodPut,
108+
getPutObjectPartURL("", bucketName, "checksums/mp-part", initiated.UploadID, "1"),
109+
int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey,
110+
map[string]string{"x-amz-checksum-sha512": unsupportedValue})
111+
if err != nil {
112+
t.Fatal(err)
113+
}
114+
rec = httptest.NewRecorder()
115+
apiRouter.ServeHTTP(rec, req)
116+
assertRejected("UploadPart", rec)
117+
parts, err := obj.ListObjectParts(t.Context(), bucketName, "checksums/mp-part", initiated.UploadID, 0, 1000, ObjectOptions{})
118+
if err != nil {
119+
t.Fatal(err)
120+
}
121+
if len(parts.Parts) != 0 {
122+
t.Fatalf("%s: rejected UploadPart stored %d parts", instanceType, len(parts.Parts))
123+
}
124+
if err := obj.AbortMultipartUpload(t.Context(), bucketName, "checksums/mp-part", initiated.UploadID, ObjectOptions{}); err != nil {
125+
t.Fatal(err)
126+
}
127+
128+
source := "checksums/source"
129+
putCopyChecksumSource(t, apiRouter, credentials, bucketName, source, data, nil)
130+
rec = copyChecksumRequest(t, apiRouter, credentials, bucketName, source, "checksums/copy", map[string]string{
131+
"x-amz-checksum-sha512": unsupportedValue,
132+
})
133+
assertRejected("CopyObject", rec)
134+
if _, err := obj.GetObjectInfo(t.Context(), bucketName, "checksums/copy", ObjectOptions{}); !isErrObjectNotFound(err) {
135+
t.Fatalf("%s: rejected CopyObject stored a destination: %v", instanceType, err)
136+
}
137+
}

cmd/object-multipart-handlers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,10 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r
309309
}
310310
}
311311

312+
if _, err := hash.GetContentChecksum(r.Header); err != nil {
313+
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidChecksum), r.URL)
314+
return
315+
}
312316
checksumType := hash.NewChecksumHeader(r.Header)
313317
if checksumType.Is(hash.ChecksumInvalid) {
314318
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidChecksum), r.URL)

internal/hash/checksum.go

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -657,22 +657,55 @@ func AddChecksumHeader(w http.ResponseWriter, c map[string]string) {
657657
}
658658
}
659659

660+
func isSupportedChecksumHeader(name string) bool {
661+
switch {
662+
case strings.EqualFold(name, xhttp.AmzChecksumAlgo),
663+
strings.EqualFold(name, xhttp.AmzChecksumType),
664+
strings.EqualFold(name, xhttp.AmzChecksumMode):
665+
return true
666+
}
667+
for _, checksumType := range BaseChecksumTypes {
668+
if strings.EqualFold(name, checksumType.Key()) {
669+
return true
670+
}
671+
}
672+
return false
673+
}
674+
675+
func hasUnsupportedChecksumHeader(h http.Header) bool {
676+
for name := range h {
677+
if strings.HasPrefix(strings.ToLower(name), "x-amz-checksum-") && !isSupportedChecksumHeader(name) {
678+
return true
679+
}
680+
}
681+
return false
682+
}
683+
660684
// GetContentChecksum returns content checksum.
661685
// Returns ErrInvalidChecksum if so.
662686
// Returns nil, nil if no checksum.
663687
func GetContentChecksum(h http.Header) (*Checksum, error) {
688+
if hasUnsupportedChecksumHeader(h) {
689+
return nil, ErrInvalidChecksum
690+
}
664691
if trailing := h.Values(xhttp.AmzTrailer); len(trailing) > 0 {
665692
var res *Checksum
666-
for _, header := range trailing {
667-
var duplicates bool
668-
for _, t := range BaseChecksumTypes {
669-
if strings.EqualFold(t.Key(), header) {
670-
duplicates = res != nil
671-
res = NewChecksumWithType(t|ChecksumTrailing, "")
693+
for _, headers := range trailing {
694+
for header := range strings.SplitSeq(headers, ",") {
695+
header = strings.TrimSpace(header)
696+
var duplicates bool
697+
for _, t := range BaseChecksumTypes {
698+
if strings.EqualFold(t.Key(), header) {
699+
duplicates = res != nil
700+
res = NewChecksumWithType(t|ChecksumTrailing, "")
701+
}
702+
}
703+
if strings.HasPrefix(strings.ToLower(header), "x-amz-checksum-") && !isSupportedChecksumHeader(header) {
704+
return nil, ErrInvalidChecksum
705+
}
706+
if duplicates {
707+
return nil, ErrInvalidChecksum
672708
}
673-
}
674-
if duplicates {
675-
return nil, ErrInvalidChecksum
676709
}
677710
}
678711
if res != nil {

internal/hash/checksum_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,53 @@
1818
package hash
1919

2020
import (
21+
"errors"
22+
"net/http"
2123
"net/http/httptest"
2224
"testing"
2325

2426
xhttp "github.com/minio/minio/internal/http"
2527
)
2628

29+
func TestGetContentChecksumRejectsUnsupportedHeaders(t *testing.T) {
30+
unsupported := []string{
31+
"x-amz-checksum-md5",
32+
"x-amz-checksum-sha512",
33+
"x-amz-checksum-xxhash64",
34+
"x-amz-checksum-xxhash3",
35+
"x-amz-checksum-xxhash128",
36+
"x-amz-checksum-future",
37+
}
38+
for _, header := range unsupported {
39+
t.Run("header/"+header, func(t *testing.T) {
40+
h := http.Header{header: {"AA=="}}
41+
if _, err := GetContentChecksum(h); !errors.Is(err, ErrInvalidChecksum) {
42+
t.Fatalf("GetContentChecksum(%s) error = %v, want ErrInvalidChecksum", header, err)
43+
}
44+
})
45+
t.Run("trailer/"+header, func(t *testing.T) {
46+
h := http.Header{xhttp.AmzTrailer: {header}}
47+
if _, err := GetContentChecksum(h); !errors.Is(err, ErrInvalidChecksum) {
48+
t.Fatalf("GetContentChecksum(trailer %s) error = %v, want ErrInvalidChecksum", header, err)
49+
}
50+
})
51+
}
52+
53+
for header, value := range map[string]string{
54+
xhttp.AmzChecksumAlgo: "CRC32",
55+
xhttp.AmzChecksumType: xhttp.AmzChecksumTypeComposite,
56+
xhttp.AmzChecksumMode: "ENABLED",
57+
"x-amz-sdk-checksum-algorithm": "SHA512",
58+
} {
59+
t.Run("control/"+header, func(t *testing.T) {
60+
h := http.Header{header: {value}}
61+
if _, err := GetContentChecksum(h); errors.Is(err, ErrInvalidChecksum) {
62+
t.Fatalf("control header %s was rejected", header)
63+
}
64+
})
65+
}
66+
}
67+
2768
// TestChecksumAddToHeader tests that adding and retrieving a checksum on a header works
2869
func TestChecksumAddToHeader(t *testing.T) {
2970
tests := []struct {

0 commit comments

Comments
 (0)