CVE-2024-30251: Insufficient Information (NVD-CWE-noinfo)
value: aiohttp is an asynchronous HTTP client/server framework for asyncio and Python. In affected versions an attacker can send a specially crafted POST (multipart/form-data) request. When the aiohttp server processes it, the server will enter an infinite loop and be unable to process any further requests. An attacker can stop the application from serving requests after sending a single request. This issue has been addressed in version 3.9.4. Users are advised to upgrade. Users unable to upgrade may manually apply a patch to their systems. Please see the linked GHSA for instructions.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-05-02T14:15Z
Repository: https://github.com/aio-libs/aiohttp
Language: Python
Files changed: CHANGES/8280.bugfix.rst, CHANGES/8280.deprecation.rst, aiohttp/formdata.py, aiohttp/multipart.py, tests/test_client_functional.py, tests/test_multipart.py, tests/test_web_functional.py
Commit date: 2024-04-07 13:19:31 +0100
Commit message: Fix handling of multipart/form-data (#8280) (#8302)

https://datatracker.ietf.org/doc/html/rfc7578
(cherry picked from commit 7d0be3fee540a3d4161ac7dc76422f1f5ea60104)
Diff: diff --git a/CHANGES/8280.bugfix.rst b/CHANGES/8280.bugfix.rst
new file mode 100644
index 0000000000..3aebe36fe9
--- /dev/null
+++ b/CHANGES/8280.bugfix.rst
@@ -0,0 +1 @@
+Fixed ``multipart/form-data`` compliance with :rfc:`7578` -- by :user:`Dreamsorcerer`.
diff --git a/CHANGES/8280.deprecation.rst b/CHANGES/8280.deprecation.rst
new file mode 100644
index 0000000000..302dbb2fe2
--- /dev/null
+++ b/CHANGES/8280.deprecation.rst
@@ -0,0 +1,2 @@
+Deprecated ``content_transfer_encoding`` parameter in :py:meth:`FormData.add_field()
+<aiohttp.FormData.add_field>` -- by :user:`Dreamsorcerer`.
diff --git a/aiohttp/formdata.py b/aiohttp/formdata.py
index e7cd24ca9f..2b75b3de72 100644
--- a/aiohttp/formdata.py
+++ b/aiohttp/formdata.py
@@ -1,6 +1,7 @@
 import io
+import warnings
 from typing import Any, Iterable, List, Optional
 from urllib.parse import urlencode
 
 from multidict import MultiDict, MultiDictProxy
 
@@ -51,11 +52,16 @@ class FormData:
     ) -> None:
 
         if isinstance(value, io.IOBase):
             self._is_multipart = True
         elif isinstance(value, (bytes, bytearray, memoryview)):
+            msg = (
+                "In v4, passing bytes will no longer create a file field. "
+                "Please explicitly use the filename parameter or pass a BytesIO object."
+            )
             if filename is None and content_transfer_encoding is None:
+                warnings.warn(msg, DeprecationWarning)
                 filename = name
 
         type_options: MultiDict[str] = MultiDict({"name": name})
         if filename is not None and not isinstance(filename, str):
             raise TypeError(
@@ -79,11 +85,15 @@ class FormData:
             if not isinstance(content_transfer_encoding, str):
                 raise TypeError(
                     "content_transfer_encoding must be an instance"
                     " of str. Got: %s" % content_transfer_encoding
                 )
-            headers[hdrs.CONTENT_TRANSFER_ENCODING] = content_transfer_encoding
+            msg = (
+                "content_transfer_encoding is deprecated. "
+                "To maintain compatibility with v4 please pass a BytesPayload."
+            )
+            warnings.warn(msg, DeprecationWarning)
             self._is_multipart = True
 
         self._fields.append((type_options, headers, value))
 
     def add_fields(self, *fields: Any) -> None:
diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py
index 4471dd4bb7..a43ec54571 100644
--- a/aiohttp/multipart.py
+++ b/aiohttp/multipart.py
@@ -254,17 +254,26 @@ class BodyPartReader:
     """Multipart reader for single body part."""
 
     chunk_size = 8192
 
     def __init__(
-        self, boundary: bytes, headers: "CIMultiDictProxy[str]", content: StreamReader
+        self,
+        boundary: bytes,
+        headers: "CIMultiDictProxy[str]",
+        content: StreamReader,
+        *,
+        subtype: str = "mixed",
+        default_charset: Optional[str] = None,
     ) -> None:
         self.headers = headers
         self._boundary = boundary
         self._content = content
+        self._default_charset = default_charset
         self._at_eof = False
-        length = self.headers.get(CONTENT_LENGTH, None)
+        self._is_form_data = subtype == "form-data"
+        # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
+        length = None if self._is_form_data else self.headers.get(CONTENT_LENGTH, None)
         self._length = int(length) if length is not None else None
         self._read_bytes = 0
         self._unread: Deque[bytes] = deque()
         self._prev_chunk: Optional[bytes] = None
         self._content_eof = 0
@@ -327,10 +336,12 @@ class BodyPartReader:
         # Reads body part content chunk of the specified size.
         # The body part must has Content-Length header with proper value.
         assert self._length is not None, "Content-Length required for chunked read"
         chunk_size = min(size, self._length - self._read_bytes)
         chunk = await self._content.read(chunk_size)
+        if self._content.at_eof():
+            self._at_eof = True
         return chunk
 
     async def _read_chunk_from_stream(self, size: int) -> bytes:
         # Reads content chunk of body part with unknown length.
         # The Content-Length header for body part is not necessary.
@@ -447,11 +458,12 @@ class BodyPartReader:
         Decoding is done according the specified Content-Encoding
         or Content-Transfer-Encoding headers value.
         """
         if CONTENT_TRANSFER_ENCODING in self.headers:
             data = self._decode_content_transfer(data)
-        if CONTENT_ENCODING in self.headers:
+        # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
+        if not self._is_form_data and CONTENT_ENCODING in self.headers:
             return self._decode_content(data)
         return data
 
     def _decode_content(self, data: bytes) -> bytes:
         encoding = self.headers.get(CONTENT_ENCODING, "").lower()
@@ -481,11 +493,11 @@ class BodyPartReader:
 
     def get_charset(self, default: str) -> str:
         """Returns charset parameter from Content-Type header or default."""
         ctype = self.headers.get(CONTENT_TYPE, "")
         mimetype = parse_mimetype(ctype)
-        return mimetype.parameters.get("charset", default)
+        return mimetype.parameters.get("charset", self._default_charset or default)
 
     @reify
     def name(self) -> Optional[str]:
         """Returns name specified in Content-Disposition header.
 
@@ -536,13 +548,21 @@ class MultipartReader:
     multipart_reader_cls = None
     #: Body part reader class for non multipart/* content types.
     part_reader_cls = BodyPartReader
 
     def __init__(self, headers: Mapping[str, str], content: StreamReader) -> None:
+        self._mimetype = parse_mimetype(headers[CONTENT_TYPE])
+        assert self._mimetype.type == "multipart", "multipart/* content type expected"
+        if "boundary" not in self._mimetype.parameters:
+            raise ValueError(
+                "boundary missed for Content-Type: %s" % headers[CONTENT_TYPE]
+            )
+
         self.headers = headers
         self._boundary = ("--" + self._get_boundary()).encode()
         self._content = content
+        self._default_charset: Optional[str] = None
         self._last_part: Optional[Union["MultipartReader", BodyPartReader]] = None
         self._at_eof = False
         self._at_bof = True
         self._unread: List[bytes] = []
 
@@ -590,11 +610,28 @@ class MultipartReader:
             self._at_bof = False
         else:
             await self._read_boundary()
         if self._at_eof:  # we just read the last boundary, nothing to do there
             return None
-        self._last_part = await self.fetch_next_part()
+
+        part = await self.fetch_next_part()
+        # https://datatracker.ietf.org/doc/html/rfc7578#section-4.6
+        if (
+            self._last_part is None
+            and self._mimetype.subtype == "form-data"
+            and isinstance(part, BodyPartReader)
+        ):
+            _, params = parse_content_disposition(part.headers.get(CONTENT_DISPOSITION))
+            if params.get("name") == "_charset_":
+                # Longest encoding in https://encoding.spec.whatwg.org/encodings.json
+                # is 19 characters, so 32 should be more than enough for any valid encoding.
+                charset = await part.read_chunk(32)
+                if len(charset) > 31:
+                    raise RuntimeError("Invalid default charset")
+                self._default_charset = charset.strip().decode()
+                part = await self.fetch_next_part()
+        self._last_part = part
         return self._last_part
 
     async def release(self) -> None:
         """Reads all the body parts to the void till the final boundary."""
         while not self._at_eof:
@@ -626,23 +663,20 @@ class MultipartReader:
         if mimetype.type == "multipart":
             if self.multipart_reader_cls is None:
                 return type(self)(headers, self._content)
             return self.multipart_reader_cls(headers, self._content)
         else:
-            return self.part_reader_cls(self._boundary, headers, self._content)
-
-    def _get_boundary(self) -> str:
-        mimetype = parse_mimetype(self.headers[CONTENT_TYPE])
-
-        assert mimetype.type == "multipart", "multipart/* content type expected"
-
-        if "boundary" not in mimetype.parameters:
-            raise ValueError(
-                "boundary missed for Content-Type: %s" % self.headers[CONTENT_TYPE]
+            return self.part_reader_cls(
+                self._boundary,
+                headers,
+                self._content,
+                subtype=self._mimetype.subtype,
+                default_charset=self._default_charset,
             )
 
-        boundary = mimetype.parameters["boundary"]
+    def _get_boundary(self) -> str:
+        boundary = self._mimetype.parameters["boundary"]
         if len(boundary) > 70:
             raise ValueError("boundary %r is too long (70 chars max)" % boundary)
 
         return boundary
 
@@ -729,10 +763,11 @@ class MultipartWriter(Payload):
         ctype = f"multipart/{subtype}; boundary={self._boundary_value}"
 
         super().__init__(None, content_type=ctype)
 
         self._parts: List[_Part] = []
+        self._is_form_data = subtype == "form-data"
 
     def __enter__(self) -> "MultipartWriter":
         return self
 
     def __exit__(
@@ -806,36 +841,40 @@ class MultipartWriter(Payload):
             else:
                 return self.append_payload(payload)
 
     def append_payload(self, payload: Payload) -> Payload:
         """Adds a new body part to multipart writer."""
-        # compression
-        encoding: Optional[str] = payload.headers.get(
-            CONTENT_ENCODING,
-            "",
-        ).lower()
-        if encoding and encoding not in ("deflate", "gzip", "identity"):
-            raise RuntimeError(f"unknown content encoding: {encoding}")
-        if encoding == "identity":
-            encoding = None
-
-        # te encoding
-        te_encoding: Optional[str] = payload.headers.get(
-            CONTENT_TRANSFER_ENCODING,
-            "",
-        ).lower()
-        if te_encoding not in ("", "base64", "quoted-printable", "binary"):
-            raise RuntimeError(
-                "unknown content transfer encoding: {}" "".format(te_encoding)
+        encoding: Optional[str] = None
+        te_encoding: Optional[str] = None
+        if self._is_form_data:
+            # https://datatracker.ietf.org/doc/html/rfc7578#section-4.7
+            # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
+            assert CONTENT_DISPOSITION in payload.headers
+            assert "name=" in payload.headers[CONTENT_DISPOSITION]
+            assert (
+                not {CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TRANSFER_ENCODING}
+                & payload.headers.keys()
             )
-        if te_encoding == "binary":
-            te_encoding = None
-
-        # size
-        size = payload.size
-        if size is not None and not (encoding or te_encoding):
-            payload.headers[CONTENT_LENGTH] = str(size)
+        else:
+            # compression
+            encoding = payload.headers.get(CONTENT_ENCODING, "").lower()
+            if encoding and encoding not in ("deflate", "gzip", "identity"):
+                raise RuntimeError(f"unknown content encoding: {encoding}")
+            if encoding == "identity":
+                encoding = None
+
+            # te encoding
+            te_encoding = payload.headers.get(CONTENT_TRANSFER_ENCODING, "").lower()
+            if te_encoding not in ("", "base64", "quoted-printable", "binary"):
+                raise RuntimeError(f"unknown content transfer encoding: {te_encoding}")
+            if te_encoding == "binary":
+                te_encoding = None
+
+            # size
+            size = payload.size
+            if size is not None and not (encoding or te_encoding):
+                payload.headers[CONTENT_LENGTH] = str(size)
 
         self._parts.append((payload, encoding, te_encoding))  # type: ignore[arg-type]
         return payload
 
     def append_json(
diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py
index 8a9a4e184b..dbb2dff5ac 100644
--- a/tests/test_client_functional.py
+++ b/tests/test_client_functional.py
@@ -1315,52 +1315,10 @@ async def test_POST_DATA_with_charset_post(aiohttp_client) -> None:
     content = await resp.text()
     assert content == "текст"
     resp.close()
 
 
-async def test_POST_DATA_with_context_transfer_encoding(aiohttp_client) -> None:
-    async def handler(request):
-        data = await request.post()
-        assert data["name"] == "text"
-        return web.Response(text=data["name"])
-
-    app = web.Application()
-    app.router.add_post("/", handler)
-    client = await aiohttp_client(app)
-
-    form = aiohttp.FormData()
-    form.add_field("name", "text", content_transfer_encoding="base64")
-
-    resp = await client.post("/", data=form)
-    assert 200 == resp.status
-    content = await resp.text()
-    assert content == "text"
-    resp.close()
-
-
-async def test_POST_DATA_with_content_type_context_transfer_encoding(aiohttp_client):
-    async def handler(request):
-        data = await request.post()
-        assert data["name"] == "text"
-        return web.Response(body=data["name"])
-
-    app = web.Application()
-    app.router.add_post("/", handler)
-    client = await aiohttp_client(app)
-
-    form = aiohttp.FormData()
-    form.add_field(
-        "name", "text", content_type="text/plain", content_transfer_encoding="base64"
-    )
-
-    resp = await client.post("/", data=form)
-    assert 200 == resp.status
-    content = await resp.text()
-    assert content == "text"
-    resp.close()
-
-
 async def test_POST_MultiDict(aiohttp_client) -> None:
     async def handler(request):
         data = await request.post()
         assert data == MultiDict([("q", "test1"), ("q", "test2")])
         return web.Response()
@@ -1408,11 +1366,11 @@ async def test_POST_FILES(aiohttp_client, fname) -> None:
     app.router.add_post("/", handler)
     client = await aiohttp_client(app)
 
     with fname.open("rb") as f:
         async with client.post(
-            "/", data={"some": f, "test": b"data"}, chunked=True
+            "/", data={"some": f, "test": io.BytesIO(b"data")}, chunked=True
         ) as resp:
             assert 200 == resp.status
 
 
 async def test_POST_FILES_DEFLATE(aiohttp_client, fname) -> None:
diff --git a/tests/test_multipart.py b/tests/test_multipart.py
index f9d130e794..dbfaf74b9b 100644
--- a/tests/test_multipart.py
+++ b/tests/test_multipart.py
@@ -942,10 +942,62 @@ class TestMultipartReader:
             second = await reader.next()
 
             assert first.at_eof()
             assert not second.at_eof()
 
+    async def test_read_form_default_encoding(self) -> None:
+        with Stream(
+            b"--:\r\n"
+            b'Content-Disposition: form-data; name="_charset_"\r\n\r\n'
+            b"ascii"
+            b"\r\n"
+            b"--:\r\n"
+            b'Content-Disposition: form-data; name="field1"\r\n\r\n'
+            b"foo"
+            b"\r\n"
+            b"--:\r\n"
+            b"Content-Type: text/plain;charset=UTF-8\r\n"
+            b'Content-Disposition: form-data; name="field2"\r\n\r\n'
+            b"foo"
+            b"\r\n"
+            b"--:\r\n"
+            b'Content-Disposition: form-data; name="field3"\r\n\r\n'
+            b"foo"
+            b"\r\n"
+        ) as stream:
+            reader = aiohttp.MultipartReader(
+                {CONTENT_TYPE: 'multipart/form-data;boundary=":"'},
+                stream,
+            )
+            field1 = await reader.next()
+            assert field1.name == "field1"
+            assert field1.get_charset("default") == "ascii"
+            field2 = await reader.next()
+            assert field2.name == "field2"
+            assert field2.get_charset("default") == "UTF-8"
+            field3 = await reader.next()
+            assert field3.name == "field3"
+            assert field3.get_charset("default") == "ascii"
+
+    async def test_read_form_invalid_default_encoding(self) -> None:
+        with Stream(
+            b"--:\r\n"
+            b'Content-Disposition: form-data; name="_charset_"\r\n\r\n'
+            b"this-value-is-too-long-to-be-a-charset"
+            b"\r\n"
+            b"--:\r\n"
+            b'Content-Disposition: form-data; name="field1"\r\n\r\n'
+            b"foo"
+            b"\r\n"
+        ) as stream:
+            reader = aiohttp.MultipartReader(
+                {CONTENT_TYPE: 'multipart/form-data;boundary=":"'},
+                stream,
+            )
+            with pytest.raises(RuntimeError, match="Invalid default charset"):
+                await reader.next()
+
 
 async def test_writer(writer) -> None:
     assert writer.size == 7
     assert writer.boundary == ":"
 
@@ -1278,24 +1330,21 @@ class TestMultipartWriter:
                     headers={
                         CONTENT_DISPOSITION: 'attachments; filename="bug.py"',
                         CONTENT_TYPE: "text/python",
                     },
                 )
-            content_length = part.size
             await writer.write(stream)
 
         assert part.headers[CONTENT_TYPE] == "text/python"
         assert part.headers[CONTENT_DISPOSITION] == ('attachments; filename="bug.py"')
 
         headers, _ = bytes(buf).split(b"\r\n\r\n", 1)
 
         assert headers == (
             b"--:\r\n"
             b"Content-Type: text/python\r\n"
-            b'Content-Disposition: attachments; filename="bug.py"\r\n'
-            b"Content-Length: %s"
-            b"" % (str(content_length).encode(),)
+            b'Content-Disposition: attachments; filename="bug.py"'
         )
 
     async def test_set_content_disposition_override(self, buf, stream):
         # https://github.com/aio-libs/aiohttp/pull/3475#issuecomment-451072381
         with pathlib.Path(__file__).open("rb") as fobj:
@@ -1305,24 +1354,21 @@ class TestMultipartWriter:
                     headers={
                         CONTENT_DISPOSITION: 'attachments; filename="bug.py"',
                         CONTENT_TYPE: "text/python",
                     },
                 )
-            content_length = part.size
             await writer.write(stream)
 
         assert part.headers[CONTENT_TYPE] == "text/python"
         assert part.headers[CONTENT_DISPOSITION] == ('attachments; filename="bug.py"')
 
         headers, _ = bytes(buf).split(b"\r\n\r\n", 1)
 
         assert headers == (
             b"--:\r\n"
             b"Content-Type: text/python\r\n"
-            b'Content-Disposition: attachments; filename="bug.py"\r\n'
-            b"Content-Length: %s"
-            b"" % (str(content_length).encode(),)
+            b'Content-Disposition: attachments; filename="bug.py"'
         )
 
     async def test_reset_content_disposition_header(self, buf, stream):
         # https://github.com/aio-libs/aiohttp/pull/3475#issuecomment-451072381
         with pathlib.Path(__file__).open("rb") as fobj:
@@ -1330,12 +1376,10 @@ class TestMultipartWriter:
                 part = writer.append(
                     fobj,
                     headers={CONTENT_TYPE: "text/plain"},
                 )
 
-            content_length = part.size
-
             assert CONTENT_DISPOSITION in part.headers
 
             part.set_content_disposition("attachments", filename="bug.py")
 
             await writer.write(stream)
@@ -1344,13 +1388,11 @@ class TestMultipartWriter:
 
         assert headers == (
             b"--:\r\n"
             b"Content-Type: text/plain\r\n"
             b"Content-Disposition:"
-            b' attachments; filename="bug.py"\r\n'
-            b"Content-Length: %s"
-            b"" % (str(content_length).encode(),)
+            b' attachments; filename="bug.py"'
         )
 
 
 async def test_async_for_reader() -> None:
     data = [{"test": "passed"}, 42, b"plain text", b"aiohttp\n", b"no epilogue"]
diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py
index 04fc2e35fd..ee61537068 100644
--- a/tests/test_web_functional.py
+++ b/tests/test_web_functional.py
@@ -46,11 +46,12 @@ def fname(here):
     return here / "conftest.py"
 
 
 def new_dummy_form():
     form = FormData()
-    form.add_field("name", b"123", content_transfer_encoding="base64")
+    with pytest.warns(DeprecationWarning, match="BytesPayload"):
+        form.add_field("name", b"123", content_transfer_encoding="base64")
     return form
 
 
 async def test_simple_get(aiohttp_client) -> None:
     async def handler(request):
@@ -445,29 +446,10 @@ async def test_release_post_data(aiohttp_client) -> None:
     assert 200 == resp.status
 
     await resp.release()
 
 
-async def test_POST_DATA_with_content_transfer_encoding(aiohttp_client) -> None:
-    async def handler(request):
-        data = await request.post()
-        assert b"123" == data["name"]
-        return web.Response()
-
-    app = web.Application()
-    app.router.add_post("/", handler)
-    client = await aiohttp_client(app)
-
-    form = FormData()
-    form.add_field("name", b"123", content_transfer_encoding="base64")
-
-    resp = await client.post("/", data=form)
-    assert 200 == resp.status
-
-    await resp.release()
-
-
 async def test_post_form_with_duplicate_keys(aiohttp_client) -> None:
     async def handler(request):
         data = await request.post()
         lst = list(data.items())
         assert [("a", "1"), ("a", "2")] == lst
@@ -521,11 +503,12 @@ async def test_100_continue(aiohttp_client) -> None:
         data = await request.post()
         assert b"123" == data["name"]
         return web.Response()
 
     form = FormData()
-    form.add_field("name", b"123", content_transfer_encoding="base64")
+    with pytest.warns(DeprecationWarning, match="BytesPayload"):
+        form.add_field("name", b"123", content_transfer_encoding="base64")
 
     app = web.Application()
     app.router.add_post("/", handler)
     client = await aiohttp_client(app)
 
@@ -725,11 +708,11 @@ async def test_upload_file(aiohttp_client) -> None:
 
     app = web.Application()
     app.router.add_post("/", handler)
     client = await aiohttp_client(app)
 
-    resp = await client.post("/", data={"file": data})
+    resp = await client.post("/", data={"file": io.BytesIO(data)})
     assert 200 == resp.status
 
     await resp.release()
 
 

Vulnerable code:             headers[hdrs.CONTENT_TRANSFER_ENCODING] = content_transfer_encoding
        self, boundary: bytes, headers: "CIMultiDictProxy[str]", content: StreamReader
        length = self.headers.get(CONTENT_LENGTH, None)
        if CONTENT_ENCODING in self.headers:
        return mimetype.parameters.get("charset", default)
        self._last_part = await self.fetch_next_part()
            return self.part_reader_cls(self._boundary, headers, self._content)

    def _get_boundary(self) -> str:
        mimetype = parse_mimetype(self.headers[CONTENT_TYPE])

        assert mimetype.type == "multipart", "multipart/* content type expected"

        if "boundary" not in mimetype.parameters:
            raise ValueError(
                "boundary missed for Content-Type: %s" % self.headers[CONTENT_TYPE]
        boundary = mimetype.parameters["boundary"]
        # compression
        encoding: Optional[str] = payload.headers.get(
            CONTENT_ENCODING,
            "",
        ).lower()
        if encoding and encoding not in ("deflate", "gzip", "identity"):
            raise RuntimeError(f"unknown content encoding: {encoding}")
        if encoding == "identity":
            encoding = None

        # te encoding
        te_encoding: Optional[str] = payload.headers.get(
            CONTENT_TRANSFER_ENCODING,
            "",
        ).lower()
        if te_encoding not in ("", "base64", "quoted-printable", "binary"):
            raise RuntimeError(
                "unknown content transfer encoding: {}" "".format(te_encoding)
        if te_encoding == "binary":
            te_encoding = None

        # size
        size = payload.size
        if size is not None and not (encoding or te_encoding):
            payload.headers[CONTENT_LENGTH] = str(size)
async def test_POST_DATA_with_context_transfer_encoding(aiohttp_client) -> None:
    async def handler(request):
        data = await request.post()
        assert data["name"] == "text"
        return web.Response(text=data["name"])

    app = web.Application()
    app.router.add_post("/", handler)
    client = await aiohttp_client(app)

    form = aiohttp.FormData()
    form.add_field("name", "text", content_transfer_encoding="base64")

    resp = await client.post("/", data=form)
    assert 200 == resp.status
    content = await resp.text()
    assert content == "text"
    resp.close()


async def test_POST_DATA_with_content_type_context_transfer_encoding(aiohttp_client):
    async def handler(request):
        data = await request.post()
        assert data["name"] == "text"
        return web.Response(body=data["name"])

    app = web.Application()
    app.router.add_post("/", handler)
    client = await aiohttp_client(app)

    form = aiohttp.FormData()
    form.add_field(
        "name", "text", content_type="text/plain", content_transfer_encoding="base64"
    )

    resp = await client.post("/", data=form)
    assert 200 == resp.status
    content = await resp.text()
    assert content == "text"
    resp.close()


            "/", data={"some": f, "test": b"data"}, chunked=True
            content_length = part.size
            b'Content-Disposition: attachments; filename="bug.py"\r\n'
            b"Content-Length: %s"
            b"" % (str(content_length).encode(),)
            content_length = part.size
            b'Content-Disposition: attachments; filename="bug.py"\r\n'
            b"Content-Length: %s"
            b"" % (str(content_length).encode(),)
            content_length = part.size

            b' attachments; filename="bug.py"\r\n'
            b"Content-Length: %s"
            b"" % (str(content_length).encode(),)
    form.add_field("name", b"123", content_transfer_encoding="base64")
async def test_POST_DATA_with_content_transfer_encoding(aiohttp_client) -> None:
    async def handler(request):
        data = await request.post()
        assert b"123" == data["name"]
        return web.Response()

    app = web.Application()
    app.router.add_post("/", handler)
    client = await aiohttp_client(app)

    form = FormData()
    form.add_field("name", b"123", content_transfer_encoding="base64")

    resp = await client.post("/", data=form)
    assert 200 == resp.status

    await resp.release()


    form.add_field("name", b"123", content_transfer_encoding="base64")
    resp = await client.post("/", data={"file": data})
Fixed code: Fixed ``multipart/form-data`` compliance with :rfc:`7578` -- by :user:`Dreamsorcerer`.
Deprecated ``content_transfer_encoding`` parameter in :py:meth:`FormData.add_field()
<aiohttp.FormData.add_field>` -- by :user:`Dreamsorcerer`.
import warnings
            msg = (
                "In v4, passing bytes will no longer create a file field. "
                "Please explicitly use the filename parameter or pass a BytesIO object."
            )
                warnings.warn(msg, DeprecationWarning)
            msg = (
                "content_transfer_encoding is deprecated. "
                "To maintain compatibility with v4 please pass a BytesPayload."
            )
            warnings.warn(msg, DeprecationWarning)
        self,
        boundary: bytes,
        headers: "CIMultiDictProxy[str]",
        content: StreamReader,
        *,
        subtype: str = "mixed",
        default_charset: Optional[str] = None,
        self._default_charset = default_charset
        self._is_form_data = subtype == "form-data"
        # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
        length = None if self._is_form_data else self.headers.get(CONTENT_LENGTH, None)
        if self._content.at_eof():
            self._at_eof = True
        # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
        if not self._is_form_data and CONTENT_ENCODING in self.headers:
        return mimetype.parameters.get("charset", self._default_charset or default)
        self._mimetype = parse_mimetype(headers[CONTENT_TYPE])
        assert self._mimetype.type == "multipart", "multipart/* content type expected"
        if "boundary" not in self._mimetype.parameters:
            raise ValueError(
                "boundary missed for Content-Type: %s" % headers[CONTENT_TYPE]
            )

        self._default_charset: Optional[str] = None

        part = await self.fetch_next_part()
        # https://datatracker.ietf.org/doc/html/rfc7578#section-4.6
        if (
            self._last_part is None
            and self._mimetype.subtype == "form-data"
            and isinstance(part, BodyPartReader)
        ):
            _, params = parse_content_disposition(part.headers.get(CONTENT_DISPOSITION))
            if params.get("name") == "_charset_":
                # Longest encoding in https://encoding.spec.whatwg.org/encodings.json
                # is 19 characters, so 32 should be more than enough for any valid encoding.
                charset = await part.read_chunk(32)
                if len(charset) > 31:
                    raise RuntimeError("Invalid default charset")
                self._default_charset = charset.strip().decode()
                part = await self.fetch_next_part()
        self._last_part = part
            return self.part_reader_cls(
                self._boundary,
                headers,
                self._content,
                subtype=self._mimetype.subtype,
                default_charset=self._default_charset,
    def _get_boundary(self) -> str:
        boundary = self._mimetype.parameters["boundary"]
        self._is_form_data = subtype == "form-data"
        encoding: Optional[str] = None
        te_encoding: Optional[str] = None
        if self._is_form_data:
            # https://datatracker.ietf.org/doc/html/rfc7578#section-4.7
            # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
            assert CONTENT_DISPOSITION in payload.headers
            assert "name=" in payload.headers[CONTENT_DISPOSITION]
            assert (
                not {CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TRANSFER_ENCODING}
                & payload.headers.keys()
        else:
            # compression
            encoding = payload.headers.get(CONTENT_ENCODING, "").lower()
            if encoding and encoding not in ("deflate", "gzip", "identity"):
                raise RuntimeError(f"unknown content encoding: {encoding}")
            if encoding == "identity":
                encoding = None

            # te encoding
            te_encoding = payload.headers.get(CONTENT_TRANSFER_ENCODING, "").lower()
            if te_encoding not in ("", "base64", "quoted-printable", "binary"):
                raise RuntimeError(f"unknown content transfer encoding: {te_encoding}")
            if te_encoding == "binary":
                te_encoding = None

            # size
            size = payload.size
            if size is not None and not (encoding or te_encoding):
                payload.headers[CONTENT_LENGTH] = str(size)
            "/", data={"some": f, "test": io.BytesIO(b"data")}, chunked=True
    async def test_read_form_default_encoding(self) -> None:
        with Stream(
            b"--:\r\n"
            b'Content-Disposition: form-data; name="_charset_"\r\n\r\n'
            b"ascii"
            b"\r\n"
            b"--:\r\n"
            b'Content-Disposition: form-data; name="field1"\r\n\r\n'
            b"foo"
            b"\r\n"
            b"--:\r\n"
            b"Content-Type: text/plain;charset=UTF-8\r\n"
            b'Content-Disposition: form-data; name="field2"\r\n\r\n'
            b"foo"
            b"\r\n"
            b"--:\r\n"
            b'Content-Disposition: form-data; name="field3"\r\n\r\n'
            b"foo"
            b"\r\n"
        ) as stream:
            reader = aiohttp.MultipartReader(
                {CONTENT_TYPE: 'multipart/form-data;boundary=":"'},
                stream,
            )
            field1 = await reader.next()
            assert field1.name == "field1"
            assert field1.get_charset("default") == "ascii"
            field2 = await reader.next()
            assert field2.name == "field2"
            assert field2.get_charset("default") == "UTF-8"
            field3 = await reader.next()
            assert field3.name == "field3"
            assert field3.get_charset("default") == "ascii"

    async def test_read_form_invalid_default_encoding(self) -> None:
        with Stream(
            b"--:\r\n"
            b'Content-Disposition: form-data; name="_charset_"\r\n\r\n'
            b"this-value-is-too-long-to-be-a-charset"
            b"\r\n"
            b"--:\r\n"
            b'Content-Disposition: form-data; name="field1"\r\n\r\n'
            b"foo"
            b"\r\n"
        ) as stream:
            reader = aiohttp.MultipartReader(
                {CONTENT_TYPE: 'multipart/form-data;boundary=":"'},
                stream,
            )
            with pytest.raises(RuntimeError, match="Invalid default charset"):
                await reader.next()

            b'Content-Disposition: attachments; filename="bug.py"'
            b'Content-Disposition: attachments; filename="bug.py"'
            b' attachments; filename="bug.py"'
    with pytest.warns(DeprecationWarning, match="BytesPayload"):
        form.add_field("name", b"123", content_transfer_encoding="base64")
    with pytest.warns(DeprecationWarning, match="BytesPayload"):
        form.add_field("name", b"123", content_transfer_encoding="base64")
    resp = await client.post("/", data={"file": io.BytesIO(data)})