Repository: localstack/localstack

Issue:
response-content-disposition is not honored on download
When generating a pre-signed URL for download and specifying response-content-disposition to set a target filename, the actual download does not contain the Content-Disposition header therefore the download file does not have the requested filename.

I have confirmed that this works corrected in S3 itself.

I'd be happy to explore a fix if anyone can point me in the right direction/location.


Discussion:

Resolving patch:
diff --git a/localstack/services/s3/s3_listener.py b/localstack/services/s3/s3_listener.py
--- a/localstack/services/s3/s3_listener.py
+++ b/localstack/services/s3/s3_listener.py
@@ -43,6 +43,16 @@
 # list of destination types for bucket notifications
 NOTIFICATION_DESTINATION_TYPES = ('Queue', 'Topic', 'CloudFunction', 'LambdaFunction')
 
+# response header overrides the client may request
+ALLOWED_HEADER_OVERRIDES = {
+    'response-content-type': 'Content-Type',
+    'response-content-language': 'Content-Language',
+    'response-expires': 'Expires',
+    'response-cache-control': 'Cache-Control',
+    'response-content-disposition': 'Content-Disposition',
+    'response-content-encoding': 'Content-Encoding',
+}
+
 
 def event_type_matches(events, action, api_method):
     """ check whether any of the event types in `events` matches the
@@ -649,6 +659,14 @@ def return_response(self, method, path, data, headers, response):
             except Exception:
                 pass
 
+            # Honor response header overrides
+            # https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGET.html
+            if method == 'GET':
+                query_map = urlparse.parse_qs(parsed.query, keep_blank_values=True)
+                for param_name, header_name in ALLOWED_HEADER_OVERRIDES.items():
+                    if param_name in query_map:
+                        response.headers[header_name] = query_map[param_name][0]
+
             # We need to un-pretty-print the XML, otherwise we run into this issue with Spark:
             # https://github.com/jserver/mock-s3/pull/9/files
             # https://github.com/localstack/localstack/issues/183


Test patch:
diff --git a/tests/integration/test_s3.py b/tests/integration/test_s3.py
--- a/tests/integration/test_s3.py
+++ b/tests/integration/test_s3.py
@@ -320,6 +320,40 @@ def test_s3_get_response_headers(self):
         # clean up
         self._delete_bucket(bucket_name, [object_key])
 
+    def test_s3_get_response_header_overrides(self):
+        # Signed requests may include certain header overrides in the querystring
+        # https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGET.html
+
+        bucket_name = 'test-bucket-%s' % short_uid()
+        self.s3_client.create_bucket(Bucket=bucket_name)
+
+        # put object
+        object_key = 'key-by-hostname'
+        self.s3_client.put_object(Bucket=bucket_name, Key=object_key, Body='something')
+
+        # get object and assert headers
+        url = self.s3_client.generate_presigned_url(
+            'get_object', Params={
+                'Bucket': bucket_name,
+                'Key': object_key,
+                'ResponseCacheControl': 'max-age=74',
+                'ResponseContentDisposition': 'attachment; filename="foo.jpg"',
+                'ResponseContentEncoding': 'identity',
+                'ResponseContentLanguage': 'de-DE',
+                'ResponseContentType': 'image/jpeg',
+                'ResponseExpires': 'Wed, 21 Oct 2015 07:28:00 GMT'}
+        )
+        response = requests.get(url, verify=False)
+
+        self.assertEqual(response.headers['cache-control'], 'max-age=74')
+        self.assertEqual(response.headers['content-disposition'], 'attachment; filename="foo.jpg"')
+        self.assertEqual(response.headers['content-encoding'], 'identity')
+        self.assertEqual(response.headers['content-language'], 'de-DE')
+        self.assertEqual(response.headers['content-type'], 'image/jpeg')
+        self.assertEqual(response.headers['expires'], '2015-10-21T07:28:00Z')
+        # clean up
+        self._delete_bucket(bucket_name, [object_key])
+
     def test_s3_copy_md5(self):
         bucket_name = 'test-bucket-%s' % short_uid()
         self.s3_client.create_bucket(Bucket=bucket_name)