Repository: pallets/werkzeug
Pull request #1417 (2018-12-09 19:47:15)

Rfc2231 parameter continuation
Continues, closes #549

Previous PR only supported a fixed number of continuations in the filename parameter during form parsing. This moves continuation handling to `parse_options_header`, so any header and option will support continuations.

The RFC says that only the first line needs to specify an encoding, and the other lines should inherit it. We keep track of the encoding, but allow any continuation to change it, since it shouldn't really affect anything.

Each continuation has an index, but the RFC says they should only come in sequential order without gaps, so the index is ignored and the values are appended in the order they're seen.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 63decd35..04d93e7e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -176,6 +176,9 @@ Unreleased
     -   ``testtools`` is removed. It did not offer significant benefit
         over the default test client.
 
+-   :func:`~http.parse_options_header` understands :rfc:`2231` parameter
+    continuations. (`#1417`_)
+
 .. _#4: https://github.com/pallets/werkzeug/issues/4
 .. _`#209`: https://github.com/pallets/werkzeug/pull/209
 .. _`#609`: https://github.com/pallets/werkzeug/pull/609
@@ -233,6 +236,7 @@ Unreleased
 .. _#1412: https://github.com/pallets/werkzeug/pull/1412
 .. _#1413: https://github.com/pallets/werkzeug/pull/1413
 .. _#1416: https://github.com/pallets/werkzeug/pull/1416
+.. _#1417: https://github.com/pallets/werkzeug/pull/1417
 
 
 Version 0.14.1
diff --git a/werkzeug/http.py b/werkzeug/http.py
index 0e96d6b6..fe85c66a 100644
--- a/werkzeug/http.py
+++ b/werkzeug/http.py
@@ -64,18 +64,21 @@ _token_chars = frozenset("!#$%&'*+-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
 _etag_re = re.compile(r'([Ww]/)?(?:"(.*?)"|(.*?))(?:\s*,\s*|$)')
 _unsafe_header_chars = set('()<>@,;:\"/[]?={} \t')
 _option_header_piece_re = re.compile(r'''
-    ;\s*
+    ;\s*,?\s*  # newlines were replaced with commas
     (?P<key>
         "[^"\\]*(?:\\.[^"\\]*)*"  # quoted string
     |
         [^\s;,=*]+  # token
     )
+    (?:\*(?P<count>\d+))?  # *1, optional continuation index
     \s*
     (?:  # optionally followed by =value
         (?:  # equals sign, possibly with encoding
             \*\s*=\s*  # * indicates extended notation
-            (?P<encoding>[^\s]+?)
-            '(?P<language>[^\s]*?)'
+            (?:  # optional encoding
+                (?P<encoding>[^\s]+?)
+                '(?P<language>[^\s]*?)'
+            )?
         |
             =\s*  # basic notation
         )
@@ -355,6 +358,9 @@ def parse_options_header(value, multiple=False):
     a slightly different format.  For these headers use the
     :func:`parse_dict_header` function.
 
+    .. versionchanged:: 0.15
+        :rfc:`2231` parameter continuations are handled.
+
     .. versionadded:: 0.5
 
     :param value: the header to parse.
@@ -376,11 +382,22 @@ def parse_options_header(value, multiple=False):
         options = {}
         # Parse options
         rest = match.group(2)
+        continued_encoding = None
         while rest:
             optmatch = _option_header_piece_re.match(rest)
             if not optmatch:
                 break
-            option, encoding, _, option_value = optmatch.groups()
+            option, count, encoding, language, option_value = optmatch.groups()
+            # Continuations don't have to supply the encoding after the
+            # first line. If we're in a continuation, track the current
+            # encoding to use for subsequent lines. Reset it when the
+            # continuation ends.
+            if not count:
+                continued_encoding = None
+            else:
+                if not encoding:
+                    encoding = continued_encoding
+                continued_encoding = encoding
             option = unquote_header_value(option)
             if option_value is not None:
                 option_value = unquote_header_value(
@@ -388,7 +405,13 @@ def parse_options_header(value, multiple=False):
                     option == 'filename')
                 if encoding is not None:
                     option_value = _unquote(option_value).decode(encoding)
-            options[option] = option_value
+            if count:
+                # Continuations append to the existing value. For
+                # simplicity, this ignores the possibility of
+                # out-of-order indices, which shouldn't happen anyway.
+                options[option] = options.get(option, "") + option_value
+            else:
+                options[option] = option_value
             rest = rest[optmatch.end():]
         result.append(options)
         if multiple is False:


Test patch:
diff --git a/tests/test_formparser.py b/tests/test_formparser.py
index b29eea59..6070465f 100644
--- a/tests/test_formparser.py
+++ b/tests/test_formparser.py
@@ -441,6 +441,25 @@ class TestMultiPartParser(object):
         assert parser.stream_factory is stream_factory
         assert parser.cls is dict
 
+    def test_file_rfc2231_filename_continuations(self):
+        data = (
+            b"--foo\r\n"
+            b"Content-Type: text/plain; charset=utf-8\r\n"
+            b'Content-Disposition: form-data; name=rfc2231;\r\n'
+            b"	filename*0*=ascii''a%20b%20;\r\n"
+            b"	filename*1*=c%20d%20;\r\n"
+            b'	filename*2="e f.txt"\r\n\r\n'
+            b"file contents\r\n--foo--"
+        )
+        request = Request.from_values(
+            input_stream=BytesIO(data),
+            content_length=len(data),
+            content_type="multipart/form-data; boundary=foo",
+            method="POST"
+        )
+        assert request.files["rfc2231"].filename == "a b c d e f.txt"
+        assert request.files["rfc2231"].read() == b"file contents"
+
 
 class TestInternalFunctions(object):