Repository: tornadoweb/tornado
Pull request #2411 (2018-06-03 03:22:37)

httputil: Support non-ascii filenames in multipart uploads
The encoding defined in RFC 2231/5987 is used by some HTTP clients (notably `requests`) when uploading a file with a non-ascii name. RFC 7578 disallows this encoding, but its predecessor RFC 2388 is ambiguous and the related RFC 6266 uses this encoding for a closely related purpose. (more details in #1898).

We had two PRs open to address this issue, #869 and #1898. This PR is based on #869 because it was the closest to working. 


Patch:
diff --git a/tornado/httputil.py b/tornado/httputil.py
index d1ace5a8..eabe11b0 100644
--- a/tornado/httputil.py
+++ b/tornado/httputil.py
@@ -891,7 +891,8 @@ def parse_response_start_line(line):
 # The original 2.7 version of this code did not correctly support some
 # combinations of semicolons and double quotes.
 # It has also been modified to support valueless parameters as seen in
-# websocket extension negotiations.
+# websocket extension negotiations, and to support non-ascii values in
+# RFC 2231/5987 format.
 
 
 def _parseparam(s):
@@ -908,25 +909,37 @@ def _parseparam(s):
 
 
 def _parse_header(line):
-    """Parse a Content-type like header.
+    r"""Parse a Content-type like header.
 
     Return the main content-type and a dictionary of options.
 
+    >>> d = "form-data; foo=\"b\\\\a\\\"r\"; file*=utf-8''T%C3%A4st"
+    >>> ct, d = _parse_header(d)
+    >>> ct
+    'form-data'
+    >>> d['file'] == r'T\u00e4st'.encode('ascii').decode('unicode_escape')
+    True
+    >>> d['foo']
+    'b\\a"r'
     """
     parts = _parseparam(';' + line)
     key = next(parts)
-    pdict = {}
+    # decode_params treats first argument special, but we already stripped key
+    params = [('Dummy', 'value')]
     for p in parts:
         i = p.find('=')
         if i >= 0:
             name = p[:i].strip().lower()
             value = p[i + 1:].strip()
-            if len(value) >= 2 and value[0] == value[-1] == '"':
-                value = value[1:-1]
-                value = value.replace('\\\\', '\\').replace('\\"', '"')
-            pdict[name] = value
-        else:
-            pdict[p] = None
+            params.append((name, native_str(value)))
+    params = email.utils.decode_params(params)
+    params.pop(0)  # get rid of the dummy again
+    pdict = {}
+    for name, value in params:
+        value = email.utils.collapse_rfc2231_value(value)
+        if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
+            value = value[1:-1]
+        pdict[name] = value
     return key, pdict
 
 


Test patch:
diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py
index bd5832bc..faf7717f 100644
--- a/tornado/test/httputil_test.py
+++ b/tornado/test/httputil_test.py
@@ -176,6 +176,20 @@ Foo
             self.assertEqual(file["filename"], filename)
             self.assertEqual(file["body"], b"Foo")
 
+    def test_non_ascii_filename(self):
+        data = b"""\
+--1234
+Content-Disposition: form-data; name="files"; filename="ab.txt"; filename*=UTF-8''%C3%A1b.txt
+
+Foo
+--1234--""".replace(b"\n", b"\r\n")
+        args = {}
+        files = {}
+        parse_multipart_form_data(b"1234", data, args, files)
+        file = files["files"][0]
+        self.assertEqual(file["filename"], u"áb.txt")
+        self.assertEqual(file["body"], b"Foo")
+
     def test_boundary_starts_and_ends_with_quotes(self):
         data = b'''\
 --1234