Skip to content

Commit 0135dd4

Browse files
authored
[merge 10/27/25] validate individual part ranges/size in upload/download (#3202)
1 parent 6c0b0d7 commit 0135dd4

File tree

5 files changed

+168
-2
lines changed

5 files changed

+168
-2
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"id": "9cf590d7-3764-44d0-8d7d-959651964337",
3+
"type": "feature",
4+
"description": "Add durability checks to validate part count and range for upload/download. You can disable this with `DisableValidateParts` in upload/download options, though doing so is not recommended because it damages the durability posture of your application.",
5+
"modules": [
6+
"feature/s3/manager"
7+
]
8+
}

feature/s3/manager/download.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ type Downloader struct {
7777
// operation requests made by the downloader.
7878
ClientOptions []func(*s3.Options)
7979

80+
// By default, the downloader verifies that individual part ranges align
81+
// based on the configured part size.
82+
//
83+
// You can disable that with this flag, however, Amazon S3 recommends
84+
// against doing so because it damages the durability posture of object
85+
// downloads.
86+
DisableValidateParts bool
87+
8088
// Defines the buffer strategy used when downloading a part.
8189
//
8290
// If a WriterReadFromProvider is given the Download manager
@@ -404,6 +412,15 @@ func (d *downloader) tryDownloadChunk(params *s3.GetObjectInput, w io.Writer) (i
404412
if err != nil {
405413
return 0, err
406414
}
415+
416+
if !d.cfg.DisableValidateParts && params.Range != nil && resp.ContentRange != nil {
417+
expectStart, expectEnd := parseContentRange(*params.Range)
418+
actualStart, actualEnd := parseContentRange(*resp.ContentRange)
419+
if isRangeMismatch(expectStart, expectEnd, actualStart, actualEnd) {
420+
return 0, fmt.Errorf("invalid content range: expect %d-%d, got %d-%d", expectStart, expectEnd, actualStart, actualEnd)
421+
}
422+
}
423+
407424
d.setTotalBytes(resp) // Set total if not yet set.
408425
d.once.Do(func() {
409426
d.etag = aws.ToString(resp.ETag)
@@ -422,6 +439,46 @@ func (d *downloader) tryDownloadChunk(params *s3.GetObjectInput, w io.Writer) (i
422439
return n, nil
423440
}
424441

442+
func parseContentRange(v string) (int, int) {
443+
parts := strings.Split(v, "/") // chop the total off, if it's there
444+
445+
// we send "bytes=" but S3 appears to return "bytes ", handle both
446+
trimmed := strings.TrimPrefix(parts[0], "bytes ")
447+
trimmed = strings.TrimPrefix(trimmed, "bytes=")
448+
449+
parts = strings.Split(trimmed, "-")
450+
if len(parts) != 2 {
451+
return -1, -1
452+
}
453+
454+
start, err := strconv.Atoi(parts[0])
455+
if err != nil {
456+
return -1, -1
457+
}
458+
459+
end, err := strconv.Atoi(parts[1])
460+
if err != nil {
461+
return -1, -1
462+
}
463+
464+
return start, end
465+
}
466+
467+
func isRangeMismatch(expectStart, expectEnd, actualStart, actualEnd int) bool {
468+
if expectStart == -1 || expectEnd == -1 || actualStart == -1 || actualEnd == -1 {
469+
return false // we don't know, one of the ranges was missing or unparseable
470+
}
471+
472+
// for the final chunk (or the first chunk if it's smaller) we still
473+
// request a full chunk but we get back the actual final part of the
474+
// object, which will be smaller
475+
if expectStart == actualStart && actualEnd < expectEnd {
476+
return false
477+
}
478+
479+
return expectStart != actualStart || expectEnd != actualEnd
480+
}
481+
425482
// getTotalBytes is a thread-safe getter for retrieving the total byte status.
426483
func (d *downloader) getTotalBytes() int64 {
427484
d.m.Lock()

feature/s3/manager/download_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,30 @@ func newDownloadNonRangeClient(data []byte) (*downloadCaptureClient, *int) {
9494
return capture, &capture.GetObjectInvocations
9595
}
9696

97+
func newDownloadBadRangeClient(data []byte) (*downloadCaptureClient, *int, *[]string) {
98+
capture := &downloadCaptureClient{}
99+
100+
capture.GetObjectFn = func(_ context.Context, params *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
101+
start, fin := parseRange(aws.ToString(params.Range))
102+
fin++
103+
104+
if fin >= int64(len(data)) {
105+
fin = int64(len(data))
106+
}
107+
108+
bodyBytes := data[start:fin]
109+
110+
return &s3.GetObjectOutput{
111+
Body: ioutil.NopCloser(bytes.NewReader(bodyBytes)),
112+
// offset start by 1 to make it wrong
113+
ContentRange: aws.String(fmt.Sprintf("bytes %d-%d/%d", start+1, fin-1, len(data))),
114+
ContentLength: aws.Int64(int64(len(bodyBytes))),
115+
}, nil
116+
}
117+
118+
return capture, &capture.GetObjectInvocations, &capture.RetrievedRanges
119+
}
120+
97121
func newDownloadVersionClient(data []byte) (*downloadCaptureClient, *int, *[]string, *[]string) {
98122
capture := &downloadCaptureClient{}
99123

feature/s3/manager/upload.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,14 @@ type Uploader struct {
269269
// Note: S3 Express buckets always require CRC32 checksums regardless of this setting.
270270
RequestChecksumCalculation aws.RequestChecksumCalculation
271271

272+
// By default, the uploader verifies that the number of expected uploaded
273+
// parts matches the actual count at the end of an upload.
274+
//
275+
// You can disable that with this flag, however, Amazon S3 recommends
276+
// against doing so because it damages the durability posture of object
277+
// uploads.
278+
DisableValidateParts bool
279+
272280
// partPool allows for the re-usage of streaming payload part buffers between upload calls
273281
partPool byteSlicePool
274282
}
@@ -362,8 +370,9 @@ type uploader struct {
362370

363371
in *s3.PutObjectInput
364372

365-
readerPos int64 // current reader position
366-
totalSize int64 // set to -1 if the size is not known
373+
readerPos int64 // current reader position
374+
totalSize int64 // set to -1 if the size is not known
375+
expectParts int64
367376
}
368377

369378
// internal logic for deciding whether to upload a single part or use a
@@ -446,6 +455,11 @@ func (u *uploader) initSize() error {
446455
// during the size calculation. e.g odd number of bytes.
447456
u.cfg.PartSize = (u.totalSize / int64(u.cfg.MaxUploadParts)) + 1
448457
}
458+
459+
u.expectParts = u.totalSize / u.cfg.PartSize
460+
if u.totalSize%u.cfg.PartSize != 0 {
461+
u.expectParts++
462+
}
449463
}
450464

451465
return nil
@@ -877,6 +891,17 @@ func (u *multiuploader) complete() *s3.CompleteMultipartUploadOutput {
877891
u.fail()
878892
}
879893

894+
// expectParts == 0 means we didn't know the content length upfront and
895+
// therefore we can't validate this at all
896+
if u.expectParts == 0 || u.cfg.DisableValidateParts {
897+
return resp
898+
}
899+
900+
if len(u.parts) != int(u.expectParts) {
901+
u.seterr(fmt.Errorf("uploaded part count mismatch: expected %d, got %d", u.expectParts, len(u.parts)))
902+
u.fail()
903+
}
904+
880905
return resp
881906
}
882907

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package manager_test
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"github.com/aws/aws-sdk-go-v2/aws"
9+
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
10+
"github.com/aws/aws-sdk-go-v2/service/s3"
11+
)
12+
13+
type invalidRangeClient struct {
14+
}
15+
16+
func TestDownload_RangeMismatch(t *testing.T) {
17+
c, _, _ := newDownloadBadRangeClient(buf12MB)
18+
19+
d := manager.NewDownloader(c, func(d *manager.Downloader) {
20+
d.Concurrency = 1
21+
})
22+
23+
w := manager.NewWriteAtBuffer(make([]byte, len(buf12MB)))
24+
_, err := d.Download(context.Background(), w, &s3.GetObjectInput{
25+
Bucket: aws.String("bucket"),
26+
Key: aws.String("key"),
27+
})
28+
if err == nil {
29+
t.Fatalf("expect err, got none")
30+
}
31+
if !strings.Contains(err.Error(), "invalid content range") {
32+
t.Errorf("error mismatch:\n%v !=\n%v", err, "invalid content range")
33+
}
34+
}
35+
36+
func TestDownload_RangeMismatchDisabled(t *testing.T) {
37+
c, _, _ := newDownloadBadRangeClient(buf12MB)
38+
39+
d := manager.NewDownloader(c, func(d *manager.Downloader) {
40+
d.Concurrency = 1
41+
d.DisableValidateParts = true
42+
})
43+
44+
w := manager.NewWriteAtBuffer(make([]byte, len(buf12MB)))
45+
_, err := d.Download(context.Background(), w, &s3.GetObjectInput{
46+
Bucket: aws.String("bucket"),
47+
Key: aws.String("key"),
48+
})
49+
if err != nil {
50+
t.Fatalf("expect no err, got %v", err)
51+
}
52+
}

0 commit comments

Comments
 (0)