Properly strip headers for request signing

Previously, when clients sent non-printable characters in headers, we
might reply with InvalidDigest or some other flavor of 400 while AWS
would have sent a 403 SignatureDoesNotMatch in a similar situation.

See, for example, the removed known failures for ceph/s3-tests.

Additionally, factor out canonical request from string-to-sign for
SigV4Mixin. This simplifies the diagnosing of certain unit test failures.

Change-Id: I703d3db134b8e2202d271eca018b920fbedd08e7
This commit is contained in:
Tim Burke 2016-08-02 11:28:58 -07:00
parent 806f7bb500
commit 710738b548
4 changed files with 81 additions and 37 deletions

View File

@ -73,6 +73,19 @@ SIGV2_TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%S'
SIGV4_X_AMZ_DATE_FORMAT = '%Y%m%dT%H%M%SZ' SIGV4_X_AMZ_DATE_FORMAT = '%Y%m%dT%H%M%SZ'
def _header_strip(value):
# S3 seems to strip *all* control characters
if value is None:
return None
stripped = _header_strip.re.sub('', value)
if value and not stripped:
# If there's nothing left after stripping,
# behave as though it wasn't provided
return None
return stripped
_header_strip.re = re.compile('^[\x00-\x20]*|[\x00-\x20]*$')
def _header_acl_property(resource): def _header_acl_property(resource):
""" """
Set and retrieve the acl in self.headers Set and retrieve the acl in self.headers
@ -236,7 +249,7 @@ class SigV4Mixin(object):
:return : dict of headers to sign, the keys are all lower case :return : dict of headers to sign, the keys are all lower case
""" """
headers_lower_dict = dict( headers_lower_dict = dict(
(k.lower().strip(), ' '.join((v or '').strip().split())) (k.lower().strip(), ' '.join(_header_strip(v or '').split()))
for (k, v) in six.iteritems(self.headers)) for (k, v) in six.iteritems(self.headers))
if 'host' in headers_lower_dict and re.match( if 'host' in headers_lower_dict and re.match(
@ -268,13 +281,7 @@ class SigV4Mixin(object):
""" """
return self.environ.get('RAW_PATH_INFO', self.path) return self.environ.get('RAW_PATH_INFO', self.path)
def _string_to_sign(self): def _canonical_request(self):
"""
Create 'StringToSign' value in Amazon terminology for v4.
"""
scope = (self.timestamp.amz_date_format.split('T')[0] +
'/' + CONF.location + '/s3/aws4_request')
# prepare 'canonical_request' # prepare 'canonical_request'
# Example requests are like following: # Example requests are like following:
# #
@ -323,12 +330,19 @@ class SigV4Mixin(object):
else: else:
hashed_payload = self.headers['X-Amz-Content-SHA256'] hashed_payload = self.headers['X-Amz-Content-SHA256']
cr.append(hashed_payload) cr.append(hashed_payload)
canonical_request = '\n'.join(cr) return '\n'.join(cr).encode('utf-8')
def _string_to_sign(self):
"""
Create 'StringToSign' value in Amazon terminology for v4.
"""
scope = (self.timestamp.amz_date_format.split('T')[0] +
'/' + CONF.location + '/s3/aws4_request')
return ('AWS4-HMAC-SHA256' + '\n' return ('AWS4-HMAC-SHA256' + '\n'
+ self.timestamp.amz_date_format + '\n' + self.timestamp.amz_date_format + '\n'
+ scope + '\n' + scope + '\n'
+ sha256(canonical_request.encode('utf-8')).hexdigest()) + sha256(self._canonical_request()).hexdigest())
def get_request_class(env): def get_request_class(env):
@ -571,8 +585,8 @@ class Request(swob.Request):
self._validate_dates() self._validate_dates()
if 'Content-MD5' in self.headers: value = _header_strip(self.headers.get('Content-MD5'))
value = self.headers['Content-MD5'] if value is not None:
if not re.match('^[A-Za-z0-9+/]+={0,2}$', value): if not re.match('^[A-Za-z0-9+/]+={0,2}$', value):
# Non-base64-alphabet characters in value. # Non-base64-alphabet characters in value.
raise InvalidDigest(content_md5=value) raise InvalidDigest(content_md5=value)
@ -725,9 +739,9 @@ class Request(swob.Request):
""" """
amz_headers = {} amz_headers = {}
buf = "%s\n%s\n%s\n" % (self.method, buf = [self.method,
self.headers.get('Content-MD5', ''), _header_strip(self.headers.get('Content-MD5')) or '',
self.headers.get('Content-Type') or '') _header_strip(self.headers.get('Content-Type')) or '']
for amz_header in sorted((key.lower() for key in self.headers for amz_header in sorted((key.lower() for key in self.headers
if key.lower().startswith('x-amz-'))): if key.lower().startswith('x-amz-'))):
@ -735,32 +749,33 @@ class Request(swob.Request):
if self._is_header_auth: if self._is_header_auth:
if 'x-amz-date' in amz_headers: if 'x-amz-date' in amz_headers:
buf += "\n" buf.append('')
elif 'Date' in self.headers: elif 'Date' in self.headers:
buf += "%s\n" % self.headers['Date'] buf.append(self.headers['Date'])
elif self._is_query_auth: elif self._is_query_auth:
buf += "%s\n" % self.params['Expires'] buf.append(self.params['Expires'])
else: else:
# Should have already raised NotS3Request in _parse_auth_info, # Should have already raised NotS3Request in _parse_auth_info,
# but as a sanity check... # but as a sanity check...
raise AccessDenied() raise AccessDenied()
for k in sorted(key.lower() for key in amz_headers): for k in sorted(key.lower() for key in amz_headers):
buf += "%s:%s\n" % (k, amz_headers[k]) buf.append("%s:%s" % (k, amz_headers[k]))
path = self._canonical_uri() path = self._canonical_uri()
if self.query_string: if self.query_string:
path += '?' + self.query_string path += '?' + self.query_string
params = []
if '?' in path: if '?' in path:
path, args = path.split('?', 1) path, args = path.split('?', 1)
params = []
for key, value in sorted(self.params.items()): for key, value in sorted(self.params.items()):
if key in ALLOWED_SUB_RESOURCES: if key in ALLOWED_SUB_RESOURCES:
params.append('%s=%s' % (key, value) if value else key) params.append('%s=%s' % (key, value) if value else key)
if params: if params:
return '%s%s?%s' % (buf, path, '&'.join(params)) buf.append('%s?%s' % (path, '&'.join(params)))
else:
return buf + path buf.append(path)
return '\n'.join(buf)
@property @property
def controller_name(self): def controller_name(self):

View File

@ -3,13 +3,9 @@ ceph_s3:
<nose.suite.ContextSuite context=test_routing_generator>:setup: {status: KNOWN} <nose.suite.ContextSuite context=test_routing_generator>:setup: {status: KNOWN}
s3tests.functional.test_headers.test_bucket_create_bad_authorization_invalid_aws2: {status: KNOWN} s3tests.functional.test_headers.test_bucket_create_bad_authorization_invalid_aws2: {status: KNOWN}
s3tests.functional.test_headers.test_bucket_create_bad_authorization_none: {status: KNOWN} s3tests.functional.test_headers.test_bucket_create_bad_authorization_none: {status: KNOWN}
s3tests.functional.test_headers.test_bucket_create_bad_contentlength_empty: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_authorization_invalid_aws2: {status: KNOWN} s3tests.functional.test_headers.test_object_create_bad_authorization_invalid_aws2: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_authorization_none: {status: KNOWN} s3tests.functional.test_headers.test_object_create_bad_authorization_none: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_contentlength_empty: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_contenttype_unreadable: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_date_after_end_aws2: {status: KNOWN} s3tests.functional.test_headers.test_object_create_bad_date_after_end_aws2: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_md5_unreadable: {status: KNOWN}
s3tests.functional.test_s3.test_100_continue: {status: KNOWN} s3tests.functional.test_s3.test_100_continue: {status: KNOWN}
s3tests.functional.test_s3.test_abort_multipart_upload: {status: KNOWN} s3tests.functional.test_s3.test_abort_multipart_upload: {status: KNOWN}
s3tests.functional.test_s3.test_abort_multipart_upload_not_found: {status: KNOWN} s3tests.functional.test_s3.test_abort_multipart_upload_not_found: {status: KNOWN}

View File

@ -3,13 +3,9 @@ ceph_s3:
<nose.suite.ContextSuite context=test_routing_generator>:setup: {status: KNOWN} <nose.suite.ContextSuite context=test_routing_generator>:setup: {status: KNOWN}
s3tests.functional.test_headers.test_bucket_create_bad_authorization_invalid_aws2: {status: KNOWN} s3tests.functional.test_headers.test_bucket_create_bad_authorization_invalid_aws2: {status: KNOWN}
s3tests.functional.test_headers.test_bucket_create_bad_authorization_none: {status: KNOWN} s3tests.functional.test_headers.test_bucket_create_bad_authorization_none: {status: KNOWN}
s3tests.functional.test_headers.test_bucket_create_bad_contentlength_empty: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_authorization_invalid_aws2: {status: KNOWN} s3tests.functional.test_headers.test_object_create_bad_authorization_invalid_aws2: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_authorization_none: {status: KNOWN} s3tests.functional.test_headers.test_object_create_bad_authorization_none: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_contentlength_empty: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_contenttype_unreadable: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_date_after_end_aws2: {status: KNOWN} s3tests.functional.test_headers.test_object_create_bad_date_after_end_aws2: {status: KNOWN}
s3tests.functional.test_headers.test_object_create_bad_md5_unreadable: {status: KNOWN}
s3tests.functional.test_s3.test_100_continue: {status: KNOWN} s3tests.functional.test_s3.test_100_continue: {status: KNOWN}
s3tests.functional.test_s3.test_atomic_conditional_write_1mb: {status: KNOWN} s3tests.functional.test_s3.test_atomic_conditional_write_1mb: {status: KNOWN}
s3tests.functional.test_s3.test_atomic_dual_conditional_write_1mb: {status: KNOWN} s3tests.functional.test_s3.test_atomic_dual_conditional_write_1mb: {status: KNOWN}

View File

@ -163,6 +163,19 @@ class TestSwift3Middleware(Swift3TestCase):
self.assertEqual(str1, str2) self.assertEqual(str1, str2)
self.assertEqual(str2, str3) self.assertEqual(str2, str3)
# Note that boto does not do proper stripping (as of 2.42.0).
# These were determined by examining the StringToSignBytes element of
# resulting SignatureDoesNotMatch errors from AWS.
str1 = canonical_string('/', {'Content-Type': 'text/plain',
'Content-MD5': '##'})
str2 = canonical_string('/', {'Content-Type': '\x01\x02text/plain',
'Content-MD5': '\x1f ##'})
str3 = canonical_string('/', {'Content-Type': 'text/plain \x10',
'Content-MD5': '##\x18'})
self.assertEqual(str1, str2)
self.assertEqual(str2, str3)
def test_signed_urls_expired(self): def test_signed_urls_expired(self):
expire = '1000000000' expire = '1000000000'
req = Request.blank('/bucket/object?Signature=X&Expires=%s&' req = Request.blank('/bucket/object?Signature=X&Expires=%s&'
@ -652,7 +665,7 @@ class TestSwift3Middleware(Swift3TestCase):
test(auth_str, 'AccessDenied', 'Access Denied.') test(auth_str, 'AccessDenied', 'Access Denied.')
def test_canonical_string_v4(self): def test_canonical_string_v4(self):
def canonical_string(path, environ): def _get_req(path, environ):
if '?' in path: if '?' in path:
path, query_string = path.split('?', 1) path, query_string = path.split('?', 1)
else: else:
@ -663,17 +676,28 @@ class TestSwift3Middleware(Swift3TestCase):
'PATH_INFO': path, 'PATH_INFO': path,
'QUERY_STRING': query_string, 'QUERY_STRING': query_string,
'HTTP_DATE': 'Mon, 09 Sep 2011 23:36:00 GMT', 'HTTP_DATE': 'Mon, 09 Sep 2011 23:36:00 GMT',
'HTTP_X_AMZ_CONTENT_SHA256': ( 'HTTP_X_AMZ_CONTENT_SHA256':
'e3b0c44298fc1c149afbf4c8996fb924' 'e3b0c44298fc1c149afbf4c8996fb924'
'27ae41e4649b934ca495991b7852b855') '27ae41e4649b934ca495991b7852b855',
'HTTP_AUTHORIZATION':
'AWS4-HMAC-SHA256 '
'Credential=X:Y/dt/reg/host/blah, '
'SignedHeaders=content-md5;content-type;date, '
'Signature=x',
} }
env.update(environ) env.update(environ)
with patch('swift3.request.Request._validate_headers'): with patch('swift3.request.Request._validate_headers'):
req = SigV4Request(env) req = SigV4Request(env)
return req._string_to_sign() return req
def string_to_sign(path, environ):
return _get_req(path, environ)._string_to_sign()
def canonical_string(path, environ):
return _get_req(path, environ)._canonical_request()
def verify(hash_val, path, environ): def verify(hash_val, path, environ):
s = canonical_string(path, environ) s = string_to_sign(path, environ)
s = s.split('\n')[3] s = s.split('\n')[3]
self.assertEqual(hash_val, s) self.assertEqual(hash_val, s)
@ -787,6 +811,19 @@ class TestSwift3Middleware(Swift3TestCase):
'1b14f04345cbfe6e739236c76dd48f74', '1b14f04345cbfe6e739236c76dd48f74',
'/', env) '/', env)
# Note that boto does not do proper stripping (as of 2.42.0).
# These were determined by examining the StringToSignBytes element of
# resulting SignatureDoesNotMatch errors from AWS.
str1 = canonical_string('/', {'CONTENT_TYPE': 'text/plain',
'HTTP_CONTENT_MD5': '##'})
str2 = canonical_string('/', {'CONTENT_TYPE': '\x01\x02text/plain',
'HTTP_CONTENT_MD5': '\x1f ##'})
str3 = canonical_string('/', {'CONTENT_TYPE': 'text/plain \x10',
'HTTP_CONTENT_MD5': '##\x18'})
self.assertEqual(str1, str2)
self.assertEqual(str2, str3)
def test_mixture_param_v4(self): def test_mixture_param_v4(self):
# now we have an Authorization header # now we have an Authorization header
headers = { headers = {