From c7273f3054eeed482c796a07313b1ccb37852db1 Mon Sep 17 00:00:00 2001
From: Tim Burke <tim.burke@gmail.com>
Date: Tue, 14 Jun 2016 16:35:08 -0700
Subject: [PATCH] Add more validation for pre-signed URL expiration

Apparently v2 signatures allow any timestamp that fits in a signed 32-bit
integer, while v4 signatures can be valid for at most a week. See also:
http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html

Pick up a couple minor cleanups while we're at it.

Change-Id: I4ebaa67607bac35a9df266ed624c97ad2c112c0d
---
 swift3/request.py                        | 31 +++++++++---
 swift3/response.py                       |  4 ++
 swift3/test/functional/test_presigned.py | 61 +++++++++++++++++++++++-
 3 files changed, 89 insertions(+), 7 deletions(-)

diff --git a/swift3/request.py b/swift3/request.py
index bcee9865..24576ecf 100644
--- a/swift3/request.py
+++ b/swift3/request.py
@@ -46,7 +46,7 @@ from swift3.response import AccessDenied, InvalidArgument, InvalidDigest, \
     InternalError, NoSuchBucket, NoSuchKey, PreconditionFailed, InvalidRange, \
     MissingContentLength, InvalidStorageClass, S3NotImplemented, InvalidURI, \
     MalformedXML, InvalidRequest, RequestTimeout, InvalidBucketName, \
-    BadDigest, AuthorizationHeaderMalformed
+    BadDigest, AuthorizationHeaderMalformed, AuthorizationQueryParametersError
 from swift3.exception import NotS3Request, BadSwiftRequest
 from swift3.utils import utf8encode, LOGGER, check_path_header, S3Timestamp, \
     mktime
@@ -95,8 +95,6 @@ def _header_acl_property(resource):
 class SigV4Mixin(object):
     """
     A request class mixin to provide S3 signature v4 functionality
-
-    :param req_cls: a Request class (Request or S3AclRequest or child classes)
     """
 
     @property
@@ -139,8 +137,24 @@ class SigV4Mixin(object):
         """
         :param now: a S3Timestamp instance
         """
-        expires = self.params['X-Amz-Expires']
-        if int(self.timestamp) + int(expires) < S3Timestamp.now():
+        err = None
+        try:
+            expires = int(self.params['X-Amz-Expires'])
+        except ValueError:
+            err = 'X-Amz-Expires should be a number'
+        else:
+            if expires < 0:
+                err = 'X-Amz-Expires must be non-negative'
+            elif expires >= 2 ** 63:
+                err = 'X-Amz-Expires should be a number'
+            elif expires > 604800:
+                err = ('X-Amz-Expires must be less than a week (in seconds); '
+                       'that is, the given X-Amz-Expires must be less than '
+                       '604800 seconds')
+        if err:
+            raise AuthorizationQueryParametersError(err)
+
+        if int(self.timestamp) + expires < S3Timestamp.now():
             raise AccessDenied('Request has expired')
 
     def _parse_query_authentication(self):
@@ -288,7 +302,7 @@ class SigV4Mixin(object):
 
         # 5. Add signed headers into canonical request like
         # content-type;host;x-amz-date
-        cr.append(';'.join(sorted(n for n in headers_to_sign)))
+        cr.append(';'.join(sorted(headers_to_sign)))
 
         # 6. Add payload string at the tail
         if 'X-Amz-Credential' in self.params:
@@ -503,6 +517,11 @@ class Request(swob.Request):
         if S3Timestamp.now() > ex:
             raise AccessDenied('Request has expired')
 
+        if ex >= 2 ** 31:
+            raise AccessDenied(
+                'Invalid date (should be seconds since epoch): %s' %
+                self.params['Expires'])
+
     def _validate_dates(self):
         if self._is_query_auth:
             self._validate_expire_param()
diff --git a/swift3/response.py b/swift3/response.py
index 5482ef8b..8f9cf0fa 100644
--- a/swift3/response.py
+++ b/swift3/response.py
@@ -230,6 +230,10 @@ class AuthorizationHeaderMalformed(ErrorResponse):
            'and Signature.'
 
 
+class AuthorizationQueryParametersError(ErrorResponse):
+    _status = '400 Bad Request'
+
+
 class BadDigest(ErrorResponse):
     _status = '400 Bad Request'
     _msg = 'The Content-MD5 you specified did not match what we received.'
diff --git a/swift3/test/functional/test_presigned.py b/swift3/test/functional/test_presigned.py
index 77628bf3..49643887 100644
--- a/swift3/test/functional/test_presigned.py
+++ b/swift3/test/functional/test_presigned.py
@@ -21,7 +21,7 @@ import requests
 from swift3.etree import fromstring
 from swift3.cfg import CONF
 from swift3.test.functional import Swift3FunctionalTestCase
-from swift3.test.functional.utils import get_error_code
+from swift3.test.functional.utils import get_error_code, get_error_msg
 
 
 class TestSwift3PresignedUrls(Swift3FunctionalTestCase):
@@ -96,6 +96,65 @@ class TestSwift3PresignedUrls(Swift3FunctionalTestCase):
         self.assertEqual(resp.status_code, 204,
                          'Got %d %s' % (resp.status_code, resp.content))
 
+    def test_expiration_limits(self):
+        if os.environ.get('S3_USE_SIGV4'):
+            self._test_expiration_limits_v4()
+        else:
+            self._test_expiration_limits_v2()
+
+    def _test_expiration_limits_v2(self):
+        bucket = 'test-bucket'
+
+        # Expiration date is too far in the future
+        url, headers = self.conn.generate_url_and_headers(
+            'GET', bucket, expires_in=2 ** 32)
+        resp = requests.get(url, headers=headers)
+        self.assertEqual(resp.status_code, 403,
+                         'Got %d %s' % (resp.status_code, resp.content))
+        self.assertEqual(get_error_code(resp.content),
+                         'AccessDenied')
+        self.assertIn('Invalid date (should be seconds since epoch)',
+                      get_error_msg(resp.content))
+
+    def _test_expiration_limits_v4(self):
+        bucket = 'test-bucket'
+
+        # Expiration is negative
+        url, headers = self.conn.generate_url_and_headers(
+            'GET', bucket, expires_in=-1)
+        resp = requests.get(url, headers=headers)
+        self.assertEqual(resp.status_code, 400,
+                         'Got %d %s' % (resp.status_code, resp.content))
+        self.assertEqual(get_error_code(resp.content),
+                         'AuthorizationQueryParametersError')
+        self.assertIn('X-Amz-Expires must be non-negative',
+                      get_error_msg(resp.content))
+
+        # Expiration date is too far in the future
+        for exp in (7 * 24 * 60 * 60 + 1,
+                    2 ** 63 - 1):
+            url, headers = self.conn.generate_url_and_headers(
+                'GET', bucket, expires_in=exp)
+            resp = requests.get(url, headers=headers)
+            self.assertEqual(resp.status_code, 400,
+                             'Got %d %s' % (resp.status_code, resp.content))
+            self.assertEqual(get_error_code(resp.content),
+                             'AuthorizationQueryParametersError')
+            self.assertIn('X-Amz-Expires must be less than 604800 seconds',
+                          get_error_msg(resp.content))
+
+        # Expiration date is *way* too far in the future, or isn't a number
+        for exp in (2 ** 63, 'foo'):
+            url, headers = self.conn.generate_url_and_headers(
+                'GET', bucket, expires_in=2 ** 63)
+            resp = requests.get(url, headers=headers)
+            self.assertEqual(resp.status_code, 400,
+                             'Got %d %s' % (resp.status_code, resp.content))
+            self.assertEqual(get_error_code(resp.content),
+                             'AuthorizationQueryParametersError')
+            self.assertEqual('X-Amz-Expires should be a number',
+                             get_error_msg(resp.content))
+
     def test_object(self):
         bucket = 'test-bucket'
         obj = 'object'