Skip to content

Commit 9f5aa1f

Browse files
committed
Validate multipart download content ranges
1 parent 203a59e commit 9f5aa1f

File tree

3 files changed

+68
-4
lines changed

3 files changed

+68
-4
lines changed

awscli/s3transfer/download.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616

1717
from botocore.exceptions import ClientError
1818
from s3transfer.compat import seekable
19-
from s3transfer.exceptions import RetriesExceededError, S3DownloadFailedError
19+
from s3transfer.exceptions import (
20+
RetriesExceededError,
21+
S3DownloadFailedError,
22+
S3ValidationError,
23+
)
2024
from s3transfer.futures import IN_MEMORY_DOWNLOAD_TAG
2125
from s3transfer.tasks import SubmissionTask, Task
2226
from s3transfer.utils import (
@@ -577,6 +581,10 @@ def _main(
577581
response = client.get_object(
578582
Bucket=bucket, Key=key, **extra_args
579583
)
584+
self._validate_content_range(
585+
extra_args.get('Range'),
586+
response.get('ContentRange'),
587+
)
580588
streaming_body = StreamReaderProgress(
581589
response['Body'], callbacks
582590
)
@@ -634,6 +642,27 @@ def _main(
634642
def _handle_io(self, download_output_manager, fileobj, chunk, index):
635643
download_output_manager.queue_file_io_task(fileobj, chunk, index)
636644

645+
def _validate_content_range(self, requested_range, content_range):
646+
if not requested_range or not content_range:
647+
return
648+
# Unparsed `ContentRange` looks like `bytes 0-8388607/39542919`,
649+
# where `0-8388607` is the fetched range and `39542919` is
650+
# the total object size.
651+
response_range, total_size = content_range.split('/')
652+
# Subtract `1` because range is 0-indexed.
653+
final_byte = str(int(total_size) - 1)
654+
# If it's the last part, the requested range will not include
655+
# the final byte, eg `bytes=33554432-`.
656+
if requested_range.endswith('-'):
657+
requested_range += final_byte
658+
# Request looks like `bytes=0-8388607`.
659+
# Parsed response looks like `bytes 0-8388607`.
660+
if requested_range[6:] != response_range[6:]:
661+
raise S3ValidationError(
662+
f"Requested range: `{requested_range[6:]}` does not match "
663+
f"content range in response: `{response_range[6:]}`"
664+
)
665+
637666

638667
class ImmediatelyWriteIOGetObjectTask(GetObjectTask):
639668
"""GetObjectTask that immediately writes to the provided file object

awscli/s3transfer/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,7 @@ class FatalError(CancelledError):
3939
"""A CancelledError raised from an error in the TransferManager"""
4040

4141
pass
42+
43+
44+
class S3ValidationError(Exception):
45+
pass

tests/functional/s3transfer/test_download.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020

2121
from botocore.exceptions import ClientError
2222
from s3transfer.compat import SOCKET_ERROR
23-
from s3transfer.exceptions import RetriesExceededError, S3DownloadFailedError
23+
from s3transfer.exceptions import (
24+
RetriesExceededError,
25+
S3DownloadFailedError,
26+
S3ValidationError,
27+
)
2428
from s3transfer.manager import TransferConfig, TransferManager
2529

2630
from tests import (
@@ -109,7 +113,7 @@ def add_head_object_response(self, expected_params=None):
109113
self.stubber.add_response(**head_response)
110114

111115
def add_successful_get_object_responses(
112-
self, expected_params=None, expected_ranges=None
116+
self, expected_params=None, expected_ranges=None, extras=None
113117
):
114118
# Add all get_object responses needed to complete the download.
115119
# Should account for both ranged and nonranged downloads.
@@ -124,6 +128,8 @@ def add_successful_get_object_responses(
124128
stubbed_response['expected_params']['Range'] = (
125129
expected_ranges[i]
126130
)
131+
if extras:
132+
stubbed_response['service_response'].update(extras[i])
127133
self.stubber.add_response(**stubbed_response)
128134

129135
def add_n_retryable_get_object_responses(self, n, num_reads=0):
@@ -511,9 +517,12 @@ def test_download(self):
511517
'RequestPayer': 'requester',
512518
}
513519
expected_ranges = ['bytes=0-3', 'bytes=4-7', 'bytes=8-']
520+
stubbed_ranges = ['bytes 0-3/10', 'bytes 4-7/10', 'bytes 8-9/10']
514521
self.add_head_object_response(expected_params)
515522
self.add_successful_get_object_responses(
516-
{**expected_params, 'IfMatch': self.etag}, expected_ranges
523+
{**expected_params, 'IfMatch': self.etag},
524+
expected_ranges,
525+
[{"ContentRange": r} for r in stubbed_ranges],
517526
)
518527

519528
future = self.manager.download(
@@ -547,6 +556,28 @@ def test_download_with_checksum_enabled(self):
547556
with open(self.filename, 'rb') as f:
548557
self.assertEqual(self.content, f.read())
549558

559+
def test_download_raises_if_content_range_mismatch(self):
560+
expected_params = {
561+
'Bucket': self.bucket,
562+
'Key': self.key,
563+
}
564+
expected_ranges = ['bytes=0-3', 'bytes=4-7', 'bytes=8-']
565+
# Note that the final retrieved range should be `bytes 8-9/10`.
566+
stubbed_ranges = ['bytes 0-3/10', 'bytes 4-7/10', 'bytes 7-8/10']
567+
self.add_head_object_response(expected_params)
568+
self.add_successful_get_object_responses(
569+
{**expected_params, 'IfMatch': self.etag},
570+
expected_ranges,
571+
[{"ContentRange": r} for r in stubbed_ranges],
572+
)
573+
574+
future = self.manager.download(
575+
self.bucket, self.key, self.filename, self.extra_args
576+
)
577+
with self.assertRaises(S3ValidationError) as e:
578+
future.result()
579+
self.assertIn('does not match content range', str(e.exception))
580+
550581
def test_download_raises_if_etag_validation_fails(self):
551582
expected_params = {
552583
'Bucket': self.bucket,

0 commit comments

Comments
 (0)