From 7bf279779995be5081e3ff9da7e113f20efe4446 Mon Sep 17 00:00:00 2001 From: Tim Burke Date: Wed, 22 May 2024 11:21:01 -0700 Subject: [PATCH] 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 --- swift/common/middleware/s3api/s3request.py | 139 +- swift/common/middleware/s3api/s3response.py | 8 +- test/s3api/__init__.py | 46 +- test/s3api/test_input_errors.py | 1384 +++++++++++++++++ .../common/middleware/s3api/test_bucket.py | 7 +- .../middleware/s3api/test_multi_upload.py | 8 +- test/unit/common/middleware/s3api/test_obj.py | 40 +- .../common/middleware/s3api/test_s3api.py | 2 +- .../common/middleware/s3api/test_s3request.py | 150 +- 9 files changed, 1683 insertions(+), 101 deletions(-) create mode 100644 test/s3api/test_input_errors.py diff --git a/swift/common/middleware/s3api/s3request.py b/swift/common/middleware/s3api/s3request.py index a2f11d900d..22ec8eb8be 100644 --- a/swift/common/middleware/s3api/s3request.py +++ b/swift/common/middleware/s3api/s3request.py @@ -57,7 +57,7 @@ from swift.common.middleware.s3api.s3response import AccessDenied, \ MalformedXML, InvalidRequest, RequestTimeout, InvalidBucketName, \ BadDigest, AuthorizationHeaderMalformed, SlowDown, \ AuthorizationQueryParametersError, ServiceUnavailable, BrokenMPU, \ - InvalidPartNumber, InvalidPartArgument + InvalidPartNumber, InvalidPartArgument, XAmzContentSHA256Mismatch from swift.common.middleware.s3api.exception import NotS3Request from swift.common.middleware.s3api.utils import utf8encode, \ 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 the s3api middleware. """ + def __init__(self, expected, computed): + self.expected = expected + self.computed = computed class HashingInput(object): @@ -141,6 +144,13 @@ class HashingInput(object): self._to_read = content_length self._hasher = hasher() 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): chunk = self._input.read(size) @@ -149,12 +159,12 @@ class HashingInput(object): 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 ( self._to_read == 0 and - self._hasher.hexdigest() != self._expected): + self._hasher.hexdigest() != self._expected.lower()): self.close() # Since we don't return the last chunk, the PUT never completes raise S3InputSHA256Mismatch( - 'The X-Amz-Content-SHA56 you specified did not match ' - 'what we received.') + self._expected, + self._hasher.hexdigest()) return chunk def close(self): @@ -249,6 +259,28 @@ class SigV4Mixin(object): if int(self.timestamp) + expires < S3Timestamp.now(): 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): parts = credential_string.split("/") # 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)) # 6. Add payload string at the tail - if 'X-Amz-Credential' in self.params: - # V4 with query parameters only - 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 + hashed_payload = self.headers.get('X-Amz-Content-SHA256', + 'UNSIGNED-PAYLOAD') + cr.append(swob.wsgi_to_bytes(hashed_payload)) return b'\n'.join(cr) @@ -810,6 +821,9 @@ class S3Request(swob.Request): if delta > self.conf.allowable_clock_skew: raise RequestTimeTooSkewed() + def _validate_sha256(self): + return self.headers.get('x-amz-content-sha256') + def _validate_headers(self): if 'CONTENT_LENGTH' in self.environ: try: @@ -820,21 +834,6 @@ class S3Request(swob.Request): raise InvalidArgument('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-Match', 'If-None-Match', 'If-Modified-Since', 'If-Unmodified-Since')): @@ -880,6 +879,38 @@ class S3Request(swob.Request): if 'x-amz-website-redirect-location' in self.headers: 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 # describes some of what would be required to support this if any(['aws-chunked' in self.headers.get('content-encoding', ''), @@ -922,7 +953,10 @@ class S3Request(swob.Request): try: body = self.body_file.read(max_length) 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: # No (or zero) Content-Length provided, and not chunked transfer; # no body. Assume zero-length, and enforce a required body below. @@ -1368,6 +1402,16 @@ class S3Request(swob.Request): return NoSuchKey(obj) 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 = { 'HEAD': { HTTP_NOT_FOUND: not_found_handler, @@ -1379,7 +1423,7 @@ class S3Request(swob.Request): }, 'PUT': { HTTP_NOT_FOUND: (NoSuchBucket, container), - HTTP_UNPROCESSABLE_ENTITY: BadDigest, + HTTP_UNPROCESSABLE_ENTITY: bad_digest_handler, HTTP_REQUEST_ENTITY_TOO_LARGE: EntityTooLarge, HTTP_LENGTH_REQUIRED: MissingContentLength, HTTP_REQUEST_TIMEOUT: RequestTimeout, @@ -1420,7 +1464,10 @@ class S3Request(swob.Request): # hopefully by now any modifications to the path (e.g. tenant to # account translation) will have been made by auth middleware 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: # reuse account _, self.account, _ = split_path(sw_resp.environ['PATH_INFO'], diff --git a/swift/common/middleware/s3api/s3response.py b/swift/common/middleware/s3api/s3response.py index 66b553ac7f..9a06b523f1 100644 --- a/swift/common/middleware/s3api/s3response.py +++ b/swift/common/middleware/s3api/s3response.py @@ -327,6 +327,12 @@ class BadDigest(ErrorResponse): _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): _status = '409 Conflict' _msg = 'The requested bucket name is not available. The bucket ' \ @@ -443,7 +449,7 @@ class InvalidBucketState(ErrorResponse): class InvalidDigest(ErrorResponse): _status = '400 Bad Request' - _msg = 'The Content-MD5 you specified was an invalid.' + _msg = 'The Content-MD5 you specified was invalid.' class InvalidLocationConstraint(ErrorResponse): diff --git a/test/s3api/__init__.py b/test/s3api/__init__.py index ad5bf0fd6d..fa68215e57 100644 --- a/test/s3api/__init__.py +++ b/test/s3api/__init__.py @@ -147,14 +147,16 @@ def get_s3_client(user=1, signature_version='s3v4', addressing_style='path'): TEST_PREFIX = 's3api-test-' -class BaseS3TestCase(unittest.TestCase): +class BaseS3Mixin(object): # Default to v4 signatures (as aws-cli does), but subclasses can override signature_version = 's3v4' - def get_s3_client(self, user): - return get_s3_client(user, self.signature_version) + @classmethod + 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) objs_to_delete = (resp.get('Versions', []) + resp.get('DeleteMarkers', [])) @@ -180,10 +182,11 @@ class BaseS3TestCase(unittest.TestCase): objs_to_delete = (resp.get('Versions', []) + resp.get('DeleteMarkers', [])) - def clear_bucket(self, client, bucket_name): + @classmethod + def clear_bucket(cls, client, bucket_name): timeout = time.time() + 10 backoff = 0.1 - self._remove_all_object_versions_from_bucket(client, bucket_name) + cls._remove_all_object_versions_from_bucket(client, bucket_name) try: client.delete_bucket(Bucket=bucket_name) except ClientError as e: @@ -196,7 +199,7 @@ class BaseS3TestCase(unittest.TestCase): Bucket=bucket_name, VersioningConfiguration={'Status': 'Suspended'}) while True: - self._remove_all_object_versions_from_bucket( + cls._remove_all_object_versions_from_bucket( client, bucket_name) # also try some version-unaware operations... for key in client.list_objects(Bucket=bucket_name).get( @@ -218,16 +221,20 @@ class BaseS3TestCase(unittest.TestCase): else: break - def create_name(self, slug): + @classmethod + def create_name(cls, slug): 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']: if not bucket['Name'].startswith(TEST_PREFIX): # these tests run against real s3 accounts continue - self.clear_bucket(client, bucket['Name']) + cls.clear_bucket(client, bucket['Name']) + +class BaseS3TestCase(BaseS3Mixin, unittest.TestCase): def tearDown(self): client = self.get_s3_client(1) self.clear_account(client) @@ -237,3 +244,22 @@ class BaseS3TestCase(unittest.TestCase): pass else: 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) diff --git a/test/s3api/test_input_errors.py b/test/s3api/test_input_errors.py new file mode 100644 index 0000000000..b260e6d564 --- /dev/null +++ b/test/s3api/test_input_errors.py @@ -0,0 +1,1384 @@ +# Copyright (c) 2024 NVIDIA +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import binascii +import base64 +import datetime +import hashlib +import hmac +import os +import requests +import requests.models +from six.moves.urllib.parse import urlsplit, urlunsplit, quote + +from swift.common import bufferedhttp +from swift.common.utils import UTC +from swift.common.utils.ipaddrs import parse_socket_string + +from test.s3api import BaseS3TestCaseWithBucket, get_opt + + +def _hmac(key, message, digest): + if not isinstance(key, bytes): + key = key.encode('utf8') + if not isinstance(message, bytes): + message = message.encode('utf8') + return hmac.new(key, message, digest).digest() + + +def _sha256(payload=b''): + if not isinstance(payload, bytes): + payload = payload.encode('utf8') + return hashlib.sha256(payload).hexdigest() + + +def _md5(payload=b''): + return base64.b64encode( + hashlib.md5(payload).digest() + ).decode('ascii') + + +EMPTY_SHA256 = _sha256() +EPOCH = datetime.datetime.fromtimestamp(0, UTC) + + +class S3Session(object): + bucket_in_host = False + default_expiration = 900 # 15 min + + def __init__( + self, + endpoint, + access_key, + secret_key, + region='us-east-1', + session_token='', + ): + parts = urlsplit(endpoint) + self.https = (parts.scheme == 'https') + self.host = parts.netloc # note: may include port + self.region = region + self.access_key = access_key + self.secret_key = secret_key + self.session_token = session_token + self.session = requests.Session() + + def make_request( + self, + bucket=None, + key=None, + method='GET', + query=None, + headers=None, + body=b'', + stream=False, + ): + req = self.build_request(bucket, key, method, query, headers, stream) + self.sign_request(req) + return self.send_request(req, body) + + def build_request( + self, + bucket=None, + key=None, + method='GET', + query=None, + headers=None, + stream=False, + ): + request = { + 'https': self.https, + 'host': self.host, + 'method': method, + 'path': '/', + 'query': query or {}, + 'headers': requests.models.CaseInsensitiveDict(headers or {}), + 'stream_response': stream, # set to True for large downloads + 'bucket': bucket, + 'key': key, + 'now': datetime.datetime.now(UTC), + } + + if bucket: + if self.bucket_in_host: + request['host'] = bucket + '.' + request['host'] + else: + request['path'] += bucket + '/' + + if key: + if not bucket: + raise ValueError('bucket required') + request['path'] += key + + request['headers'].update({ + 'Date': request['now'].strftime("%a, %d %b %Y %H:%M:%S GMT"), + }) + + return request + + def date_to_sign(self, request): + raise NotImplementedError + + def sign_request(self, request): + raise NotImplementedError + + def send_request(self, request, body): + url = urlunsplit(( + 'https' if request['https'] else 'http', + request['host'], + request['path'], + '&'.join('%s=%s' % (k, v) for k, v in request['query'].items()), + None, # no fragment + )) + # Note that + # * requests will automatically include a Content-Length header when + # sending a bytes body + # * no signing method incorporates the value of any Content-Length + # header or even its existence/absence + return self.session.request( + request['method'], + url, + headers=request['headers'], + data=body, + stream=request['stream_response'], + ) + + +class S3SessionV2(S3Session): + def build_request( + self, + bucket=None, + key=None, + method='GET', + query=None, + headers=None, + stream=False, + ): + request = super().build_request( + bucket, + key, + method, + query, + headers, + stream, + ) + if self.session_token: + request['headers']['x-amz-security-token'] = self.session_token + return request + + def sign_v2(self, request): + string_to_sign_lines = [ + request['method'], + request['headers'].get('content-md5', ''), + request['headers'].get('Content-Type', ''), + self.date_to_sign(request), + ] + + amz_headers = sorted( + (h.strip(), v.strip()) + for h, v in request['headers'].lower_items() + if h.startswith('x-amz-') + ) + string_to_sign_lines.extend('%s:%s' % (h, v) + for h, v in amz_headers) + + string_to_sign_lines.append( + ('/' + request['bucket'] if self.bucket_in_host else '') + + request['path'] + ) + signature = base64.b64encode(_hmac( + self.secret_key, + '\n'.join(string_to_sign_lines), + hashlib.sha1, + )).decode('ascii') + return { + 'credential': self.access_key, + 'signature': signature, + } + + +class S3SessionV2Headers(S3SessionV2): + def date_to_sign(self, request): + if 'X-Amz-Date' in request['headers']: + return '' + else: + return request['headers']['Date'] + + def sign_request(self, request): + bundle = self.sign_v2(request) + request['headers']['Authorization'] = 'AWS ' + ':'.join([ + bundle['credential'], + bundle['signature'], + ]) + + +class S3SessionV2Query(S3SessionV2): + def build_request( + self, + bucket=None, + key=None, + method='GET', + query=None, + headers=None, + stream=False, + ): + request = super().build_request( + bucket, + key, + method, + query, + headers, + stream, + ) + + expires = int((request['now'] - EPOCH).total_seconds()) \ + + self.default_expiration + request['query'].update({ + 'Expires': str(expires), + 'AWSAccessKeyId': self.access_key, + }) + + return request + + def date_to_sign(self, request): + return request['query']['Expires'] + + def sign_request(self, request): + bundle = self.sign_v2(request) + request['query'].update({ + 'Signature': quote(bundle['signature'], safe='-_.~'), + }) + + +class S3SessionV4(S3Session): + def sign_v4(self, request): + canonical_request_lines = [ + request['method'], + ('/' + request['bucket'] if self.bucket_in_host else '') + + request['path'], + '&'.join('%s=%s' % (k, v) + for k, v in sorted(request['query'].items())), + ] + canonical_request_lines.extend( + '%s:%s' % (h, request['headers'][h]) + for h in request['signed_headers']) + canonical_request_lines.extend([ + '', + ';'.join(request['signed_headers']), + request['headers'].get('x-amz-content-sha256', 'UNSIGNED-PAYLOAD') + ]) + scope = [ + request['now'].strftime('%Y%m%d'), + self.region, + 's3', + 'aws4_request', + ] + string_to_sign_lines = [ + 'AWS4-HMAC-SHA256', + self.date_to_sign(request), + '/'.join(scope), + _sha256('\n'.join(canonical_request_lines)), + ] + key = 'AWS4' + self.secret_key + for piece in scope: + key = _hmac(key, piece, hashlib.sha256) + signature = binascii.hexlify(_hmac( + key, + '\n'.join(string_to_sign_lines), + hashlib.sha256 + )).decode('ascii') + return { + 'credential': self.access_key + '/' + '/'.join(scope), + 'signature': signature, + } + + +class S3SessionV4Headers(S3SessionV4): + def build_request( + self, + bucket=None, + key=None, + method='GET', + query=None, + headers=None, + stream=False, + ): + request = super().build_request( + bucket, + key, + method, + query, + headers, + stream, + ) + + request['headers'].update({ + 'Host': request['host'], + 'X-Amz-Date': request['now'].strftime('%Y%m%dT%H%M%SZ'), + }) + + if self.session_token: + request['headers']['x-amz-security-token'] = self.session_token + + request['signed_headers'] = sorted( + h.strip() + for h, _ in request['headers'].lower_items() + if h in ('host', 'content-type', 'content-md5') + or h.startswith('x-amz-') + ) + + return request + + def date_to_sign(self, request): + return request['headers']['X-Amz-Date'] + + def sign_request(self, request): + bundle = self.sign_v4(request) + request['headers']['Authorization'] = 'AWS4-HMAC-SHA256 ' + \ + ','.join([ + 'Credential=' + bundle['credential'], + 'SignedHeaders=' + ';'.join(request['signed_headers']), + 'Signature=' + quote(bundle['signature'], safe='-_.~'), + ]) + + +class S3SessionV4Query(S3SessionV4): + def build_request( + self, + bucket=None, + key=None, + method='GET', + query=None, + headers=None, + stream=False, + ): + request = super().build_request( + bucket, + key, + method, + query, + headers, + stream, + ) + + request['headers'].update({ + 'Host': request['host'], + }) + scope = [ + request['now'].strftime('%Y%m%d'), + self.region, + 's3', + 'aws4_request', + ] + for k, v in { + 'X-Amz-Expires': str(self.default_expiration), + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-Credential': quote( + self.access_key + '/' + '/'.join(scope), + safe='-_.~'), + 'X-Amz-Date': request['now'].strftime('%Y%m%dT%H%M%SZ'), + }.items(): + request['query'].setdefault(k, v) + + if self.session_token: + request['query']['X-Amz-Security-Token'] = quote( + self.session_token, safe='-_.~') + + request['signed_headers'] = sorted( + h.strip() + for h, _ in request['headers'].lower_items() + if h in ('host', 'content-type', 'content-md5') + or h.startswith('x-amz-') + ) + + return request + + def date_to_sign(self, request): + return request['query']['X-Amz-Date'] + + def sign_request(self, request): + request['query'].setdefault( + 'X-Amz-SignedHeaders', + '%3B'.join(request['signed_headers']), + ) + bundle = self.sign_v4(request) + request['query'].update({ + 'X-Amz-Signature': bundle['signature'], + 'X-Amz-SignedHeaders': '%3B'.join( + request['signed_headers']), + }) + + +TEST_BODY = os.urandom(32) + + +class InputErrorsMixin(object): + session_cls = None + + @classmethod + def setUpClass(cls): + super(InputErrorsMixin, cls).setUpClass() + cls.conn = cls.session_cls( + get_opt('endpoint', None), + get_opt('access_key1', None), + get_opt('secret_key1', None), + get_opt('region', 'us-east-1'), + get_opt('session_token1', None), + ) + + def assertOK(self, resp, expected_body=b''): + respbody = resp.content + if not isinstance(respbody, str): + try: + respbody = respbody.decode('utf8') + except UnicodeError: + pass # just trying to improve the error message + self.assertEqual( + (resp.status_code, resp.reason), + (200, 'OK'), + respbody) + if expected_body is not None: + self.assertEqual(resp.content, expected_body) + + def assertSHA256Mismatch(self, resp, sha_in_headers, sha_of_body): + respbody = resp.content + if not isinstance(respbody, str): + respbody = respbody.decode('utf8') + self.assertEqual( + (resp.status_code, resp.reason), + (400, 'Bad Request'), + respbody) + self.assertIn('XAmzContentSHA256Mismatch', respbody) + self.assertIn("The provided 'x-amz-content-sha256' header " + "does not match what was computed.", + respbody) + self.assertIn('%s' + '' + % sha_in_headers, respbody) + self.assertIn('%s' + % sha_of_body, respbody) + + def assertInvalidDigest(self, resp, md5_in_headers): + respbody = resp.content + if not isinstance(respbody, str): + respbody = respbody.decode('utf8') + self.assertEqual( + (resp.status_code, resp.reason), + (400, 'Bad Request'), + respbody) + self.assertIn('InvalidDigest', respbody) + self.assertIn("The Content-MD5 you specified was " + "invalid.", + respbody) + # TODO: AWS provides this, but swift doesn't (yet) + # self.assertIn('%s' % md5_in_headers, + # respbody) + + def assertBadDigest(self, resp, md5_in_headers, md5_of_body): + respbody = resp.content + if not isinstance(respbody, str): + respbody = respbody.decode('utf8') + self.assertEqual( + (resp.status_code, resp.reason), + (400, 'Bad Request'), + respbody) + self.assertIn('BadDigest', respbody) + self.assertIn("The Content-MD5 you specified did not match " + "what we received.", + respbody) + # Yes, really -- AWS needs b64 in headers, but reflects back hex + self.assertIn('%s' % binascii.hexlify( + base64.b64decode(md5_in_headers)).decode('ascii'), respbody) + # TODO: AWS provides this, but swift doesn't (yet) + # self.assertIn('%s' + # % md5_of_body, respbody) + + def assertIncompleteBody( + self, + resp, + bytes_provided=None, + bytes_expected=None, + ): + self.assertEqual(resp.status_code, 400, resp.content) + self.assertIn(b'IncompleteBody', resp.content) + if bytes_provided is None: + self.assertIn(b'The request body terminated ' + b'unexpectedly', + resp.content) + self.assertNotIn(b'', resp.content) + self.assertNotIn(b'', resp.content) + else: + self.assertIn(b'You did not provide the number of bytes ' + b'specified by the Content-Length HTTP header' + b'', + resp.content) + self.assertIn(b'%d' + % bytes_expected, + resp.content) + self.assertIn(b'%d' + % bytes_provided, + resp.content) + + def test_get_service_no_sha(self): + resp = self.conn.make_request() + self.assertOK(resp, None) + + def test_get_service_invalid_sha(self): + resp = self.conn.make_request(headers={ + 'x-amz-content-sha256': 'invalid'}) + # (!) invalid doesn't matter on GET, at least most of the time + self.assertOK(resp, None) + + def test_get_service_bad_sha(self): + resp = self.conn.make_request(headers={ + 'x-amz-content-sha256': _sha256(b'not the body')}) + # (!) mismatch doesn't matter on GET, either + self.assertOK(resp, None) + + def test_get_service_good_sha(self): + resp = self.conn.make_request(headers={ + 'x-amz-content-sha256': EMPTY_SHA256}) + self.assertOK(resp, None) + + def test_get_service_unsigned(self): + resp = self.conn.make_request(headers={ + 'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp, None) + + def test_head_service_no_sha(self): + resp = self.conn.make_request(method='HEAD') + self.assertEqual( + (resp.status_code, resp.reason), + (405, 'Method Not Allowed')) + + def test_head_service_invalid_sha(self): + resp = self.conn.make_request(method='HEAD', headers={ + 'x-amz-content-sha256': 'invalid'}) + self.assertEqual( + (resp.status_code, resp.reason), + (405, 'Method Not Allowed')) + + def test_head_service_bad_sha(self): + resp = self.conn.make_request(method='HEAD', headers={ + 'x-amz-content-sha256': _sha256(b'not the body')}) + self.assertEqual( + (resp.status_code, resp.reason), + (405, 'Method Not Allowed')) + + def test_head_service_good_sha(self): + resp = self.conn.make_request(method='HEAD', headers={ + 'x-amz-content-sha256': EMPTY_SHA256}) + self.assertEqual( + (resp.status_code, resp.reason), + (405, 'Method Not Allowed')) + + def test_head_service_unsigned(self): + resp = self.conn.make_request(method='HEAD', headers={ + 'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertEqual( + (resp.status_code, resp.reason), + (405, 'Method Not Allowed')) + + def test_get_bucket_no_md5_no_sha(self): + resp = self.conn.make_request(self.bucket_name) + self.assertOK(resp, None) + + def test_get_bucket_no_md5_invalid_sha(self): + resp = self.conn.make_request( + self.bucket_name, + headers={ + 'x-amz-content-sha256': 'invalid'}) + self.assertOK(resp, None) + + def test_get_bucket_no_md5_bad_sha(self): + resp = self.conn.make_request( + self.bucket_name, + headers={ + 'x-amz-content-sha256': _sha256(b'not the body')}) + self.assertOK(resp, None) + + def test_get_bucket_no_md5_good_sha(self): + resp = self.conn.make_request( + self.bucket_name, + headers={ + 'x-amz-content-sha256': _sha256(TEST_BODY)}) + self.assertOK(resp, None) + + def test_get_bucket_no_md5_unsigned(self): + resp = self.conn.make_request( + self.bucket_name, + headers={ + 'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp, None) + + def test_get_bucket_good_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + headers={ + 'content-md5': _md5(TEST_BODY)}) + self.assertOK(resp, None) + + def test_get_bucket_good_md5_good_sha(self): + resp = self.conn.make_request( + self.bucket_name, + headers={ + 'content-md5': _md5(TEST_BODY), + 'x-amz-content-sha256': _sha256(TEST_BODY)}) + self.assertOK(resp, None) + + def test_head_bucket_no_md5_no_sha(self): + resp = self.conn.make_request(self.bucket_name, method='HEAD') + self.assertOK(resp) + + def test_head_bucket_no_md5_good_sha(self): + resp = self.conn.make_request( + self.bucket_name, + method='HEAD', + headers={ + 'x-amz-content-sha256': _sha256(TEST_BODY)}) + self.assertOK(resp) + + def test_head_bucket_good_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + method='HEAD', + headers={ + 'content-md5': _md5(TEST_BODY)}) + self.assertOK(resp) + + def test_head_bucket_good_md5_good_sha(self): + resp = self.conn.make_request( + self.bucket_name, + method='HEAD', + headers={ + 'content-md5': _md5(TEST_BODY), + 'x-amz-content-sha256': _sha256(TEST_BODY)}) + self.assertOK(resp) + + def test_no_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY) + self.assertOK(resp) + + def get_response_put_object_no_md5_no_sha_no_content_length(self): + request = self.conn.build_request( + self.bucket_name, + 'test-obj', + method='PUT', + ) + self.conn.sign_request(request) + # requests is not our friend here; it's going to try *real hard* to + # either send a "Content-Length" or "Transfer-encoding: chunked" header + # so dip down to our bufferedhttp to do the sending/parsing + host, port = parse_socket_string(request['host'], None) + if port: + port = int(port) + conn = bufferedhttp.http_connect_raw( + host, + port, + request['method'], + request['path'], + request['headers'], + '&'.join('%s=%s' % (k, v) for k, v in request['query'].items()), + request['https'] + ) + conn.send(TEST_BODY) + return conn.getresponse() + + def test_no_md5_no_sha_no_content_length(self): + resp = self.get_response_put_object_no_md5_no_sha_no_content_length() + body = resp.read() + self.assertEqual(resp.status, 411, body) + self.assertIn(b'MissingContentLength', body) + self.assertIn(b'You must provide the Content-Length HTTP ' + b'header.', body) + + def test_no_md5_invalid_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'invalid'}) + self.assertSHA256Mismatch(resp, 'invalid', _sha256(TEST_BODY)) + + def test_no_md5_invalid_sha_ucase(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'X-AMZ-CONTENT-SHA256': 'INVALID'}) + # Despite the upper-cased header name in the request, + # the error message has it lower + self.assertSHA256Mismatch(resp, 'INVALID', _sha256(TEST_BODY)) + + def test_no_md5_bad_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': EMPTY_SHA256}) + self.assertSHA256Mismatch(resp, EMPTY_SHA256, _sha256(TEST_BODY)) + + def test_no_md5_bad_sha_ucase(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'X-AMZ-CONTENT-SHA256': EMPTY_SHA256.upper()}) + # Despite the upper-cased header name in the request, + # the error message has it lower + self.assertSHA256Mismatch( + resp, EMPTY_SHA256.upper(), _sha256(TEST_BODY)) + + def test_no_md5_bad_sha_empty_body(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + headers={'x-amz-content-sha256': _sha256(b'not the body')}) + self.assertSHA256Mismatch(resp, _sha256(b'not the body'), EMPTY_SHA256) + + def test_no_md5_good_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': _sha256(TEST_BODY)}) + self.assertOK(resp) + + def test_no_md5_good_sha_ucase(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': _sha256(TEST_BODY).upper()}) + self.assertOK(resp) + + def test_no_md5_good_sha_no_content_length(self): + request = self.conn.build_request( + self.bucket_name, + 'test-obj', + method='PUT', + headers={'x-amz-content-sha256': _sha256(TEST_BODY)}, + ) + self.conn.sign_request(request) + # requests is not our friend here; it's going to try *real hard* to + # either send a "Content-Length" or "Transfer-encoding: chunked" header + # so dip down to our bufferedhttp to do the sending/parsing + host, port = parse_socket_string(request['host'], None) + if port: + port = int(port) + conn = bufferedhttp.http_connect_raw( + host, + port, + request['method'], + request['path'], + request['headers'], + '&'.join('%s=%s' % (k, v) for k, v in request['query'].items()), + request['https'] + ) + conn.send(TEST_BODY) + resp = conn.getresponse() + body = resp.read() + self.assertEqual(resp.status, 411, body) + self.assertIn(b'MissingContentLength', body) + self.assertIn(b'You must provide the Content-Length HTTP ' + b'header.', body) + + def test_no_md5_unsigned(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + def test_no_md5_unsigned_lcase(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'unsigned-payload'}) + self.assertSHA256Mismatch(resp, 'unsigned-payload', _sha256(TEST_BODY)) + + def test_invalid_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'content-md5': 'invalid'}) + self.assertInvalidDigest(resp, 'invalid') + + def test_invalid_md5_invalid_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'content-md5': 'invalid', + 'x-amz-content-sha256': 'invalid'}) + self.assertInvalidDigest(resp, 'invalid') + + def test_invalid_md5_bad_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'content-md5': 'invalid', + 'x-amz-content-sha256': EMPTY_SHA256}) + self.assertInvalidDigest(resp, 'invalid') + + def test_invalid_md5_good_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'content-md5': 'invalid', + 'x-amz-content-sha256': _sha256(TEST_BODY)}) + self.assertInvalidDigest(resp, 'invalid') + + def test_bad_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'content-md5': _md5(b'')}) + self.assertBadDigest(resp, _md5(b''), _md5(TEST_BODY)) + + def test_bad_md5_invalid_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={ + 'content-md5': _md5(b''), + 'x-amz-content-sha256': 'invalid'}) + # Neither is right; "mismatched" sha256 trumps + self.assertSHA256Mismatch(resp, 'invalid', _sha256(TEST_BODY)) + + def test_bad_md5_bad_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={ + 'content-md5': _md5(b''), + 'x-amz-content-sha256': EMPTY_SHA256}) + # Neither is right; bad sha256 trumps + self.assertSHA256Mismatch(resp, EMPTY_SHA256, _sha256(TEST_BODY)) + + def test_bad_md5_good_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={ + 'content-md5': _md5(b''), + 'x-amz-content-sha256': _sha256(TEST_BODY)}) + self.assertBadDigest(resp, _md5(b''), _md5(TEST_BODY)) + + def test_good_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'content-md5': _md5(TEST_BODY)}) + self.assertOK(resp) + + def test_good_md5_invalid_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={ + 'content-md5': _md5(TEST_BODY), + 'x-amz-content-sha256': 'invalid'}) + self.assertSHA256Mismatch(resp, 'invalid', _sha256(TEST_BODY)) + + def test_good_md5_bad_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={ + 'content-md5': _md5(TEST_BODY), + 'x-amz-content-sha256': EMPTY_SHA256}) + self.assertSHA256Mismatch(resp, EMPTY_SHA256, _sha256(TEST_BODY)) + + def test_good_md5_good_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={ + 'content-md5': _md5(TEST_BODY), + 'x-amz-content-sha256': _sha256(TEST_BODY)}) + self.assertOK(resp) + + def test_get_object_no_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request(self.bucket_name, obj_name) + self.assertOK(resp, TEST_BODY) + + def test_get_object_invalid_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + headers={'x-amz-content-sha256': 'invalid'}) + self.assertOK(resp, TEST_BODY) + + def test_get_object_bad_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + headers={'x-amz-content-sha256': _sha256(b'not the body')}) + self.assertOK(resp, TEST_BODY) + + def test_get_object_good_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + headers={'x-amz-content-sha256': _sha256()}) + self.assertOK(resp, TEST_BODY) + + def test_get_object_unsigned(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp, TEST_BODY) + + def test_head_object_no_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='HEAD') + self.assertOK(resp) + + def test_head_object_invalid_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='HEAD', + headers={'x-amz-content-sha256': 'invalid'}) + self.assertOK(resp) + + def test_head_object_bad_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='HEAD', + headers={'x-amz-content-sha256': _sha256(b'not the body')}) + self.assertOK(resp) + + def test_head_object_good_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='HEAD', + headers={'x-amz-content-sha256': _sha256()}) + self.assertOK(resp) + + def test_head_object_unsigned(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='HEAD', + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + +class TestV4AuthHeaders(InputErrorsMixin, BaseS3TestCaseWithBucket): + session_cls = S3SessionV4Headers + + def assertMissingSHA256(self, resp): + self.assertEqual(resp.status_code, 400, resp.content) + self.assertIn(b'InvalidRequest', resp.content) + self.assertIn(b'Missing required header for this ' + b'request: x-amz-content-sha256', + resp.content) + + def assertInvalidSHA256(self, resp, sha_in_headers): + respbody = resp.content + if not isinstance(respbody, str): + respbody = respbody.decode('utf8') + self.assertEqual( + (resp.status_code, resp.reason), + (400, 'Bad Request'), + respbody) + self.assertIn('InvalidArgument', respbody) + self.assertIn('x-amz-content-sha256 must be ' + 'UNSIGNED-PAYLOAD', respbody) + # There can be a whole list here, but Swift only supports these + # two at the moment + self.assertIn('or a valid sha256 value.', respbody) + self.assertIn('x-amz-content-sha256', + respbody) + self.assertIn('%s' % sha_in_headers, + respbody) + + def test_get_service_no_sha(self): + resp = self.conn.make_request() + self.assertMissingSHA256(resp) + + def test_get_service_invalid_sha(self): + resp = self.conn.make_request(headers={ + 'x-amz-content-sha256': 'invalid'}) + self.assertInvalidSHA256(resp, 'invalid') + + def test_head_service_no_sha(self): + resp = self.conn.make_request(method='HEAD') + self.assertEqual( + (resp.status_code, resp.reason), + (400, 'Bad Request')) + + def test_head_service_invalid_sha(self): + resp = self.conn.make_request(method='HEAD', headers={ + 'x-amz-content-sha256': 'invalid'}) + self.assertEqual( + (resp.status_code, resp.reason), + (400, 'Bad Request')) + + def test_get_bucket_no_md5_no_sha(self): + resp = self.conn.make_request(self.bucket_name) + self.assertMissingSHA256(resp) + + def test_get_bucket_no_md5_invalid_sha(self): + resp = self.conn.make_request( + self.bucket_name, + headers={ + 'x-amz-content-sha256': 'invalid'}) + self.assertInvalidSHA256(resp, 'invalid') + + def test_get_bucket_good_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + headers={ + 'content-md5': _md5(TEST_BODY)}) + self.assertMissingSHA256(resp) + + def test_head_bucket_no_md5_no_sha(self): + resp = self.conn.make_request(self.bucket_name, method='HEAD') + self.assertEqual( + (resp.status_code, resp.reason), + (400, 'Bad Request')) + + def test_head_bucket_good_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + method='HEAD', + headers={ + 'content-md5': _md5(TEST_BODY)}) + self.assertEqual( + (resp.status_code, resp.reason), + (400, 'Bad Request')) + + def test_no_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY) + self.assertMissingSHA256(resp) + + def test_no_md5_no_sha_no_content_length(self): + resp = self.get_response_put_object_no_md5_no_sha_no_content_length() + body = resp.read() + self.assertEqual(resp.status, 400, body) + self.assertIn(b'InvalidRequest', body) + self.assertIn(b'Missing required header for this ' + b'request: x-amz-content-sha256', + body) + + def test_no_md5_invalid_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'invalid'}) + self.assertInvalidSHA256(resp, 'invalid') + + def test_no_md5_invalid_sha_ucase(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'X-AMZ-CONTENT-SHA256': 'INVALID'}) + # Despite the upper-cased header name in the request, + # the error message has it lower + self.assertInvalidSHA256(resp, 'INVALID') + + def test_no_md5_unsigned_lcase(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'unsigned-payload'}) + self.assertInvalidSHA256(resp, 'unsigned-payload') + + def test_invalid_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'content-md5': 'invalid'}) + self.assertMissingSHA256(resp) + + def test_invalid_md5_invalid_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'content-md5': 'invalid', + 'x-amz-content-sha256': 'invalid'}) + # Both invalid; sha256 trumps + self.assertInvalidSHA256(resp, 'invalid') + + def test_bad_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'content-md5': _md5(b'')}) + self.assertMissingSHA256(resp) + + def test_bad_md5_invalid_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={ + 'content-md5': _md5(b''), + 'x-amz-content-sha256': 'invalid'}) + # Neither is right; invalid sha256 trumps + self.assertInvalidSHA256(resp, 'invalid') + + def test_good_md5_no_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={'content-md5': _md5(TEST_BODY)}) + self.assertMissingSHA256(resp) + + def test_good_md5_invalid_sha(self): + resp = self.conn.make_request( + self.bucket_name, + 'test-obj', + method='PUT', + body=TEST_BODY, + headers={ + 'content-md5': _md5(TEST_BODY), + 'x-amz-content-sha256': 'invalid'}) + self.assertInvalidSHA256(resp, 'invalid') + + def test_get_object_no_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request(self.bucket_name, obj_name) + self.assertMissingSHA256(resp) + + def test_get_object_invalid_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + headers={'x-amz-content-sha256': 'invalid'}) + self.assertInvalidSHA256(resp, 'invalid') + + def test_head_object_no_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='HEAD') + # Since it's a HEAD, all we get is status + self.assertEqual( + (resp.status_code, resp.reason), + (400, 'Bad Request')) + + def test_head_object_invalid_sha(self): + obj_name = self.create_name('get-object') + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='PUT', + body=TEST_BODY, + headers={'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'}) + self.assertOK(resp) + + resp = self.conn.make_request( + self.bucket_name, + obj_name, + method='HEAD', + headers={'x-amz-content-sha256': 'invalid'}) + # Since it's a HEAD, all we get is status + self.assertEqual( + (resp.status_code, resp.reason), + (400, 'Bad Request')) + + +class TestV4AuthQuery(InputErrorsMixin, BaseS3TestCaseWithBucket): + session_cls = S3SessionV4Query + + +class TestV2AuthHeaders(InputErrorsMixin, BaseS3TestCaseWithBucket): + session_cls = S3SessionV2Headers + + +class TestV2AuthQuery(InputErrorsMixin, BaseS3TestCaseWithBucket): + session_cls = S3SessionV2Query diff --git a/test/unit/common/middleware/s3api/test_bucket.py b/test/unit/common/middleware/s3api/test_bucket.py index ef24932584..3385aa9357 100644 --- a/test/unit/common/middleware/s3api/test_bucket.py +++ b/test/unit/common/middleware/s3api/test_bucket.py @@ -1525,7 +1525,7 @@ class TestS3ApiBucketNoACL(BaseS3ApiBucket, S3ApiTestCase): 'Signature=X', ]), 'Date': self.get_date_header(), - 'x-amz-content-sha256': 'not the hash', + 'x-amz-content-sha256': '0' * 64, } req = Request.blank('/bucket', environ={'REQUEST_METHOD': 'PUT'}, @@ -1533,8 +1533,9 @@ class TestS3ApiBucketNoACL(BaseS3ApiBucket, S3ApiTestCase): body=req_body) status, headers, body = self.call_s3api(req) self.assertEqual(status.split()[0], '400') - self.assertEqual(self._get_error_code(body), 'BadDigest') - self.assertIn(b'X-Amz-Content-SHA56', body) + self.assertEqual(self._get_error_code(body), + 'XAmzContentSHA256Mismatch') + self.assertIn(b'x-amz-content-sha256', body) # we maybe haven't parsed the location/path yet? self.assertNotIn('swift.backend_path', req.environ) diff --git a/test/unit/common/middleware/s3api/test_multi_upload.py b/test/unit/common/middleware/s3api/test_multi_upload.py index e2e121ace7..81cc503144 100644 --- a/test/unit/common/middleware/s3api/test_multi_upload.py +++ b/test/unit/common/middleware/s3api/test_multi_upload.py @@ -988,14 +988,15 @@ class TestS3ApiMultiUpload(BaseS3ApiMultiUpload, S3ApiTestCase): method='PUT', headers={'Authorization': authz_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') with patch('swift.common.middleware.s3api.s3request.' 'get_container_info', lambda env, app, swift_source: {'status': 204}): status, headers, body = self.call_s3api(req) self.assertEqual(status, '400 Bad Request') - self.assertEqual(self._get_error_code(body), 'BadDigest') + self.assertEqual(self._get_error_code(body), + 'XAmzContentSHA256Mismatch') self.assertEqual([ ('HEAD', '/v1/AUTH_test/bucket+segments/object/X'), ('PUT', '/v1/AUTH_test/bucket+segments/object/X/1'), @@ -1717,7 +1718,8 @@ class TestS3ApiMultiUpload(BaseS3ApiMultiUpload, S3ApiTestCase): body=XML) status, headers, body = self.call_s3api(req) 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', req.environ.get('swift.backend_path')) diff --git a/test/unit/common/middleware/s3api/test_obj.py b/test/unit/common/middleware/s3api/test_obj.py index 79d4819946..1f1da8302c 100644 --- a/test/unit/common/middleware/s3api/test_obj.py +++ b/test/unit/common/middleware/s3api/test_obj.py @@ -629,8 +629,10 @@ class BaseS3ApiObj(object): code = self._test_method_error('PUT', '/bucket/object', swob.HTTPServerError) self.assertEqual(code, 'InternalError') - code = self._test_method_error('PUT', '/bucket/object', - swob.HTTPUnprocessableEntity) + code = self._test_method_error( + 'PUT', '/bucket/object', + swob.HTTPUnprocessableEntity, + headers={'Content-MD5': '1B2M2Y8AsgTpgAmY7PhCfg=='}) self.assertEqual(code, 'BadDigest') code = self._test_method_error('PUT', '/bucket/object', swob.HTTPLengthRequired) @@ -811,6 +813,31 @@ class BaseS3ApiObj(object): 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( '/bucket/object', environ={'REQUEST_METHOD': 'PUT'}, @@ -830,10 +857,11 @@ class BaseS3ApiObj(object): 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), 'BadDigest') - self.assertIn(b'X-Amz-Content-SHA56', body) - self.assertEqual('/v1/AUTH_test/bucket/object', - req.environ.get('swift.backend_path')) + self.assertEqual(self._get_error_code(body), + 'InvalidArgument') + self.assertIn(b'x-amz-content-sha256', + body) + self.assertNotIn('swift.backend_path', req.environ) def test_object_PUT_v4_unsigned_payload(self): req = Request.blank( diff --git a/test/unit/common/middleware/s3api/test_s3api.py b/test/unit/common/middleware/s3api/test_s3api.py index 0b1f2db368..c32b9f0743 100644 --- a/test/unit/common/middleware/s3api/test_s3api.py +++ b/test/unit/common/middleware/s3api/test_s3api.py @@ -1137,7 +1137,7 @@ class TestS3ApiMiddleware(S3ApiTestCase): headers = { 'Authorization': authz_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.content_type = 'text/plain' status, headers, body = self.call_s3api(req) diff --git a/test/unit/common/middleware/s3api/test_s3request.py b/test/unit/common/middleware/s3api/test_s3request.py index 344e4d7ce3..87276ff06a 100644 --- a/test/unit/common/middleware/s3api/test_s3request.py +++ b/test/unit/common/middleware/s3api/test_s3request.py @@ -33,8 +33,9 @@ from swift.common.middleware.s3api.s3request import S3Request, \ S3InputSHA256Mismatch from swift.common.middleware.s3api.s3response import InvalidArgument, \ NoSuchBucket, InternalError, ServiceUnavailable, \ - AccessDenied, SignatureDoesNotMatch, RequestTimeTooSkewed, BadDigest, \ - InvalidPartArgument, InvalidPartNumber, InvalidRequest + AccessDenied, SignatureDoesNotMatch, RequestTimeTooSkewed, \ + InvalidPartArgument, InvalidPartNumber, InvalidRequest, \ + XAmzContentSHA256Mismatch from swift.common.utils import md5 from test.debug_logger import debug_logger @@ -412,7 +413,7 @@ class TestRequest(S3ApiTestCase): 'Signature=X' % ( scope_date, ';'.join(sorted(['host', included_header]))), - 'X-Amz-Content-SHA256': '0123456789'} + 'X-Amz-Content-SHA256': '0' * 64} headers.update(date_header) req = Request.blank('/', environ=environ, headers=headers) @@ -594,7 +595,7 @@ class TestRequest(S3ApiTestCase): 'Credential=test/%s/us-east-1/s3/aws4_request, ' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' '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(), 'X-Amz-Date': x_amz_date} @@ -604,7 +605,7 @@ class TestRequest(S3ApiTestCase): headers_to_sign = sigv4_req._headers_to_sign() self.assertEqual(headers_to_sign, [ ('host', 'localhost:80'), - ('x-amz-content-sha256', '0123456789'), + ('x-amz-content-sha256', '0' * 64), ('x-amz-date', x_amz_date)]) # no x-amz-date @@ -614,7 +615,7 @@ class TestRequest(S3ApiTestCase): 'Credential=test/%s/us-east-1/s3/aws4_request, ' 'SignedHeaders=host;x-amz-content-sha256,' '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()} req = Request.blank('/', environ=environ, headers=headers) @@ -623,7 +624,7 @@ class TestRequest(S3ApiTestCase): headers_to_sign = sigv4_req._headers_to_sign() self.assertEqual(headers_to_sign, [ ('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 # X-Amz-Date header @@ -633,7 +634,7 @@ class TestRequest(S3ApiTestCase): 'Credential=test/%s/us-east-1/s3/aws4_request, ' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' '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()} req = Request.blank('/', environ=environ, headers=headers) @@ -730,7 +731,7 @@ class TestRequest(S3ApiTestCase): 'Credential=test/%s/us-east-1/s3/aws4_request, ' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' '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(), 'X-Amz-Date': x_amz_date} @@ -872,7 +873,7 @@ class TestRequest(S3ApiTestCase): 'Credential=test/%s/us-east-1/s3/aws4_request, ' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' '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 }) sigv4_req = SigV4Request( @@ -942,18 +943,18 @@ class TestRequest(S3ApiTestCase): # Virtual hosted-style self.s3api.conf.storage_domains = ['s3.test.com'] - # bad sha256 + # bad sha256 -- but note that SHAs are not checked for GET/HEAD! environ = { 'HTTP_HOST': 'bucket.s3.test.com', - 'REQUEST_METHOD': 'GET'} + 'REQUEST_METHOD': 'PUT'} headers = { 'Authorization': 'AWS4-HMAC-SHA256 ' 'Credential=test/20210104/us-east-1/s3/aws4_request, ' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' - 'Signature=f721a7941d5b7710344bc62cc45f87e66f4bb1dd00d9075ee61' - '5b1a5c72b0f8c', - 'X-Amz-Content-SHA256': 'bad', + 'Signature=5f31c77dbc63e7c6ffc84dae60a9261c57c44884fe7927baeb9' + '84f418d4d511a', + 'X-Amz-Content-SHA256': '0' * 64, 'Date': 'Mon, 04 Jan 2021 10:26:23 -0000', 'X-Amz-Date': '20210104T102623Z', 'Content-Length': 0, @@ -961,15 +962,15 @@ class TestRequest(S3ApiTestCase): # lowercase sha256 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') headers = { 'Authorization': 'AWS4-HMAC-SHA256 ' 'Credential=test/20210104/us-east-1/s3/aws4_request, ' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' - 'Signature=d90542e8b4c0d2f803162040a948e8e51db00b62a59ffb16682' - 'ef433718fde12', + 'Signature=96df261d8f0b617b7c6368e0c5d96ee61f1ec84005e826ece65' + 'c0e0f97eba945', 'X-Amz-Content-SHA256': sha256_of_nothing, 'Date': 'Mon, 04 Jan 2021 10:26:23 -0000', 'X-Amz-Date': '20210104T102623Z', @@ -981,14 +982,14 @@ class TestRequest(S3ApiTestCase): sigv4_req._canonical_request().endswith(sha256_of_nothing)) self.assertTrue(sigv4_req.check_signature('secret')) - # uppercase sha256 + # uppercase sha256 -- signature changes, but content's valid headers = { 'Authorization': 'AWS4-HMAC-SHA256 ' 'Credential=test/20210104/us-east-1/s3/aws4_request, ' 'SignedHeaders=host;x-amz-content-sha256;x-amz-date,' - 'Signature=4aab5102e58e9e40f331417d322465c24cac68a7ce77260e9bf' - '5ce9a6200862b', + 'Signature=7a3c396fd6043fb397888e6f4d6acc294a99636ff0bb57b283d' + '9e075ed87fce2', 'X-Amz-Content-SHA256': sha256_of_nothing.upper(), 'Date': 'Mon, 04 Jan 2021 10:26:23 -0000', 'X-Amz-Date': '20210104T102623Z', @@ -1000,6 +1001,91 @@ class TestRequest(S3ApiTestCase): sigv4_req._canonical_request().endswith(sha256_of_nothing.upper())) 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'XAmzContentSHA256Mismatch', + caught.exception.body) + self.assertIn( + ('%s' + % sha256_of_body).encode('ascii'), + caught.exception.body) + self.assertIn( + ('%s' + % 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): sw_req = Request.blank('/nojunk', environ={'REQUEST_METHOD': 'GET'}, @@ -1113,7 +1199,7 @@ class TestSigV4Request(S3ApiTestCase): x_amz_date = self.get_v4_amz_date_header() headers = { 'Authorization': auth, - 'X-Amz-Content-SHA256': '0123456789', + 'X-Amz-Content-SHA256': '0' * 64, 'Date': self.get_date_header(), 'X-Amz-Date': x_amz_date} req = Request.blank('/', environ=environ, headers=headers) @@ -1144,7 +1230,7 @@ class TestSigV4Request(S3ApiTestCase): x_amz_date = self.get_v4_amz_date_header() headers = { 'Authorization': auth, - 'X-Amz-Content-SHA256': '0123456789', + 'X-Amz-Content-SHA256': '0' * 64, 'Date': self.get_date_header(), 'X-Amz-Date': x_amz_date} req = Request.blank('/', environ=environ, headers=headers) @@ -1202,7 +1288,7 @@ class TestSigV4Request(S3ApiTestCase): x_amz_date = self.get_v4_amz_date_header() params['X-Amz-Date'] = x_amz_date signed_headers = { - 'X-Amz-Content-SHA256': '0123456789', + 'X-Amz-Content-SHA256': '0' * 64, 'Date': self.get_date_header(), 'X-Amz-Date': x_amz_date} req = Request.blank('/', environ=environ, headers=signed_headers, @@ -1237,7 +1323,7 @@ class TestSigV4Request(S3ApiTestCase): x_amz_date = self.get_v4_amz_date_header() params['X-Amz-Date'] = x_amz_date signed_headers = { - 'X-Amz-Content-SHA256': '0123456789', + 'X-Amz-Content-SHA256': '0' * 64, 'Date': self.get_date_header(), 'X-Amz-Date': x_amz_date} 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]) headers = { 'Authorization': auth, - 'X-Amz-Content-SHA256': '0123456789', + 'X-Amz-Content-SHA256': '0' * 64, 'Date': self.get_date_header(), '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]) headers = { 'Authorization': auth, - 'X-Amz-Content-SHA256': '0123456789', + 'X-Amz-Content-SHA256': '0' * 64, 'Date': self.get_date_header(), 'X-Amz-Date': x_amz_date} @@ -1438,10 +1524,12 @@ class TestHashingInput(S3ApiTestCase): self.assertTrue(wrapped._input.closed) def test_empty_bad_hash(self): - wrapped = HashingInput(BytesIO(b''), 0, hashlib.sha256, 'nope') - with self.assertRaises(S3InputSHA256Mismatch): - wrapped.read(3) - self.assertTrue(wrapped._input.closed) + _input = BytesIO(b'') + self.assertFalse(_input.closed) + with self.assertRaises(XAmzContentSHA256Mismatch): + # Don't even get a chance to try to read it + HashingInput(_input, 0, hashlib.sha256, 'nope') + self.assertTrue(_input.closed) if __name__ == '__main__':