VYPR
High severityNVD Advisory· Published May 2, 2024· Updated Nov 3, 2025

Denial of service when trying to parse malformed POST requests in aiohttp

CVE-2024-30251

Description

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.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
aiohttpPyPI
< 3.9.43.9.4

Affected products

1

Patches

3
f21c6f2ca512

[PR #8335/5a6949da backport][3.9] Add Content-Disposition automatically (#8336)

https://github.com/aio-libs/aiohttppatchback[bot]Apr 15, 2024via ghsa
3 files changed · +22 5
  • aiohttp/multipart.py+4 0 modified
    @@ -852,6 +852,10 @@ def append_payload(self, payload: Payload) -> Payload:
                     not {CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TRANSFER_ENCODING}
                     & payload.headers.keys()
                 )
    +            # Set default Content-Disposition in case user doesn't create one
    +            if CONTENT_DISPOSITION not in payload.headers:
    +                name = f"section-{len(self._parts)}"
    +                payload.set_content_disposition("form-data", name=name)
             else:
                 # compression
                 encoding = payload.headers.get(CONTENT_ENCODING, "").lower()
    
  • CHANGES/8335.bugfix.rst+1 0 added
    @@ -0,0 +1 @@
    +Added default Content-Disposition in multipart/form-data responses -- by :user:`Dreamsorcerer`.
    
  • tests/test_multipart.py+17 5 modified
    @@ -1282,12 +1282,24 @@ def test_append_multipart(self, writer) -> None:
             part = writer._parts[0][0]
             assert part.headers[CONTENT_TYPE] == "test/passed"
     
    -    async def test_set_content_disposition_after_append(self):
    +    def test_set_content_disposition_after_append(self):
             writer = aiohttp.MultipartWriter("form-data")
    -        payload = writer.append("some-data")
    -        payload.set_content_disposition("form-data", name="method")
    -        assert CONTENT_DISPOSITION in payload.headers
    -        assert "name=" in payload.headers[CONTENT_DISPOSITION]
    +        part = writer.append("some-data")
    +        part.set_content_disposition("form-data", name="method")
    +        assert 'name="method"' in part.headers[CONTENT_DISPOSITION]
    +
    +    def test_automatic_content_disposition(self):
    +        writer = aiohttp.MultipartWriter("form-data")
    +        writer.append_json(())
    +        part = payload.StringPayload("foo")
    +        part.set_content_disposition("form-data", name="second")
    +        writer.append_payload(part)
    +        writer.append("foo")
    +
    +        disps = tuple(p[0].headers[CONTENT_DISPOSITION] for p in writer._parts)
    +        assert 'name="section-0"' in disps[0]
    +        assert 'name="second"' in disps[1]
    +        assert 'name="section-2"' in disps[2]
     
         def test_with(self) -> None:
             with aiohttp.MultipartWriter(boundary=":") as writer:
    
7eecdff163cc

[PR #8332/482e6cdf backport][3.9] Add set_content_disposition test (#8333)

https://github.com/aio-libs/aiohttppatchback[bot]Apr 15, 2024via ghsa
3 files changed · +13 2
  • aiohttp/multipart.py+5 2 modified
    @@ -848,8 +848,6 @@ def append_payload(self, payload: Payload) -> Payload:
             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()
    @@ -930,6 +928,11 @@ def size(self) -> Optional[int]:
         async def write(self, writer: Any, close_boundary: bool = True) -> None:
             """Write body."""
             for part, encoding, te_encoding in self._parts:
    +            if self._is_form_data:
    +                # https://datatracker.ietf.org/doc/html/rfc7578#section-4.2
    +                assert CONTENT_DISPOSITION in part.headers
    +                assert "name=" in part.headers[CONTENT_DISPOSITION]
    +
                 await writer.write(b"--" + self._boundary + b"\r\n")
                 await writer.write(part._binary_headers)
     
    
  • CHANGES/8332.bugfix.rst+1 0 added
    @@ -0,0 +1 @@
    +Fixed regression with adding Content-Disposition to form-data part after appending to writer -- by :user:`Dreamsorcerer`/:user:`Olegt0rr`.
    
  • tests/test_multipart.py+7 0 modified
    @@ -1282,6 +1282,13 @@ def test_append_multipart(self, writer) -> None:
             part = writer._parts[0][0]
             assert part.headers[CONTENT_TYPE] == "test/passed"
     
    +    async def test_set_content_disposition_after_append(self):
    +        writer = aiohttp.MultipartWriter("form-data")
    +        payload = writer.append("some-data")
    +        payload.set_content_disposition("form-data", name="method")
    +        assert CONTENT_DISPOSITION in payload.headers
    +        assert "name=" in payload.headers[CONTENT_DISPOSITION]
    +
         def test_with(self) -> None:
             with aiohttp.MultipartWriter(boundary=":") as writer:
                 writer.append("foo")
    
cebe526b9c34

Fix handling of multipart/form-data (#8280) (#8302)

https://github.com/aio-libs/aiohttpSam BullApr 7, 2024via ghsa
7 files changed · +155 120
  • aiohttp/formdata.py+11 1 modified
    @@ -1,4 +1,5 @@
     import io
    +import warnings
     from typing import Any, Iterable, List, Optional
     from urllib.parse import urlencode
     
    @@ -53,7 +54,12 @@ def add_field(
             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})
    @@ -81,7 +87,11 @@ def add_field(
                         "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))
    
  • aiohttp/multipart.py+80 41 modified
    @@ -256,13 +256,22 @@ class BodyPartReader:
         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()
    @@ -329,6 +338,8 @@ async def _read_chunk_from_length(self, size: int) -> bytes:
             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:
    @@ -449,7 +460,8 @@ def decode(self, data: bytes) -> bytes:
             """
             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
     
    @@ -483,7 +495,7 @@ 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]:
    @@ -538,9 +550,17 @@ class MultipartReader:
         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
    @@ -592,7 +612,24 @@ async def next(
                 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:
    @@ -628,19 +665,16 @@ def _get_part_reader(
                     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)
     
    @@ -731,6 +765,7 @@ def __init__(self, subtype: str = "mixed", boundary: Optional[str] = None) -> No
             super().__init__(None, content_type=ctype)
     
             self._parts: List[_Part] = []
    +        self._is_form_data = subtype == "form-data"
     
         def __enter__(self) -> "MultipartWriter":
             return self
    @@ -808,32 +843,36 @@ def append(self, obj: Any, headers: Optional[Mapping[str, str]] = None) -> Paylo
     
         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
    
  • CHANGES/8280.bugfix.rst+1 0 added
    @@ -0,0 +1 @@
    +Fixed ``multipart/form-data`` compliance with :rfc:`7578` -- by :user:`Dreamsorcerer`.
    
  • CHANGES/8280.deprecation.rst+2 0 added
    @@ -0,0 +1,2 @@
    +Deprecated ``content_transfer_encoding`` parameter in :py:meth:`FormData.add_field()
    +<aiohttp.FormData.add_field>` -- by :user:`Dreamsorcerer`.
    
  • tests/test_client_functional.py+1 43 modified
    @@ -1317,48 +1317,6 @@ async def handler(request):
         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()
    @@ -1410,7 +1368,7 @@ async def handler(request):
     
         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
     
    
  • tests/test_multipart.py+55 13 modified
    @@ -944,6 +944,58 @@ async def test_reading_skips_prelude(self) -> None:
                 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
    @@ -1280,7 +1332,6 @@ async def test_preserve_content_disposition_header(self, buf, stream):
                             CONTENT_TYPE: "text/python",
                         },
                     )
    -            content_length = part.size
                 await writer.write(stream)
     
             assert part.headers[CONTENT_TYPE] == "text/python"
    @@ -1291,9 +1342,7 @@ async def test_preserve_content_disposition_header(self, buf, stream):
             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):
    @@ -1307,7 +1356,6 @@ async def test_set_content_disposition_override(self, buf, stream):
                             CONTENT_TYPE: "text/python",
                         },
                     )
    -            content_length = part.size
                 await writer.write(stream)
     
             assert part.headers[CONTENT_TYPE] == "text/python"
    @@ -1318,9 +1366,7 @@ async def test_set_content_disposition_override(self, buf, stream):
             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):
    @@ -1332,8 +1378,6 @@ async def test_reset_content_disposition_header(self, buf, stream):
                         headers={CONTENT_TYPE: "text/plain"},
                     )
     
    -            content_length = part.size
    -
                 assert CONTENT_DISPOSITION in part.headers
     
                 part.set_content_disposition("attachments", filename="bug.py")
    @@ -1346,9 +1390,7 @@ async def test_reset_content_disposition_header(self, buf, stream):
                 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"'
             )
     
     
    
  • tests/test_web_functional.py+5 22 modified
    @@ -48,7 +48,8 @@ def fname(here):
     
     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
     
     
    @@ -447,25 +448,6 @@ async def handler(request):
         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()
    @@ -523,7 +505,8 @@ async def handler(request):
             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)
    @@ -727,7 +710,7 @@ async def handler(request):
         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()
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

8

News mentions

0

No linked articles in our index yet.