Repository: getredash/redash

Issue:
Queries with unicode in query name fails downloading result file
This just happened on deploy preview. Sentry error:
https://sentry.io/share/issue/b8268b7c97784a30b67c512332a8d779/

Some possible solutions:

1. Implement a solution similar to pallets/flask#2223.
2. Switch to using Flask's `send_file` method instead of handling this on our own (need to make sure that we always have a file-like object at hand and that the behavior consistent with our current one).
3. Try quoting the filename, as suggested here: https://github.com/benoitc/gunicorn/issues/1214#issuecomment-238193505.

3 is the simplest, 2 is probably the most future proof.


Discussion:
+1 on (2)

Resolving patch:
diff --git a/redash/handlers/query_results.py b/redash/handlers/query_results.py
--- a/redash/handlers/query_results.py
+++ b/redash/handlers/query_results.py
@@ -1,9 +1,11 @@
 import logging
 import time
 
+import unicodedata
 from flask import make_response, request
 from flask_login import current_user
 from flask_restful import abort
+from werkzeug.urls import url_quote
 from redash import models, settings
 from redash.handlers.base import BaseResource, get_object_or_404, record_event
 from redash.permissions import (
@@ -128,6 +130,25 @@ def get_download_filename(query_result, query, filetype):
     return "{}_{}.{}".format(filename, retrieved_at, filetype)
 
 
+def content_disposition_filenames(attachment_filename):
+    if not isinstance(attachment_filename, str):
+        attachment_filename = attachment_filename.decode("utf-8")
+
+    try:
+        attachment_filename = attachment_filename.encode("ascii")
+    except UnicodeEncodeError:
+        filenames = {
+            "filename": unicodedata.normalize("NFKD", attachment_filename).encode(
+                "ascii", "ignore"
+            ),
+            "filename*": "UTF-8''%s" % url_quote(attachment_filename, safe=b""),
+        }
+    else:
+        filenames = {"filename": attachment_filename}
+
+    return filenames
+
+
 class QueryResultListResource(BaseResource):
     @require_permission("execute_query")
     def post(self):
@@ -381,9 +402,8 @@ def get(self, query_id=None, query_result_id=None, filetype="json"):
 
             filename = get_download_filename(query_result, query, filetype)
 
-            response.headers.add_header(
-                "Content-Disposition", 'attachment; filename="{}"'.format(filename)
-            )
+            filenames = content_disposition_filenames(filename)
+            response.headers.add("Content-Disposition", "attachment", **filenames)
 
             return response
 


Test patch:
diff --git a/tests/handlers/test_query_results.py b/tests/handlers/test_query_results.py
--- a/tests/handlers/test_query_results.py
+++ b/tests/handlers/test_query_results.py
@@ -29,6 +29,19 @@ def test_returns_404_if_no_cached_result_found(self):
         self.assertEqual(404, rv.status_code)
 
 
+class TestQueryResultsContentDispositionHeaders(BaseTestCase):
+    def test_supports_unicode(self):
+        query_result = self.factory.create_query_result()
+        query = self.factory.create_query(name="עברית", latest_query_data=query_result)
+
+        rv = self.make_request("get", "/api/queries/{}/results.json".format(query.id))
+        # This is what gunicorn will do with it
+        try:
+            rv.headers['Content-Disposition'].encode('ascii')
+        except Exception as e:
+            self.fail(repr(e))            
+
+
 class TestQueryResultListAPI(BaseTestCase):
     def test_get_existing_result(self):
         query_result = self.factory.create_query_result()