s3api: Clean up some errors

- SHA256 mismatches should trip XAmzContentSHA256Mismatch errors,
  not BadDigest. This should include ClientComputedContentSHA256 and
  S3ComputedContentSHA256 elements.
- BadDigest responses should include ExpectedDigest elements.
- Fix a typo in InvalidDigest error message.
- Requests with a v4 authorization header require a sha256 header,
  rejecting with InvalidRequest on failure (and pretty darn early!).
- Requests with a v4 authorization header perform a
  looks-like-a-valid-sha256 check, rejecting with InvalidArgument
  on failure.
- Invalid SHA256 should take precedence over invalid MD5.
- v2-signed requests can still raise XAmzContentSHA256Mismatch errors
  (though they *don't* do the looks-like-a-valid-sha256 check).
- If provided, SHA256 should be used in calculating canonical request
  for v4 pre-signed URLs.

Change-Id: I06c2a16126886bab8807d704294b9809844be086
This commit is contained in:
Tim Burke 2024-05-22 11:21:01 -07:00
parent ec8166be33
commit 7bf2797799
9 changed files with 1683 additions and 101 deletions

View File

@ -57,7 +57,7 @@ from swift.common.middleware.s3api.s3response import AccessDenied, \
MalformedXML, InvalidRequest, RequestTimeout, InvalidBucketName, \ MalformedXML, InvalidRequest, RequestTimeout, InvalidBucketName, \
BadDigest, AuthorizationHeaderMalformed, SlowDown, \ BadDigest, AuthorizationHeaderMalformed, SlowDown, \
AuthorizationQueryParametersError, ServiceUnavailable, BrokenMPU, \ AuthorizationQueryParametersError, ServiceUnavailable, BrokenMPU, \
InvalidPartNumber, InvalidPartArgument InvalidPartNumber, InvalidPartArgument, XAmzContentSHA256Mismatch
from swift.common.middleware.s3api.exception import NotS3Request from swift.common.middleware.s3api.exception import NotS3Request
from swift.common.middleware.s3api.utils import utf8encode, \ from swift.common.middleware.s3api.utils import utf8encode, \
S3Timestamp, mktime, MULTIUPLOAD_SUFFIX S3Timestamp, mktime, MULTIUPLOAD_SUFFIX
@ -129,6 +129,9 @@ class S3InputSHA256Mismatch(BaseException):
through all the layers of the pipeline back to us. It should never escape through all the layers of the pipeline back to us. It should never escape
the s3api middleware. the s3api middleware.
""" """
def __init__(self, expected, computed):
self.expected = expected
self.computed = computed
class HashingInput(object): class HashingInput(object):
@ -141,6 +144,13 @@ class HashingInput(object):
self._to_read = content_length self._to_read = content_length
self._hasher = hasher() self._hasher = hasher()
self._expected = expected_hex_hash self._expected = expected_hex_hash
if content_length == 0 and \
self._hasher.hexdigest() != self._expected.lower():
self.close()
raise XAmzContentSHA256Mismatch(
client_computed_content_s_h_a256=self._expected,
s3_computed_content_s_h_a256=self._hasher.hexdigest(),
)
def read(self, size=None): def read(self, size=None):
chunk = self._input.read(size) chunk = self._input.read(size)
@ -149,12 +159,12 @@ class HashingInput(object):
short_read = bool(chunk) if size is None else (len(chunk) < size) short_read = bool(chunk) if size is None else (len(chunk) < size)
if self._to_read < 0 or (short_read and self._to_read) or ( if self._to_read < 0 or (short_read and self._to_read) or (
self._to_read == 0 and self._to_read == 0 and
self._hasher.hexdigest() != self._expected): self._hasher.hexdigest() != self._expected.lower()):
self.close() self.close()
# Since we don't return the last chunk, the PUT never completes # Since we don't return the last chunk, the PUT never completes
raise S3InputSHA256Mismatch( raise S3InputSHA256Mismatch(
'The X-Amz-Content-SHA56 you specified did not match ' self._expected,
'what we received.') self._hasher.hexdigest())
return chunk return chunk
def close(self): def close(self):
@ -249,6 +259,28 @@ class SigV4Mixin(object):
if int(self.timestamp) + expires < S3Timestamp.now(): if int(self.timestamp) + expires < S3Timestamp.now():
raise AccessDenied('Request has expired', reason='expired') raise AccessDenied('Request has expired', reason='expired')
def _validate_sha256(self):
aws_sha256 = self.headers.get('x-amz-content-sha256')
looks_like_sha256 = (
aws_sha256 and len(aws_sha256) == 64 and
all(c in '0123456789abcdef' for c in aws_sha256.lower()))
if not aws_sha256:
if 'X-Amz-Credential' in self.params:
pass # pre-signed URL; not required
else:
msg = 'Missing required header for this request: ' \
'x-amz-content-sha256'
raise InvalidRequest(msg)
elif aws_sha256 == 'UNSIGNED-PAYLOAD':
pass
elif not looks_like_sha256 and 'X-Amz-Credential' not in self.params:
raise InvalidArgument(
'x-amz-content-sha256',
aws_sha256,
'x-amz-content-sha256 must be UNSIGNED-PAYLOAD, or '
'a valid sha256 value.')
return aws_sha256
def _parse_credential(self, credential_string): def _parse_credential(self, credential_string):
parts = credential_string.split("/") parts = credential_string.split("/")
# credential must be in following format: # credential must be in following format:
@ -459,30 +491,9 @@ class SigV4Mixin(object):
cr.append(b';'.join(swob.wsgi_to_bytes(k) for k, v in headers_to_sign)) cr.append(b';'.join(swob.wsgi_to_bytes(k) for k, v in headers_to_sign))
# 6. Add payload string at the tail # 6. Add payload string at the tail
if 'X-Amz-Credential' in self.params: hashed_payload = self.headers.get('X-Amz-Content-SHA256',
# V4 with query parameters only 'UNSIGNED-PAYLOAD')
hashed_payload = 'UNSIGNED-PAYLOAD'
elif 'X-Amz-Content-SHA256' not in self.headers:
msg = 'Missing required header for this request: ' \
'x-amz-content-sha256'
raise InvalidRequest(msg)
else:
hashed_payload = self.headers['X-Amz-Content-SHA256']
if hashed_payload != 'UNSIGNED-PAYLOAD':
if self.content_length == 0:
if hashed_payload.lower() != sha256().hexdigest():
raise BadDigest(
'The X-Amz-Content-SHA56 you specified did not '
'match what we received.')
elif self.content_length:
self.environ['wsgi.input'] = HashingInput(
self.environ['wsgi.input'],
self.content_length,
sha256,
hashed_payload.lower())
# else, length not provided -- Swift will kick out a
# 411 Length Required which will get translated back
# to a S3-style response in S3Request._swift_error_codes
cr.append(swob.wsgi_to_bytes(hashed_payload)) cr.append(swob.wsgi_to_bytes(hashed_payload))
return b'\n'.join(cr) return b'\n'.join(cr)
@ -810,6 +821,9 @@ class S3Request(swob.Request):
if delta > self.conf.allowable_clock_skew: if delta > self.conf.allowable_clock_skew:
raise RequestTimeTooSkewed() raise RequestTimeTooSkewed()
def _validate_sha256(self):
return self.headers.get('x-amz-content-sha256')
def _validate_headers(self): def _validate_headers(self):
if 'CONTENT_LENGTH' in self.environ: if 'CONTENT_LENGTH' in self.environ:
try: try:
@ -820,21 +834,6 @@ class S3Request(swob.Request):
raise InvalidArgument('Content-Length', raise InvalidArgument('Content-Length',
self.environ['CONTENT_LENGTH']) self.environ['CONTENT_LENGTH'])
value = _header_strip(self.headers.get('Content-MD5'))
if value is not None:
if not re.match('^[A-Za-z0-9+/]+={0,2}$', value):
# Non-base64-alphabet characters in value.
raise InvalidDigest(content_md5=value)
try:
self.headers['ETag'] = binascii.b2a_hex(
binascii.a2b_base64(value))
except binascii.Error:
# incorrect padding, most likely
raise InvalidDigest(content_md5=value)
if len(self.headers['ETag']) != 32:
raise InvalidDigest(content_md5=value)
if self.method == 'PUT' and any(h in self.headers for h in ( if self.method == 'PUT' and any(h in self.headers for h in (
'If-Match', 'If-None-Match', 'If-Match', 'If-None-Match',
'If-Modified-Since', 'If-Unmodified-Since')): 'If-Modified-Since', 'If-Unmodified-Since')):
@ -880,6 +879,38 @@ class S3Request(swob.Request):
if 'x-amz-website-redirect-location' in self.headers: if 'x-amz-website-redirect-location' in self.headers:
raise S3NotImplemented('Website redirection is not supported.') raise S3NotImplemented('Website redirection is not supported.')
aws_sha256 = self._validate_sha256()
if (aws_sha256
and aws_sha256 != 'UNSIGNED-PAYLOAD'
and self.content_length is not None):
# Even if client-provided SHA doesn't look like a SHA, wrap the
# input anyway so we'll send the SHA of what the client sent in
# the eventual error
self.environ['wsgi.input'] = HashingInput(
self.environ['wsgi.input'],
self.content_length,
sha256,
aws_sha256)
# If no content-length, either client's trying to do a HTTP chunked
# transfer, or a HTTP/1.0-style transfer (in which case swift will
# reject with length-required and we'll translate back to
# MissingContentLength)
value = _header_strip(self.headers.get('Content-MD5'))
if value is not None:
if not re.match('^[A-Za-z0-9+/]+={0,2}$', value):
# Non-base64-alphabet characters in value.
raise InvalidDigest(content_md5=value)
try:
self.headers['ETag'] = binascii.b2a_hex(
binascii.a2b_base64(value))
except binascii.Error:
# incorrect padding, most likely
raise InvalidDigest(content_md5=value)
if len(self.headers['ETag']) != 32:
raise InvalidDigest(content_md5=value)
# https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html # https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html
# describes some of what would be required to support this # describes some of what would be required to support this
if any(['aws-chunked' in self.headers.get('content-encoding', ''), if any(['aws-chunked' in self.headers.get('content-encoding', ''),
@ -922,7 +953,10 @@ class S3Request(swob.Request):
try: try:
body = self.body_file.read(max_length) body = self.body_file.read(max_length)
except S3InputSHA256Mismatch as err: except S3InputSHA256Mismatch as err:
raise BadDigest(err.args[0]) raise XAmzContentSHA256Mismatch(
client_computed_content_s_h_a256=err.expected,
s3_computed_content_s_h_a256=err.computed,
)
else: else:
# No (or zero) Content-Length provided, and not chunked transfer; # No (or zero) Content-Length provided, and not chunked transfer;
# no body. Assume zero-length, and enforce a required body below. # no body. Assume zero-length, and enforce a required body below.
@ -1368,6 +1402,16 @@ class S3Request(swob.Request):
return NoSuchKey(obj) return NoSuchKey(obj)
return NoSuchBucket(container) return NoSuchBucket(container)
# Since BadDigest ought to plumb in some client-provided values,
# defer evaluation until we know they're provided
def bad_digest_handler():
etag = binascii.hexlify(base64.b64decode(
env['HTTP_CONTENT_MD5']))
return BadDigest(
expected_digest=etag, # yes, really hex
# TODO: plumb in calculated_digest, as b64
)
code_map = { code_map = {
'HEAD': { 'HEAD': {
HTTP_NOT_FOUND: not_found_handler, HTTP_NOT_FOUND: not_found_handler,
@ -1379,7 +1423,7 @@ class S3Request(swob.Request):
}, },
'PUT': { 'PUT': {
HTTP_NOT_FOUND: (NoSuchBucket, container), HTTP_NOT_FOUND: (NoSuchBucket, container),
HTTP_UNPROCESSABLE_ENTITY: BadDigest, HTTP_UNPROCESSABLE_ENTITY: bad_digest_handler,
HTTP_REQUEST_ENTITY_TOO_LARGE: EntityTooLarge, HTTP_REQUEST_ENTITY_TOO_LARGE: EntityTooLarge,
HTTP_LENGTH_REQUIRED: MissingContentLength, HTTP_LENGTH_REQUIRED: MissingContentLength,
HTTP_REQUEST_TIMEOUT: RequestTimeout, HTTP_REQUEST_TIMEOUT: RequestTimeout,
@ -1420,7 +1464,10 @@ class S3Request(swob.Request):
# hopefully by now any modifications to the path (e.g. tenant to # hopefully by now any modifications to the path (e.g. tenant to
# account translation) will have been made by auth middleware # account translation) will have been made by auth middleware
self.environ['s3api.backend_path'] = sw_req.environ['PATH_INFO'] self.environ['s3api.backend_path'] = sw_req.environ['PATH_INFO']
raise BadDigest(err.args[0]) raise XAmzContentSHA256Mismatch(
client_computed_content_s_h_a256=err.expected,
s3_computed_content_s_h_a256=err.computed,
)
else: else:
# reuse account # reuse account
_, self.account, _ = split_path(sw_resp.environ['PATH_INFO'], _, self.account, _ = split_path(sw_resp.environ['PATH_INFO'],

View File

@ -327,6 +327,12 @@ class BadDigest(ErrorResponse):
_msg = 'The Content-MD5 you specified did not match what we received.' _msg = 'The Content-MD5 you specified did not match what we received.'
class XAmzContentSHA256Mismatch(ErrorResponse):
_status = '400 Bad Request'
_msg = "The provided 'x-amz-content-sha256' header does not match what " \
"was computed."
class BucketAlreadyExists(ErrorResponse): class BucketAlreadyExists(ErrorResponse):
_status = '409 Conflict' _status = '409 Conflict'
_msg = 'The requested bucket name is not available. The bucket ' \ _msg = 'The requested bucket name is not available. The bucket ' \
@ -443,7 +449,7 @@ class InvalidBucketState(ErrorResponse):
class InvalidDigest(ErrorResponse): class InvalidDigest(ErrorResponse):
_status = '400 Bad Request' _status = '400 Bad Request'
_msg = 'The Content-MD5 you specified was an invalid.' _msg = 'The Content-MD5 you specified was invalid.'
class InvalidLocationConstraint(ErrorResponse): class InvalidLocationConstraint(ErrorResponse):

View File

@ -147,14 +147,16 @@ def get_s3_client(user=1, signature_version='s3v4', addressing_style='path'):
TEST_PREFIX = 's3api-test-' TEST_PREFIX = 's3api-test-'
class BaseS3TestCase(unittest.TestCase): class BaseS3Mixin(object):
# Default to v4 signatures (as aws-cli does), but subclasses can override # Default to v4 signatures (as aws-cli does), but subclasses can override
signature_version = 's3v4' signature_version = 's3v4'
def get_s3_client(self, user): @classmethod
return get_s3_client(user, self.signature_version) def get_s3_client(cls, user):
return get_s3_client(user, cls.signature_version)
def _remove_all_object_versions_from_bucket(self, client, bucket_name): @classmethod
def _remove_all_object_versions_from_bucket(cls, client, bucket_name):
resp = client.list_object_versions(Bucket=bucket_name) resp = client.list_object_versions(Bucket=bucket_name)
objs_to_delete = (resp.get('Versions', []) + objs_to_delete = (resp.get('Versions', []) +
resp.get('DeleteMarkers', [])) resp.get('DeleteMarkers', []))
@ -180,10 +182,11 @@ class BaseS3TestCase(unittest.TestCase):
objs_to_delete = (resp.get('Versions', []) + objs_to_delete = (resp.get('Versions', []) +
resp.get('DeleteMarkers', [])) resp.get('DeleteMarkers', []))
def clear_bucket(self, client, bucket_name): @classmethod
def clear_bucket(cls, client, bucket_name):
timeout = time.time() + 10 timeout = time.time() + 10
backoff = 0.1 backoff = 0.1
self._remove_all_object_versions_from_bucket(client, bucket_name) cls._remove_all_object_versions_from_bucket(client, bucket_name)
try: try:
client.delete_bucket(Bucket=bucket_name) client.delete_bucket(Bucket=bucket_name)
except ClientError as e: except ClientError as e:
@ -196,7 +199,7 @@ class BaseS3TestCase(unittest.TestCase):
Bucket=bucket_name, Bucket=bucket_name,
VersioningConfiguration={'Status': 'Suspended'}) VersioningConfiguration={'Status': 'Suspended'})
while True: while True:
self._remove_all_object_versions_from_bucket( cls._remove_all_object_versions_from_bucket(
client, bucket_name) client, bucket_name)
# also try some version-unaware operations... # also try some version-unaware operations...
for key in client.list_objects(Bucket=bucket_name).get( for key in client.list_objects(Bucket=bucket_name).get(
@ -218,16 +221,20 @@ class BaseS3TestCase(unittest.TestCase):
else: else:
break break
def create_name(self, slug): @classmethod
def create_name(cls, slug):
return '%s%s-%s' % (TEST_PREFIX, slug, uuid.uuid4().hex) return '%s%s-%s' % (TEST_PREFIX, slug, uuid.uuid4().hex)
def clear_account(self, client): @classmethod
def clear_account(cls, client):
for bucket in client.list_buckets()['Buckets']: for bucket in client.list_buckets()['Buckets']:
if not bucket['Name'].startswith(TEST_PREFIX): if not bucket['Name'].startswith(TEST_PREFIX):
# these tests run against real s3 accounts # these tests run against real s3 accounts
continue continue
self.clear_bucket(client, bucket['Name']) cls.clear_bucket(client, bucket['Name'])
class BaseS3TestCase(BaseS3Mixin, unittest.TestCase):
def tearDown(self): def tearDown(self):
client = self.get_s3_client(1) client = self.get_s3_client(1)
self.clear_account(client) self.clear_account(client)
@ -237,3 +244,22 @@ class BaseS3TestCase(unittest.TestCase):
pass pass
else: else:
self.clear_account(client) self.clear_account(client)
class BaseS3TestCaseWithBucket(BaseS3Mixin, unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.bucket_name = cls.create_name('test-bucket')
client = cls.get_s3_client(1)
client.create_bucket(Bucket=cls.bucket_name)
@classmethod
def tearDownClass(cls):
client = cls.get_s3_client(1)
cls.clear_account(client)
try:
client = cls.get_s3_client(2)
except ConfigError:
pass
else:
cls.clear_account(client)

File diff suppressed because it is too large Load Diff

View File

@ -1525,7 +1525,7 @@ class TestS3ApiBucketNoACL(BaseS3ApiBucket, S3ApiTestCase):
'Signature=X', 'Signature=X',
]), ]),
'Date': self.get_date_header(), 'Date': self.get_date_header(),
'x-amz-content-sha256': 'not the hash', 'x-amz-content-sha256': '0' * 64,
} }
req = Request.blank('/bucket', req = Request.blank('/bucket',
environ={'REQUEST_METHOD': 'PUT'}, environ={'REQUEST_METHOD': 'PUT'},
@ -1533,8 +1533,9 @@ class TestS3ApiBucketNoACL(BaseS3ApiBucket, S3ApiTestCase):
body=req_body) body=req_body)
status, headers, body = self.call_s3api(req) status, headers, body = self.call_s3api(req)
self.assertEqual(status.split()[0], '400') self.assertEqual(status.split()[0], '400')
self.assertEqual(self._get_error_code(body), 'BadDigest') self.assertEqual(self._get_error_code(body),
self.assertIn(b'X-Amz-Content-SHA56', body) 'XAmzContentSHA256Mismatch')
self.assertIn(b'x-amz-content-sha256', body)
# we maybe haven't parsed the location/path yet? # we maybe haven't parsed the location/path yet?
self.assertNotIn('swift.backend_path', req.environ) self.assertNotIn('swift.backend_path', req.environ)

View File

@ -988,14 +988,15 @@ class TestS3ApiMultiUpload(BaseS3ApiMultiUpload, S3ApiTestCase):
method='PUT', method='PUT',
headers={'Authorization': authz_header, headers={'Authorization': authz_header,
'X-Amz-Date': self.get_v4_amz_date_header(), 'X-Amz-Date': self.get_v4_amz_date_header(),
'X-Amz-Content-SHA256': 'not_the_hash'}, 'X-Amz-Content-SHA256': '0' * 64},
body=b'test') body=b'test')
with patch('swift.common.middleware.s3api.s3request.' with patch('swift.common.middleware.s3api.s3request.'
'get_container_info', 'get_container_info',
lambda env, app, swift_source: {'status': 204}): lambda env, app, swift_source: {'status': 204}):
status, headers, body = self.call_s3api(req) status, headers, body = self.call_s3api(req)
self.assertEqual(status, '400 Bad Request') self.assertEqual(status, '400 Bad Request')
self.assertEqual(self._get_error_code(body), 'BadDigest') self.assertEqual(self._get_error_code(body),
'XAmzContentSHA256Mismatch')
self.assertEqual([ self.assertEqual([
('HEAD', '/v1/AUTH_test/bucket+segments/object/X'), ('HEAD', '/v1/AUTH_test/bucket+segments/object/X'),
('PUT', '/v1/AUTH_test/bucket+segments/object/X/1'), ('PUT', '/v1/AUTH_test/bucket+segments/object/X/1'),
@ -1717,7 +1718,8 @@ class TestS3ApiMultiUpload(BaseS3ApiMultiUpload, S3ApiTestCase):
body=XML) body=XML)
status, headers, body = self.call_s3api(req) status, headers, body = self.call_s3api(req)
self.assertEqual('400 Bad Request', status) self.assertEqual('400 Bad Request', status)
self.assertEqual(self._get_error_code(body), 'BadDigest') self.assertEqual(self._get_error_code(body),
'XAmzContentSHA256Mismatch')
self.assertEqual('/v1/AUTH_test/bucket+segments/object/X', self.assertEqual('/v1/AUTH_test/bucket+segments/object/X',
req.environ.get('swift.backend_path')) req.environ.get('swift.backend_path'))

View File

@ -629,8 +629,10 @@ class BaseS3ApiObj(object):
code = self._test_method_error('PUT', '/bucket/object', code = self._test_method_error('PUT', '/bucket/object',
swob.HTTPServerError) swob.HTTPServerError)
self.assertEqual(code, 'InternalError') self.assertEqual(code, 'InternalError')
code = self._test_method_error('PUT', '/bucket/object', code = self._test_method_error(
swob.HTTPUnprocessableEntity) 'PUT', '/bucket/object',
swob.HTTPUnprocessableEntity,
headers={'Content-MD5': '1B2M2Y8AsgTpgAmY7PhCfg=='})
self.assertEqual(code, 'BadDigest') self.assertEqual(code, 'BadDigest')
code = self._test_method_error('PUT', '/bucket/object', code = self._test_method_error('PUT', '/bucket/object',
swob.HTTPLengthRequired) swob.HTTPLengthRequired)
@ -811,6 +813,31 @@ class BaseS3ApiObj(object):
self.s3api.app = error_catching_app self.s3api.app = error_catching_app
req = Request.blank(
'/bucket/object',
environ={'REQUEST_METHOD': 'PUT'},
headers={
'Authorization':
'AWS4-HMAC-SHA256 '
'Credential=test:tester/%s/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-date, '
'Signature=hmac' % (
self.get_v4_amz_date_header().split('T', 1)[0]),
'x-amz-date': self.get_v4_amz_date_header(),
'x-amz-storage-class': 'STANDARD',
'x-amz-content-sha256': '0' * 64,
'Date': self.get_date_header()},
body=self.object_body)
req.date = datetime.now()
req.content_type = 'text/plain'
status, headers, body = self.call_s3api(req)
self.assertEqual(status.split()[0], '400')
self.assertEqual(self._get_error_code(body),
'XAmzContentSHA256Mismatch')
self.assertIn(b'x-amz-content-sha256', body)
self.assertEqual('/v1/AUTH_test/bucket/object',
req.environ.get('swift.backend_path'))
req = Request.blank( req = Request.blank(
'/bucket/object', '/bucket/object',
environ={'REQUEST_METHOD': 'PUT'}, environ={'REQUEST_METHOD': 'PUT'},
@ -830,10 +857,11 @@ class BaseS3ApiObj(object):
req.content_type = 'text/plain' req.content_type = 'text/plain'
status, headers, body = self.call_s3api(req) status, headers, body = self.call_s3api(req)
self.assertEqual(status.split()[0], '400') self.assertEqual(status.split()[0], '400')
self.assertEqual(self._get_error_code(body), 'BadDigest') self.assertEqual(self._get_error_code(body),
self.assertIn(b'X-Amz-Content-SHA56', body) 'InvalidArgument')
self.assertEqual('/v1/AUTH_test/bucket/object', self.assertIn(b'<ArgumentName>x-amz-content-sha256</ArgumentName>',
req.environ.get('swift.backend_path')) body)
self.assertNotIn('swift.backend_path', req.environ)
def test_object_PUT_v4_unsigned_payload(self): def test_object_PUT_v4_unsigned_payload(self):
req = Request.blank( req = Request.blank(

View File

@ -1137,7 +1137,7 @@ class TestS3ApiMiddleware(S3ApiTestCase):
headers = { headers = {
'Authorization': authz_header, 'Authorization': authz_header,
'X-Amz-Date': self.get_v4_amz_date_header(), 'X-Amz-Date': self.get_v4_amz_date_header(),
'X-Amz-Content-SHA256': '0123456789'} 'X-Amz-Content-SHA256': '0' * 64}
req = Request.blank('/bucket/object', environ=environ, headers=headers) req = Request.blank('/bucket/object', environ=environ, headers=headers)
req.content_type = 'text/plain' req.content_type = 'text/plain'
status, headers, body = self.call_s3api(req) status, headers, body = self.call_s3api(req)

View File

@ -33,8 +33,9 @@ from swift.common.middleware.s3api.s3request import S3Request, \
S3InputSHA256Mismatch S3InputSHA256Mismatch
from swift.common.middleware.s3api.s3response import InvalidArgument, \ from swift.common.middleware.s3api.s3response import InvalidArgument, \
NoSuchBucket, InternalError, ServiceUnavailable, \ NoSuchBucket, InternalError, ServiceUnavailable, \
AccessDenied, SignatureDoesNotMatch, RequestTimeTooSkewed, BadDigest, \ AccessDenied, SignatureDoesNotMatch, RequestTimeTooSkewed, \
InvalidPartArgument, InvalidPartNumber, InvalidRequest InvalidPartArgument, InvalidPartNumber, InvalidRequest, \
XAmzContentSHA256Mismatch
from swift.common.utils import md5 from swift.common.utils import md5
from test.debug_logger import debug_logger from test.debug_logger import debug_logger
@ -412,7 +413,7 @@ class TestRequest(S3ApiTestCase):
'Signature=X' % ( 'Signature=X' % (
scope_date, scope_date,
';'.join(sorted(['host', included_header]))), ';'.join(sorted(['host', included_header]))),
'X-Amz-Content-SHA256': '0123456789'} 'X-Amz-Content-SHA256': '0' * 64}
headers.update(date_header) headers.update(date_header)
req = Request.blank('/', environ=environ, headers=headers) req = Request.blank('/', environ=environ, headers=headers)
@ -594,7 +595,7 @@ class TestRequest(S3ApiTestCase):
'Credential=test/%s/us-east-1/s3/aws4_request, ' 'Credential=test/%s/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,'
'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0], 'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0],
'X-Amz-Content-SHA256': '0123456789', 'X-Amz-Content-SHA256': '0' * 64,
'Date': self.get_date_header(), 'Date': self.get_date_header(),
'X-Amz-Date': x_amz_date} 'X-Amz-Date': x_amz_date}
@ -604,7 +605,7 @@ class TestRequest(S3ApiTestCase):
headers_to_sign = sigv4_req._headers_to_sign() headers_to_sign = sigv4_req._headers_to_sign()
self.assertEqual(headers_to_sign, [ self.assertEqual(headers_to_sign, [
('host', 'localhost:80'), ('host', 'localhost:80'),
('x-amz-content-sha256', '0123456789'), ('x-amz-content-sha256', '0' * 64),
('x-amz-date', x_amz_date)]) ('x-amz-date', x_amz_date)])
# no x-amz-date # no x-amz-date
@ -614,7 +615,7 @@ class TestRequest(S3ApiTestCase):
'Credential=test/%s/us-east-1/s3/aws4_request, ' 'Credential=test/%s/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-content-sha256,' 'SignedHeaders=host;x-amz-content-sha256,'
'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0], 'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0],
'X-Amz-Content-SHA256': '0123456789', 'X-Amz-Content-SHA256': '1' * 64,
'Date': self.get_date_header()} 'Date': self.get_date_header()}
req = Request.blank('/', environ=environ, headers=headers) req = Request.blank('/', environ=environ, headers=headers)
@ -623,7 +624,7 @@ class TestRequest(S3ApiTestCase):
headers_to_sign = sigv4_req._headers_to_sign() headers_to_sign = sigv4_req._headers_to_sign()
self.assertEqual(headers_to_sign, [ self.assertEqual(headers_to_sign, [
('host', 'localhost:80'), ('host', 'localhost:80'),
('x-amz-content-sha256', '0123456789')]) ('x-amz-content-sha256', '1' * 64)])
# SignedHeaders says, host and x-amz-date included but there is not # SignedHeaders says, host and x-amz-date included but there is not
# X-Amz-Date header # X-Amz-Date header
@ -633,7 +634,7 @@ class TestRequest(S3ApiTestCase):
'Credential=test/%s/us-east-1/s3/aws4_request, ' 'Credential=test/%s/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,'
'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0], 'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0],
'X-Amz-Content-SHA256': '0123456789', 'X-Amz-Content-SHA256': '2' * 64,
'Date': self.get_date_header()} 'Date': self.get_date_header()}
req = Request.blank('/', environ=environ, headers=headers) req = Request.blank('/', environ=environ, headers=headers)
@ -730,7 +731,7 @@ class TestRequest(S3ApiTestCase):
'Credential=test/%s/us-east-1/s3/aws4_request, ' 'Credential=test/%s/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,'
'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0], 'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0],
'X-Amz-Content-SHA256': '0123456789', 'X-Amz-Content-SHA256': '0' * 64,
'Date': self.get_date_header(), 'Date': self.get_date_header(),
'X-Amz-Date': x_amz_date} 'X-Amz-Date': x_amz_date}
@ -872,7 +873,7 @@ class TestRequest(S3ApiTestCase):
'Credential=test/%s/us-east-1/s3/aws4_request, ' 'Credential=test/%s/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,'
'Signature=X' % amz_date_header.split('T', 1)[0], 'Signature=X' % amz_date_header.split('T', 1)[0],
'X-Amz-Content-SHA256': '0123456789', 'X-Amz-Content-SHA256': '0' * 64,
'X-Amz-Date': amz_date_header 'X-Amz-Date': amz_date_header
}) })
sigv4_req = SigV4Request( sigv4_req = SigV4Request(
@ -942,18 +943,18 @@ class TestRequest(S3ApiTestCase):
# Virtual hosted-style # Virtual hosted-style
self.s3api.conf.storage_domains = ['s3.test.com'] self.s3api.conf.storage_domains = ['s3.test.com']
# bad sha256 # bad sha256 -- but note that SHAs are not checked for GET/HEAD!
environ = { environ = {
'HTTP_HOST': 'bucket.s3.test.com', 'HTTP_HOST': 'bucket.s3.test.com',
'REQUEST_METHOD': 'GET'} 'REQUEST_METHOD': 'PUT'}
headers = { headers = {
'Authorization': 'Authorization':
'AWS4-HMAC-SHA256 ' 'AWS4-HMAC-SHA256 '
'Credential=test/20210104/us-east-1/s3/aws4_request, ' 'Credential=test/20210104/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,'
'Signature=f721a7941d5b7710344bc62cc45f87e66f4bb1dd00d9075ee61' 'Signature=5f31c77dbc63e7c6ffc84dae60a9261c57c44884fe7927baeb9'
'5b1a5c72b0f8c', '84f418d4d511a',
'X-Amz-Content-SHA256': 'bad', 'X-Amz-Content-SHA256': '0' * 64,
'Date': 'Mon, 04 Jan 2021 10:26:23 -0000', 'Date': 'Mon, 04 Jan 2021 10:26:23 -0000',
'X-Amz-Date': '20210104T102623Z', 'X-Amz-Date': '20210104T102623Z',
'Content-Length': 0, 'Content-Length': 0,
@ -961,15 +962,15 @@ class TestRequest(S3ApiTestCase):
# lowercase sha256 # lowercase sha256
req = Request.blank('/', environ=environ, headers=headers) req = Request.blank('/', environ=environ, headers=headers)
self.assertRaises(BadDigest, SigV4Request, req.environ) self.assertRaises(XAmzContentSHA256Mismatch, SigV4Request, req.environ)
sha256_of_nothing = hashlib.sha256().hexdigest().encode('ascii') sha256_of_nothing = hashlib.sha256().hexdigest().encode('ascii')
headers = { headers = {
'Authorization': 'Authorization':
'AWS4-HMAC-SHA256 ' 'AWS4-HMAC-SHA256 '
'Credential=test/20210104/us-east-1/s3/aws4_request, ' 'Credential=test/20210104/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,'
'Signature=d90542e8b4c0d2f803162040a948e8e51db00b62a59ffb16682' 'Signature=96df261d8f0b617b7c6368e0c5d96ee61f1ec84005e826ece65'
'ef433718fde12', 'c0e0f97eba945',
'X-Amz-Content-SHA256': sha256_of_nothing, 'X-Amz-Content-SHA256': sha256_of_nothing,
'Date': 'Mon, 04 Jan 2021 10:26:23 -0000', 'Date': 'Mon, 04 Jan 2021 10:26:23 -0000',
'X-Amz-Date': '20210104T102623Z', 'X-Amz-Date': '20210104T102623Z',
@ -981,14 +982,14 @@ class TestRequest(S3ApiTestCase):
sigv4_req._canonical_request().endswith(sha256_of_nothing)) sigv4_req._canonical_request().endswith(sha256_of_nothing))
self.assertTrue(sigv4_req.check_signature('secret')) self.assertTrue(sigv4_req.check_signature('secret'))
# uppercase sha256 # uppercase sha256 -- signature changes, but content's valid
headers = { headers = {
'Authorization': 'Authorization':
'AWS4-HMAC-SHA256 ' 'AWS4-HMAC-SHA256 '
'Credential=test/20210104/us-east-1/s3/aws4_request, ' 'Credential=test/20210104/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,'
'Signature=4aab5102e58e9e40f331417d322465c24cac68a7ce77260e9bf' 'Signature=7a3c396fd6043fb397888e6f4d6acc294a99636ff0bb57b283d'
'5ce9a6200862b', '9e075ed87fce2',
'X-Amz-Content-SHA256': sha256_of_nothing.upper(), 'X-Amz-Content-SHA256': sha256_of_nothing.upper(),
'Date': 'Mon, 04 Jan 2021 10:26:23 -0000', 'Date': 'Mon, 04 Jan 2021 10:26:23 -0000',
'X-Amz-Date': '20210104T102623Z', 'X-Amz-Date': '20210104T102623Z',
@ -1000,6 +1001,91 @@ class TestRequest(S3ApiTestCase):
sigv4_req._canonical_request().endswith(sha256_of_nothing.upper())) sigv4_req._canonical_request().endswith(sha256_of_nothing.upper()))
self.assertTrue(sigv4_req.check_signature('secret')) self.assertTrue(sigv4_req.check_signature('secret'))
@patch.object(S3Request, '_validate_dates', lambda *a: None)
def test_v4_req_xmz_content_sha256_mismatch(self):
# Virtual hosted-style
def fake_app(environ, start_response):
environ['wsgi.input'].read()
self.s3api.conf.storage_domains = ['s3.test.com']
environ = {
'HTTP_HOST': 'bucket.s3.test.com',
'REQUEST_METHOD': 'PUT'}
sha256_of_body = hashlib.sha256(b'body').hexdigest()
headers = {
'Authorization':
'AWS4-HMAC-SHA256 '
'Credential=test/20210104/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-date,'
'Signature=5f31c77dbc63e7c6ffc84dae60a9261c57c44884fe7927baeb9'
'84f418d4d511a',
'Date': 'Mon, 04 Jan 2021 10:26:23 -0000',
'X-Amz-Date': '20210104T102623Z',
'Content-Length': 4,
'X-Amz-Content-SHA256': sha256_of_body,
}
req = Request.blank('/', environ=environ, headers=headers,
body=b'not_body')
with self.assertRaises(XAmzContentSHA256Mismatch) as caught:
SigV4Request(req.environ).get_response(fake_app)
self.assertIn(b'<Code>XAmzContentSHA256Mismatch</Code>',
caught.exception.body)
self.assertIn(
('<ClientComputedContentSHA256>%s</ClientComputedContentSHA256>'
% sha256_of_body).encode('ascii'),
caught.exception.body)
self.assertIn(
('<S3ComputedContentSHA256>%s</S3ComputedContentSHA256>'
% hashlib.sha256(b'not_body').hexdigest()).encode('ascii'),
caught.exception.body)
@patch.object(S3Request, '_validate_dates', lambda *a: None)
def test_v4_req_xmz_content_sha256_missing(self):
# Virtual hosted-style
self.s3api.conf.storage_domains = ['s3.test.com']
environ = {
'HTTP_HOST': 'bucket.s3.test.com',
'REQUEST_METHOD': 'PUT'}
headers = {
'Authorization':
'AWS4-HMAC-SHA256 '
'Credential=test/20210104/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-date,'
'Signature=5f31c77dbc63e7c6ffc84dae60a9261c57c44884fe7927baeb9'
'84f418d4d511a',
'Date': 'Mon, 04 Jan 2021 10:26:23 -0000',
'X-Amz-Date': '20210104T102623Z',
'Content-Length': 0,
}
req = Request.blank('/', environ=environ, headers=headers)
self.assertRaises(InvalidRequest, SigV4Request, req.environ)
@patch.object(S3Request, '_validate_dates', lambda *a: None)
def test_v4_req_x_mz_content_sha256_bad_format(self):
# Virtual hosted-style
self.s3api.conf.storage_domains = ['s3.test.com']
environ = {
'HTTP_HOST': 'bucket.s3.test.com',
'REQUEST_METHOD': 'PUT'}
headers = {
'Authorization':
'AWS4-HMAC-SHA256 '
'Credential=test/20210104/us-east-1/s3/aws4_request, '
'SignedHeaders=host;x-amz-date,'
'Signature=5f31c77dbc63e7c6ffc84dae60a9261c57c44884fe7927baeb9'
'84f418d4d511a',
'Date': 'Mon, 04 Jan 2021 10:26:23 -0000',
'X-Amz-Date': '20210104T102623Z',
'Content-Length': 0,
'X-Amz-Content-SHA256': '0' * 63 # too short
}
req = Request.blank('/', environ=environ, headers=headers)
self.assertRaises(InvalidArgument, SigV4Request, req.environ)
headers['X-Amz-Content-SHA256'] = '0' * 63 + 'x' # bad character
req = Request.blank('/', environ=environ, headers=headers)
self.assertRaises(InvalidArgument, SigV4Request, req.environ)
def test_validate_part_number(self): def test_validate_part_number(self):
sw_req = Request.blank('/nojunk', sw_req = Request.blank('/nojunk',
environ={'REQUEST_METHOD': 'GET'}, environ={'REQUEST_METHOD': 'GET'},
@ -1113,7 +1199,7 @@ class TestSigV4Request(S3ApiTestCase):
x_amz_date = self.get_v4_amz_date_header() x_amz_date = self.get_v4_amz_date_header()
headers = { headers = {
'Authorization': auth, 'Authorization': auth,
'X-Amz-Content-SHA256': '0123456789', 'X-Amz-Content-SHA256': '0' * 64,
'Date': self.get_date_header(), 'Date': self.get_date_header(),
'X-Amz-Date': x_amz_date} 'X-Amz-Date': x_amz_date}
req = Request.blank('/', environ=environ, headers=headers) req = Request.blank('/', environ=environ, headers=headers)
@ -1144,7 +1230,7 @@ class TestSigV4Request(S3ApiTestCase):
x_amz_date = self.get_v4_amz_date_header() x_amz_date = self.get_v4_amz_date_header()
headers = { headers = {
'Authorization': auth, 'Authorization': auth,
'X-Amz-Content-SHA256': '0123456789', 'X-Amz-Content-SHA256': '0' * 64,
'Date': self.get_date_header(), 'Date': self.get_date_header(),
'X-Amz-Date': x_amz_date} 'X-Amz-Date': x_amz_date}
req = Request.blank('/', environ=environ, headers=headers) req = Request.blank('/', environ=environ, headers=headers)
@ -1202,7 +1288,7 @@ class TestSigV4Request(S3ApiTestCase):
x_amz_date = self.get_v4_amz_date_header() x_amz_date = self.get_v4_amz_date_header()
params['X-Amz-Date'] = x_amz_date params['X-Amz-Date'] = x_amz_date
signed_headers = { signed_headers = {
'X-Amz-Content-SHA256': '0123456789', 'X-Amz-Content-SHA256': '0' * 64,
'Date': self.get_date_header(), 'Date': self.get_date_header(),
'X-Amz-Date': x_amz_date} 'X-Amz-Date': x_amz_date}
req = Request.blank('/', environ=environ, headers=signed_headers, req = Request.blank('/', environ=environ, headers=signed_headers,
@ -1237,7 +1323,7 @@ class TestSigV4Request(S3ApiTestCase):
x_amz_date = self.get_v4_amz_date_header() x_amz_date = self.get_v4_amz_date_header()
params['X-Amz-Date'] = x_amz_date params['X-Amz-Date'] = x_amz_date
signed_headers = { signed_headers = {
'X-Amz-Content-SHA256': '0123456789', 'X-Amz-Content-SHA256': '0' * 64,
'Date': self.get_date_header(), 'Date': self.get_date_header(),
'X-Amz-Date': x_amz_date} 'X-Amz-Date': x_amz_date}
req = Request.blank('/', environ=environ, headers=signed_headers, req = Request.blank('/', environ=environ, headers=signed_headers,
@ -1294,7 +1380,7 @@ class TestSigV4Request(S3ApiTestCase):
'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0]) 'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0])
headers = { headers = {
'Authorization': auth, 'Authorization': auth,
'X-Amz-Content-SHA256': '0123456789', 'X-Amz-Content-SHA256': '0' * 64,
'Date': self.get_date_header(), 'Date': self.get_date_header(),
'X-Amz-Date': x_amz_date} 'X-Amz-Date': x_amz_date}
@ -1346,7 +1432,7 @@ class TestSigV4Request(S3ApiTestCase):
'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0]) 'Signature=X' % self.get_v4_amz_date_header().split('T', 1)[0])
headers = { headers = {
'Authorization': auth, 'Authorization': auth,
'X-Amz-Content-SHA256': '0123456789', 'X-Amz-Content-SHA256': '0' * 64,
'Date': self.get_date_header(), 'Date': self.get_date_header(),
'X-Amz-Date': x_amz_date} 'X-Amz-Date': x_amz_date}
@ -1438,10 +1524,12 @@ class TestHashingInput(S3ApiTestCase):
self.assertTrue(wrapped._input.closed) self.assertTrue(wrapped._input.closed)
def test_empty_bad_hash(self): def test_empty_bad_hash(self):
wrapped = HashingInput(BytesIO(b''), 0, hashlib.sha256, 'nope') _input = BytesIO(b'')
with self.assertRaises(S3InputSHA256Mismatch): self.assertFalse(_input.closed)
wrapped.read(3) with self.assertRaises(XAmzContentSHA256Mismatch):
self.assertTrue(wrapped._input.closed) # Don't even get a chance to try to read it
HashingInput(_input, 0, hashlib.sha256, 'nope')
self.assertTrue(_input.closed)
if __name__ == '__main__': if __name__ == '__main__':