Duplicate Part Numbers in CompleteMultipartUpload
In CompleteMultipartUpload, the S3 API requires part numbers in the completion XML to be strictly ascending. Up through Server 20260725, SILO enforced that invariant with sort.SliceIsSorted and a strict < comparator—which accepts non-decreasing sequences such as [1, 1] and duplicates the referenced part in the assembled object. Server 20260804 (pgsty/silo#49) replaces that check with an explicit adjacent-pair comparison (Parts[i-1].PartNumber >= Parts[i].PartNumber) and fixes an adjacent empty-part panic in ReadParts (2602177ef).
Conclusions first
- The bug is real and small:
CompleteMultipartUploadusedsort.SliceIsSortedwith<, which tests non-decreasing order (a[0] <= a[1] <= ...) rather than strictly increasing order (a[0] < a[1] < ...). Passing duplicate part numbers such as[1, 1]succeeded and duplicated the underlying part in the final object. - This is a data-integrity defect, not a CVE: Triggering it requires a caller who already holds
s3:PutObjectpermission on the target key and a validuploadIdfor an in-progress multipart upload. It does not cross tenant, bucket, or credential boundaries. - The fix stays at the HTTP handler boundary:
cmd/object-handlers.gorejectsParts[i-1].PartNumber >= Parts[i].PartNumberwithInvalidPartOrder(HTTP 400). Sparse part numbers ([1, 3],[5, 9],[3]) remain valid.
Why sort.SliceIsSorted Accepted Duplicates
In cmd/object-handlers.go, CompleteMultipartUploadHandler validated the parsed <CompleteMultipartUpload> part list with:
In Go’s standard library, sort.SliceIsSorted iterates i from n-1 down to 1 and calls less(i, i-1). If less(i, i-1) returns true, the slice is out of order. When two adjacent elements are equal (Parts[i].PartNumber == Parts[i-1].PartNumber), Parts[i].PartNumber < Parts[i-1].PartNumber evaluates to false, so SliceIsSorted treats them as sorted.
Replacing sort.SliceIsSorted with a direct adjacent check closes the gap:
Two Refactors Preserved the Defect
Tracing git log -S across the handler history shows that the non-decreasing check survived two separate cleanups over nine years:
| Date | PR / Commit | What Changed | Why [1, 1] Survived |
|---|---|---|---|
| 2016-08 | minio/minio#2490 |
Introduced sort.IsSorted(completedParts(complMultipartUpload.Parts)) with Less(i, j) { return a[i].PartNumber < a[j].PartNumber }. |
sort.IsSorted only checks that no element is smaller than its predecessor. |
| 2017-11 | minio/minio#5170 |
Replaced the named completedParts type with sort.SliceIsSorted using the same < closure. |
Mechanical refactor of the same comparator. |
| 2023-04 | minio/minio#17066 |
Moved ErrInvalidPartOrder validation earlier in CompleteMultipartUploadHandler before hitting the object layer. |
Moved the check block verbatim without testing duplicate part numbers. |
Every unit and mint test for ErrInvalidPartOrder tested inversions such as [2, 1], never adjacent duplicates such as [1, 1].
Observed Impact
Before the fix, uploading a single 5 MiB part (partNumber=1) and completing the upload with two <Part> entries both referencing PartNumber=1 and its ETag produced the following behavior:
| Step | Request | Pre-Fix Result | Post-Fix Result |
|---|---|---|---|
| 1 | UploadPart(partNumber=1) (5 MiB) |
200 OK, returns ETag: "e1" |
200 OK, returns ETag: "e1" |
| 2 | CompleteMultipartUpload with [(1, "e1"), (1, "e1")] |
200 OK, ETag: "...-2" |
400 Bad Request (InvalidPartOrder) |
| 3 | HeadObject / GetObject |
Content-Length: 10485760 (part 1 repeated twice) |
Object not created; upload remains open |
| 4 | Retry CompleteMultipartUpload with [(1, "e1")] |
404 NoSuchUpload (upload already consumed) |
200 OK, Content-Length: 5242880 |
Because CompleteMultipartUpload consumes the upload state on success, a buggy client that accidentally duplicated an entry in its completion XML (for example, when retrying an UploadPart call and appending to an in-memory slice instead of deduplicating by part number) silently wrote a corrupted object of the wrong size and could not retry completion without re-uploading every part.
Why This Is Not a CVE
We evaluated whether a malicious caller could weaponize duplicate part numbers for storage amplification:
- No physical disk amplification: In erasure-coded storage,
CompleteMultipartUploadrecords part metadata inxl.metareferencing the existingpart.1data files on disk rather than copying part bytes per entry. Reading the object streamspart.1twice, and background healing or copying materializes the logical size, but the caller could achieve the same result legitimately by uploading two parts or callingUploadPartCopy. - Same-caller scope: Executing
CompleteMultipartUploadrequires write access to the object and knowledge of theuploadId. No unauthenticated or read-only principal can trigger it.
The issue is therefore tracked as a correctness and S3 protocol compliance bug (#49) rather than a security advisory (SN-2026-*).
Handler vs. Object-Layer Boundary
During investigation we checked whether erasureObjects.CompleteMultipartUpload (cmd/erasure-multipart.go) also validates part ordering. It does not: the object layer iterates for i, part := range parts and matches each entry against uploadID part metadata.
We kept the check in CompleteMultipartUploadHandler (cmd/object-handlers.go) rather than duplicating it inside every ObjectLayer implementation for two reasons:
- Single untrusted entry point: Client-supplied completion lists enter only through
CompleteMultipartUploadHandler. Internal callers (batch-replicate,restore,decommission,rebalance, andPutObjectPartpathways) construct part lists directly from existingObjectInfo.Partsor sequential1..nloops. - Fail-fast before lock acquisition: Rejecting malformed XML in the HTTP handler avoids acquiring the object’s namespace lock or reading upload metadata from disk.
Preserving Sparse Part Numbers
The S3 specification requires part numbers in CompleteMultipartUpload to be strictly increasing (1 <= PartNumber <= 10,000), not contiguous and not starting at 1. A client that uploads parts 1, 2, 3 and completes using only [1, 3], or completes using [5, 9] or [3], is conforming to S3.
To make sure our fix did not over-constrain valid clients, we added explicit regression cases for:
[1, 3](sparse gap),[3](single part not starting at1),[1, 1]and[1, 2, 2, 3](adjacent duplicates, rejected withErrInvalidPartOrder),[1, 3, 2]and[2, 1](decreasing order, rejected withErrInvalidPartOrder),[1, 2, 1](non-adjacent duplicate, rejected withErrInvalidPartOrder).
Error Code Precedence for Out-of-Range Duplicates
One subtle edge case is an input that is both out of range (PartNumber < 1) and duplicated, such as [0, 0] or [-1, -1].
Because CompleteMultipartUploadHandler validates part ordering before iterating into the object layer (where individual part numbers are looked up and ErrInvalidPart is returned), inputs like [0, 0] previously passed sort.SliceIsSorted and failed later in the object layer with InvalidPart (HTTP 400). With strict ordering enforced up front, [0, 0] and [-1, -1] now fail immediately at the handler with InvalidPartOrder (HTTP 400). Both error codes map to HTTP 400 Bad Request and neither is retriable; a single out-of-range part ([0] or [-1]) still reaches the part lookup and returns InvalidPart.
Mutation Testing and a Misleading Comment
While verifying the unit tests in cmd/object-handlers-multipart_test.go, we ran two manual mutations against the handler:
- Mutating the check to require
Parts[0].PartNumber == 1: Immediately caught by the sparse-part regression tests ([3]and[5, 9]). - Deleting the
len(complMultipartUpload.Parts) == 0guard: Our draft test comment originally claimed that removing the empty-parts guard in the handler would allow an empty multipart completion to succeed. Running that mutation revealed something much more interesting: an emptyPartsslice bypassed the handler check, entered the object layer, and triggered an index-out-of-range panic inReadParts(cmd/storage-rest-client.go) inside an errgroup goroutine.
That second mutation served two purposes: we corrected the misleading comment in cmd/object-handlers-multipart_test.go, and we fixed ReadParts in commit 2602177ef (also shipped in Server 20260804) so that an empty partNumbers slice returns early instead of indexing partNumbers[0].
Declined Changes and Follow-Ups
- No retrospective repair tool: Existing objects assembled from duplicate part lists before
20260804have valid erasure metadata for their logical size; automatic truncation or deduplication would risk corrupting objects intentionally assembled from identical chunks. - No duplicate object-layer check: As noted in Handler vs. Object-Layer Boundary, internal callers construct part lists from validated metadata, and
2602177efcloses theReadPartsempty-slice boundary found during mutation testing.
Review Record
Three independent review passes verified the handler predicate, error-code precedence (InvalidPartOrder vs. InvalidPart), sparse part numbering ([3] and [5, 9]), and the ReadParts empty-slice guard (2602177ef).
Summary
Shipped in Server 20260804 (5df7b5466 and 2602177ef), CompleteMultipartUploadHandler enforces strict part-number monotonicity (PartNumber[i-1] < PartNumber[i]) and ReadParts safely rejects empty part-number slices.