fix: keep streaming trailers visible after replication headers are stripped

A request that does not earn replication trust continues with a clone whose
internal replication headers are removed. r.Clone copies the Trailer map, but
the streaming body reader created from the original request fills the
original map, so a trailing checksum was never seen by the hash reader and
PutObject and UploadPart with STREAMING-UNSIGNED-PAYLOAD-TRAILER failed with
XAmzContentChecksumMismatch whenever an untrusted X-Minio-Source-* header was
present. Share the trailer map with the clone, as the Snowball path already
does for its per-entry requests, and cover both handlers with a test.

The marker evaluation that was copied into six handlers now lives in
evaluateReplicationTrust so the rule (a declared replica without the
replication permission is rejected; trust needs the exact marker plus the
permission) is defined once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PvgysXDmhPBBimCReYtA8q
Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-09-02 14:06:56 +08:00
parent 6586fbfd0d
commit 76195f1c68
4 changed files with 95 additions and 59 deletions
+54
View File
@@ -830,3 +830,57 @@ func testAPISSECMultipartReplicationTrust(obj ObjectLayer, instanceType, bucketN
t.Fatal("replicated SSE-C multipart object did not decrypt to source plaintext")
}
}
// TestAPIStreamingTrailerWithUntrustedReplicationHeaders verifies that a
// request which does not earn replication trust is still processed as an
// ordinary upload. The streaming body reader fills the original request's
// trailer while the handler continues with a header-stripped clone, so the
// trailing checksum must remain visible through that clone.
func TestAPIStreamingTrailerWithUntrustedReplicationHeaders(t *testing.T) {
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testAPIStreamingTrailerWithUntrustedReplicationHeaders})
}
func testAPIStreamingTrailerWithUntrustedReplicationHeaders(obj ObjectLayer, instanceType, bucketName string, apiRouter http.Handler, _ auth.Credentials, t *testing.T) {
putOnly := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject"`)
payload := bytes.Repeat([]byte("trailer probe "), 4096)
send := func(targetURL string) *httptest.ResponseRecorder {
req, err := newStreamingUnsignedTrailerRequest(http.MethodPut, targetURL, payload, UTCNow())
if err != nil {
t.Fatal(err)
}
req.Header.Set(xhttp.MinIOSourceReplicationRequest, "true")
req.Header.Set(xhttp.MinIOSourceETag, "forged-etag")
if err := signRequestV4(req, putOnly.AccessKey, putOnly.SecretKey); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
return rec
}
if rec := send(getPutObjectURL("", bucketName, "trailer-object")); rec.Code != http.StatusOK {
t.Fatalf("%s: PutObject status %d: %s", instanceType, rec.Code, rec.Body.String())
}
info, err := obj.GetObjectInfo(t.Context(), bucketName, "trailer-object", ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if info.Size != int64(len(payload)) || info.ETag == "forged-etag" {
t.Fatalf("%s: stored size %d etag %q, want %d bytes with a computed etag", instanceType, info.Size, info.ETag, len(payload))
}
upload, err := obj.NewMultipartUpload(t.Context(), bucketName, "trailer-multipart", ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if rec := send(getPutObjectPartURL("", bucketName, "trailer-multipart", upload.UploadID, "1")); rec.Code != http.StatusOK {
t.Fatalf("%s: PutObjectPart status %d: %s", instanceType, rec.Code, rec.Body.String())
}
parts, err := obj.ListObjectParts(t.Context(), bucketName, "trailer-multipart", upload.UploadID, 0, 10, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if len(parts.Parts) != 1 || parts.Parts[0].Size != int64(len(payload)) {
t.Fatalf("%s: uploaded parts %+v, want one part of %d bytes", instanceType, parts.Parts, len(payload))
}
}