Repository: falconry/falcon
Pull request #1745 (2020-07-26 21:29:48)

docs(recipes): discuss parsing nested multipart forms
When it comes to nested multipart forms, most other Python frameworks just slam a fat `wontfix`.

I don't think it should be something we would actively support, but our parser architecture actually happens to support some limited cases out of the box. So I went ahead and added a recipe explaining how one can enable the functionality.

codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1745?src=pr&el=h1) Report
> Merging [#1745](https://codecov.io/gh/falconry/falcon/pull/1745?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/84c391695e9f0492e80a23f93ef6f7cabf1fa659&el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1745/graphs/tree.svg?width=650&height=150&src=pr&token=zOtkfNQ3h6)](https://codecov.io/gh/falconry/falcon/pull/1745?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master     #1745   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           55        55           
  Lines         5002      5002           
  Branches       816       816           
=========================================
  Hits          5002      5002           
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1745?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/media/multipart.py](https://codecov.io/gh/falconry/falcon/pull/1745/diff?src=pr&el=tree#diff-ZmFsY29uL21lZGlhL211bHRpcGFydC5weQ==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1745?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1745?src=pr&el=footer). Last update [84c3916...117ce52](https://codecov.io/gh/falconry/falcon/pull/1745?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

vytas7: This PR also adds one small functional improvement: the deprecated `Content-Transfer-Encoding` header is accepted if its value is `binary`.

Patch:
diff --git a/docs/user/faq.rst b/docs/user/faq.rst
index 502e3c9..b640703 100644
--- a/docs/user/faq.rst
+++ b/docs/user/faq.rst
@@ -610,6 +610,20 @@ method, making it compatible with ``boto3``\'s
    pattern can be applied to any storage API that supports streaming directly
    from a file-like object.
 
+How do I parse a nested multipart form?
+---------------------------------------
+Falcon does not offer official support for parsing nested multipart forms
+(i.e., where multiple files for a single field are transmitted using a nested
+``multipart/mixed`` part) at this time. The usage is considered deprecated
+according to the `living HTML5 standard
+<https://html.spec.whatwg.org/multipage/form-control-infrastructure.html>`_ and
+`RFC 7578, Section 4.3 <https://tools.ietf.org/html/rfc7578#section-4.3>`_.
+
+.. tip::
+    If your app absolutely must deal with such legacy forms, the parser may
+    actually be capable of the task. See more in this recipe:
+    :ref:`nested-multipart-forms`.
+
 How do I retrieve a JSON value from the query string?
 -----------------------------------------------------
 To retrieve a JSON-encoded value from the query string, Falcon provides the
diff --git a/docs/user/recipes/index.rst b/docs/user/recipes/index.rst
index 9c5b7d1..3f16bef 100644
--- a/docs/user/recipes/index.rst
+++ b/docs/user/recipes/index.rst
@@ -4,5 +4,6 @@ Recipes
 .. toctree::
    :maxdepth: 2
 
+   multipart-mixed
    pretty-json
    request-id
diff --git a/docs/user/recipes/multipart-mixed.rst b/docs/user/recipes/multipart-mixed.rst
new file mode 100644
index 0000000..d7f80ab
--- /dev/null
+++ b/docs/user/recipes/multipart-mixed.rst
@@ -0,0 +1,96 @@
+.. _nested-multipart-forms:
+
+Parsing Nested Multipart Forms
+==============================
+
+Out of the box, Falcon does not offer official support for parsing nested
+multipart forms (i.e., where multiple files for a single field are transmitted
+using a nested ``multipart/mixed`` part).
+
+.. note::
+    Nested multipart forms are considered deprecated according to the
+    `living HTML5 standard
+    <https://html.spec.whatwg.org/multipage/form-control-infrastructure.html>`_
+    and
+    `RFC 7578, Section 4.3 <https://tools.ietf.org/html/rfc7578#section-4.3>`_.
+
+However, if your app needs that, nested forms can be handled in the same
+fashion as any other part embedded in the form -- by installing an appropriate
+media handler.
+
+Let us extend the multipart form parser :attr:`media handlers
+<falcon.media.multipart.MultipartParseOptions.media_handlers>` to recursively
+parse embedded forms of the ``multipart/mixed`` content type:
+
+.. code:: python
+
+    import falcon
+    import falcon.media
+
+    parser = falcon.media.MultipartFormHandler()
+    parser.parse_options.media_handlers['multipart/mixed'] = (
+        falcon.media.MultipartFormHandler())
+
+.. note::
+    Here we create a new parser (with default options) for nested parts,
+    effectively disallowing further recursion.
+
+    If traversing into even deeper multipart form hierarchy is desired, we
+    can just reuse the same parser.
+
+Let us now use the nesting-aware parser in an app:
+
+.. code:: python
+
+    import falcon
+    import falcon.media
+
+    class Forms:
+        def on_post(self, req, resp):
+            example = {}
+            for part in req.media:
+                if part.content_type.startswith('multipart/mixed'):
+                    for nested in part.media:
+                        example[nested.filename] = nested.text
+
+            resp.media = example
+
+
+    parser = falcon.media.MultipartFormHandler()
+    parser.parse_options.media_handlers['multipart/mixed'] = (
+        falcon.media.MultipartFormHandler())
+
+    app = falcon.App()
+    app.req_options.media_handlers[falcon.MEDIA_MULTIPART] = parser
+    app.add_route('/forms', Forms())
+
+We should now be able to consume a form containing a nested ``multipart/mixed``
+part (the example is adapted from the now-obsolete
+`RFC 1867 <https://tools.ietf.org/html/rfc1867>`_)::
+
+    --AaB03x
+    Content-Disposition: form-data; name="field1"
+
+    Joe Blow
+    --AaB03x
+    Content-Disposition: form-data; name="docs"
+    Content-Type: multipart/mixed; boundary=BbC04y
+
+    --BbC04y
+    Content-Disposition: attachment; filename="file1.txt"
+
+    This is file1.
+
+    --BbC04y
+    Content-Disposition: attachment; filename="file2.txt"
+
+    Hello, World!
+
+    --BbC04y--
+
+    --AaB03x--
+
+Note that all line endings in the form above are assumed to be CRLF.
+
+The form should be ``POST``\ed with the ``Content-Type`` header set to
+``multipart/form-data; boundary=AaB03x``.
diff --git a/falcon/asgi/multipart.py b/falcon/asgi/multipart.py
index 8ca8569..2c66a82 100644
--- a/falcon/asgi/multipart.py
+++ b/falcon/asgi/multipart.py
@@ -140,7 +140,7 @@ class MultipartForm:
                     #
                     #   Currently, no deployed implementations that send such
                     #   bodies have been discovered.
-                    if name == b'content-transfer-encoding':
+                    if name == b'content-transfer-encoding' and value != b'binary':
                         raise MultipartParseError(
                             description=(
                                 'the deprecated Content-Transfer-Encoding '
diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py
index ff4ca4d..742c53d 100644
--- a/falcon/media/multipart.py
+++ b/falcon/media/multipart.py
@@ -450,7 +450,7 @@ class MultipartForm:
                     #
                     #   Currently, no deployed implementations that send such
                     #   bodies have been discovered.
-                    if name == b'content-transfer-encoding':
+                    if name == b'content-transfer-encoding' and value != b'binary':
                         raise MultipartParseError(
                             description=(
                                 'the deprecated Content-Transfer-Encoding '


Test patch:
diff --git a/tests/test_media_multipart.py b/tests/test_media_multipart.py
index cafd674..49daaee 100644
--- a/tests/test_media_multipart.py
+++ b/tests/test_media_multipart.py
@@ -596,6 +596,55 @@ def test_invalid_random_form(client):
     assert resp.status_code == 400
 
 
+def test_nested_multipart_mixed():
+    class Forms:
+        def on_post(self, req, resp):
+            example = {}
+            for part in req.media:
+                if part.content_type.startswith('multipart/mixed'):
+                    for nested in part.media:
+                        example[nested.filename] = nested.text
+
+            resp.media = example
+
+    parser = media.MultipartFormHandler()
+    parser.parse_options.media_handlers['multipart/mixed'] = (
+        media.MultipartFormHandler())
+
+    app = falcon.App()
+    app.req_options.media_handlers[falcon.MEDIA_MULTIPART] = parser
+    app.add_route('/forms', Forms())
+    client = testing.TestClient(app)
+
+    form_data = (
+        b'--AaB03x\r\n'
+        b'Content-Disposition: form-data; name="field1"\r\n\r\n'
+        b'Joe Blow\r\n'
+        b'--AaB03x\r\n'
+        b'Content-Disposition: form-data; name="docs"\r\n'
+        b'Content-Type: multipart/mixed; boundary=BbC04y\r\n\r\n'
+        b'--BbC04y\r\n'
+        b'Content-Disposition: attachment; filename="file1.txt"\r\n\r\n'
+        b'This is file1.\r\n\r\n'
+        b'--BbC04y\r\n'
+        b'Content-Disposition: attachment; filename="file2.txt"\r\n'
+        b'Content-Transfer-Encoding: binary\r\n\r\n'
+        b'Hello, World!\r\n\r\n'
+        b'--BbC04y--\r\n\r\n'
+        b'--AaB03x--\r\n'
+    )
+    resp = client.simulate_post(
+        '/forms',
+        headers={'Content-Type': 'multipart/form-data; boundary=AaB03x'},
+        body=form_data,
+    )
+    assert resp.status_code == 200
+    assert resp.json == {
+        'file1.txt': 'This is file1.\r\n',
+        'file2.txt': 'Hello, World!\r\n',
+    }
+
+
 def test_content_transfer_encoding_header(client):
     data = (
         b'--BOUNDARY\r\n'