Repository: aio-libs/aiohttp

Issue:
File uploads without a content type are treated as ordinary fields
## Long story short

Multipart file uploads do not work correctly since aiohttp 3.6.0 unless a content type is provided. This used to work up to aiohttp 3.5.4 and broke due to #3905, [this line specifically](https://github.com/aio-libs/aiohttp/pull/3905/files#diff-6e3c6d0970cb316846993e9d988175fdR639).

## Expected behaviour

File upload data is treated as a binary data stream if no content type is provided.

## Actual behaviour

File upload data is treated as an ordinary text field, possibly causing an UnicodeDecodeError.

## Steps to reproduce

1. Send a request to an aiohttp server with a multipart/form-data payload, where one item is a file without a content type. The requests library will not provide a content type unless you specifically set one.
2. Call `await request.post()` in your aiohttp server.

## Your environment

This issue is causing [integration tests for bravado-asyncio to fail](https://travis-ci.org/sjaensch/bravado-asyncio/builds/581878297) - the build before this one [passed](https://travis-ci.org/sjaensch/bravado-asyncio/builds/581426849), the only change is the upgrade from aiohttp 3.5.4 to aiohttp 3.6.0.


Discussion:
@kornicameister why did you add the additional check for `field_ct` to the if condition?

Resolving patch:
diff --git a/aiohttp/web_request.py b/aiohttp/web_request.py
--- a/aiohttp/web_request.py
+++ b/aiohttp/web_request.py
@@ -620,7 +620,12 @@ async def post(self) -> 'MultiDictProxy[Union[str, bytes, FileField]]':
 
                 if isinstance(field, BodyPartReader):
                     assert field.name is not None
-                    if field.filename and field_ct:
+
+                    # Note that according to RFC 7578, the Content-Type header
+                    # is optional, even for files, so we can't assume it's
+                    # present.
+                    # https://tools.ietf.org/html/rfc7578#section-4.4
+                    if field.filename:
                         # store file in temp file
                         tmp = tempfile.TemporaryFile()
                         chunk = await field.read_chunk(size=2**16)
@@ -636,6 +641,9 @@ async def post(self) -> 'MultiDictProxy[Union[str, bytes, FileField]]':
                             chunk = await field.read_chunk(size=2**16)
                         tmp.seek(0)
 
+                        if field_ct is None:
+                            field_ct = 'application/octet-stream'
+
                         ff = FileField(field.name, field.filename,
                                        cast(io.BufferedReader, tmp),
                                        field_ct, field.headers)


Test patch:
diff --git a/tests/test_web_request.py b/tests/test_web_request.py
--- a/tests/test_web_request.py
+++ b/tests/test_web_request.py
@@ -608,17 +608,21 @@ async def test_make_too_big_request_adjust_limit(protocol) -> None:
 
 async def test_multipart_formdata(protocol) -> None:
     payload = StreamReader(protocol, 2 ** 16, loop=asyncio.get_event_loop())
-    payload.feed_data(b"""-----------------------------326931944431359\r
-Content-Disposition: form-data; name="a"\r
-\r
-b\r
------------------------------326931944431359\r
-Content-Disposition: form-data; name="c"\r
-\r
-d\r
------------------------------326931944431359--\r\n""")
-    content_type = "multipart/form-data; boundary="\
-                   "---------------------------326931944431359"
+    payload.feed_data(
+        b'-----------------------------326931944431359\r\n'
+        b'Content-Disposition: form-data; name="a"\r\n'
+        b'\r\n'
+        b'b\r\n'
+        b'-----------------------------326931944431359\r\n'
+        b'Content-Disposition: form-data; name="c"\r\n'
+        b'\r\n'
+        b'd\r\n'
+        b'-----------------------------326931944431359--\r\n'
+    )
+    content_type = (
+        "multipart/form-data; boundary="
+        "---------------------------326931944431359"
+    )
     payload.feed_eof()
     req = make_mocked_request('POST', '/',
                               headers={'CONTENT-TYPE': content_type},
@@ -627,6 +631,30 @@ async def test_multipart_formdata(protocol) -> None:
     assert dict(result) == {'a': 'b', 'c': 'd'}
 
 
+async def test_multipart_formdata_file(protocol) -> None:
+    # Make sure file uploads work, even without a content type
+    payload = StreamReader(protocol, 2 ** 16, loop=asyncio.get_event_loop())
+    payload.feed_data(
+        b'-----------------------------326931944431359\r\n'
+        b'Content-Disposition: form-data; name="a_file"; filename="binary"\r\n'
+        b'\r\n'
+        b'\ff\r\n'
+        b'-----------------------------326931944431359--\r\n'
+    )
+    content_type = (
+        "multipart/form-data; boundary="
+        "---------------------------326931944431359"
+    )
+    payload.feed_eof()
+    req = make_mocked_request('POST', '/',
+                              headers={'CONTENT-TYPE': content_type},
+                              payload=payload)
+    result = await req.post()
+    assert hasattr(result['a_file'], 'file')
+    content = result['a_file'].file.read()
+    assert content == b'\ff'
+
+
 async def test_make_too_big_request_limit_None(protocol) -> None:
     payload = StreamReader(protocol, 2 ** 16, loop=asyncio.get_event_loop())
     large_file = 1024 ** 2 * b'x'