summaryrefslogtreecommitdiffstats
path: root/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2024-30251.patch
diff options
context:
space:
mode:
Diffstat (limited to 'meta-python/recipes-devtools/python/python3-aiohttp/CVE-2024-30251.patch')
-rw-r--r--meta-python/recipes-devtools/python/python3-aiohttp/CVE-2024-30251.patch522
1 files changed, 522 insertions, 0 deletions
diff --git a/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2024-30251.patch b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2024-30251.patch
new file mode 100644
index 0000000000..20226eb754
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-aiohttp/CVE-2024-30251.patch
@@ -0,0 +1,522 @@
1From 44108afc0c0460d154216cab9aaa1d8f57edc3cc Mon Sep 17 00:00:00 2001
2From: Sam Bull <git@sambull.org>
3Date: Sun, 7 Apr 2024 13:19:31 +0100
4Subject: [PATCH] Fix handling of multipart/form-data (#8280) (#8302)
5
6https://datatracker.ietf.org/doc/html/rfc7578
7(cherry picked from commit 7d0be3fee540a3d4161ac7dc76422f1f5ea60104)
8
9The following commits are also included:
107eecdff1 [PR #8332/482e6cdf backport][3.9] Add set_content_disposition test (#8333)
11f21c6f2c [PR #8335/5a6949da backport][3.9] Add Content-Disposition automatically (#8336)
12
13Upstream-Status: Backport
14[https://github.com/aio-libs/aiohttp/commit/cebe526b9c34dc3a3da9140409db63014bc4cf19
15https://github.com/aio-libs/aiohttp/commit/7eecdff163ccf029fbb1ddc9de4169d4aaeb6597
16https://github.com/aio-libs/aiohttp/commit/f21c6f2ca512a026ce7f0f6c6311f62d6a638866]
17
18CVE: CVE-2024-30251
19
20Signed-off-by: Jiaying Song <jiaying.song.cn@windriver.com>
21---
22 CHANGES/8280.bugfix.rst | 1 +
23 CHANGES/8280.deprecation.rst | 2 +
24 CHANGES/8332.bugfix.rst | 1 +
25 CHANGES/8335.bugfix.rst | 1 +
26 aiohttp/formdata.py | 12 +++-
27 aiohttp/multipart.py | 128 ++++++++++++++++++++++++-----------
28 tests/test_multipart.py | 87 ++++++++++++++++++++----
29 tests/test_web_functional.py | 27 ++------
30 8 files changed, 182 insertions(+), 77 deletions(-)
31 create mode 100644 CHANGES/8280.bugfix.rst
32 create mode 100644 CHANGES/8280.deprecation.rst
33 create mode 100644 CHANGES/8332.bugfix.rst
34 create mode 100644 CHANGES/8335.bugfix.rst
35
36diff --git a/CHANGES/8280.bugfix.rst b/CHANGES/8280.bugfix.rst
37new file mode 100644
38index 0000000..3aebe36
39--- /dev/null
40+++ b/CHANGES/8280.bugfix.rst
41@@ -0,0 +1 @@
42+Fixed ``multipart/form-data`` compliance with :rfc:`7578` -- by :user:`Dreamsorcerer`.
43diff --git a/CHANGES/8280.deprecation.rst b/CHANGES/8280.deprecation.rst
44new file mode 100644
45index 0000000..302dbb2
46--- /dev/null
47+++ b/CHANGES/8280.deprecation.rst
48@@ -0,0 +1,2 @@
49+Deprecated ``content_transfer_encoding`` parameter in :py:meth:`FormData.add_field()
50+<aiohttp.FormData.add_field>` -- by :user:`Dreamsorcerer`.
51diff --git a/CHANGES/8332.bugfix.rst b/CHANGES/8332.bugfix.rst
52new file mode 100644
53index 0000000..70cad26
54--- /dev/null
55+++ b/CHANGES/8332.bugfix.rst
56@@ -0,0 +1 @@
57+Fixed regression with adding Content-Disposition to form-data part after appending to writer -- by :user:`Dreamsorcerer`/:user:`Olegt0rr`.
58diff --git a/CHANGES/8335.bugfix.rst b/CHANGES/8335.bugfix.rst
59new file mode 100644
60index 0000000..cd93b86
61--- /dev/null
62+++ b/CHANGES/8335.bugfix.rst
63@@ -0,0 +1 @@
64+Added default Content-Disposition in multipart/form-data responses -- by :user:`Dreamsorcerer`.
65diff --git a/aiohttp/formdata.py b/aiohttp/formdata.py
66index e7cd24c..2b75b3d 100644
67--- a/aiohttp/formdata.py
68+++ b/aiohttp/formdata.py
69@@ -1,4 +1,5 @@
70 import io
71+import warnings
72 from typing import Any, Iterable, List, Optional
73 from urllib.parse import urlencode
74
75@@ -53,7 +54,12 @@ class FormData:
76 if isinstance(value, io.IOBase):
77 self._is_multipart = True
78 elif isinstance(value, (bytes, bytearray, memoryview)):
79+ msg = (
80+ "In v4, passing bytes will no longer create a file field. "
81+ "Please explicitly use the filename parameter or pass a BytesIO object."
82+ )
83 if filename is None and content_transfer_encoding is None:
84+ warnings.warn(msg, DeprecationWarning)
85 filename = name
86
87 type_options: MultiDict[str] = MultiDict({"name": name})
88@@ -81,7 +87,11 @@ class FormData:
89 "content_transfer_encoding must be an instance"
90 " of str. Got: %s" % content_transfer_encoding
91 )
92- headers[hdrs.CONTENT_TRANSFER_ENCODING] = content_transfer_encoding
93+ msg = (
94+ "content_transfer_encoding is deprecated. "
95+ "To maintain compatibility with v4 please pass a BytesPayload."
96+ )
97+ warnings.warn(msg, DeprecationWarning)
98 self._is_multipart = True
99
100 self._fields.append((type_options, headers, value))
101diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py
102index 73801f4..9cd49bb 100644
103--- a/aiohttp/multipart.py
104+++ b/aiohttp/multipart.py
105@@ -255,13 +255,22 @@ class BodyPartReader:
106 chunk_size = 8192
107
108 def __init__(
109- self, boundary: bytes, headers: "CIMultiDictProxy[str]", content: StreamReader
110+ self,
111+ boundary: bytes,
112+ headers: "CIMultiDictProxy[str]",
113+ content: StreamReader,
114+ *,
115+ subtype: str = "mixed",
116+ default_charset: Optional[str] = None,
117 ) -> None:
118 self.headers = headers
119 self._boundary = boundary
120 self._content = content
121+ self._default_charset = default_charset
122 self._at_eof = False
123- length = self.headers.get(CONTENT_LENGTH, None)
124+ self._is_form_data = subtype == "form-data"
125+ # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
126+ length = None if self._is_form_data else self.headers.get(CONTENT_LENGTH, None)
127 self._length = int(length) if length is not None else None
128 self._read_bytes = 0
129 # TODO: typeing.Deque is not supported by Python 3.5
130@@ -329,6 +338,8 @@ class BodyPartReader:
131 assert self._length is not None, "Content-Length required for chunked read"
132 chunk_size = min(size, self._length - self._read_bytes)
133 chunk = await self._content.read(chunk_size)
134+ if self._content.at_eof():
135+ self._at_eof = True
136 return chunk
137
138 async def _read_chunk_from_stream(self, size: int) -> bytes:
139@@ -444,7 +455,8 @@ class BodyPartReader:
140 """
141 if CONTENT_TRANSFER_ENCODING in self.headers:
142 data = self._decode_content_transfer(data)
143- if CONTENT_ENCODING in self.headers:
144+ # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
145+ if not self._is_form_data and CONTENT_ENCODING in self.headers:
146 return self._decode_content(data)
147 return data
148
149@@ -478,7 +490,7 @@ class BodyPartReader:
150 """Returns charset parameter from Content-Type header or default."""
151 ctype = self.headers.get(CONTENT_TYPE, "")
152 mimetype = parse_mimetype(ctype)
153- return mimetype.parameters.get("charset", default)
154+ return mimetype.parameters.get("charset", self._default_charset or default)
155
156 @reify
157 def name(self) -> Optional[str]:
158@@ -533,9 +545,17 @@ class MultipartReader:
159 part_reader_cls = BodyPartReader
160
161 def __init__(self, headers: Mapping[str, str], content: StreamReader) -> None:
162+ self._mimetype = parse_mimetype(headers[CONTENT_TYPE])
163+ assert self._mimetype.type == "multipart", "multipart/* content type expected"
164+ if "boundary" not in self._mimetype.parameters:
165+ raise ValueError(
166+ "boundary missed for Content-Type: %s" % headers[CONTENT_TYPE]
167+ )
168+
169 self.headers = headers
170 self._boundary = ("--" + self._get_boundary()).encode()
171 self._content = content
172+ self._default_charset: Optional[str] = None
173 self._last_part: Optional[Union["MultipartReader", BodyPartReader]] = None
174 self._at_eof = False
175 self._at_bof = True
176@@ -587,7 +607,24 @@ class MultipartReader:
177 await self._read_boundary()
178 if self._at_eof: # we just read the last boundary, nothing to do there
179 return None
180- self._last_part = await self.fetch_next_part()
181+
182+ part = await self.fetch_next_part()
183+ # https://datatracker.ietf.org/doc/html/rfc7578#section-4.6
184+ if (
185+ self._last_part is None
186+ and self._mimetype.subtype == "form-data"
187+ and isinstance(part, BodyPartReader)
188+ ):
189+ _, params = parse_content_disposition(part.headers.get(CONTENT_DISPOSITION))
190+ if params.get("name") == "_charset_":
191+ # Longest encoding in https://encoding.spec.whatwg.org/encodings.json
192+ # is 19 characters, so 32 should be more than enough for any valid encoding.
193+ charset = await part.read_chunk(32)
194+ if len(charset) > 31:
195+ raise RuntimeError("Invalid default charset")
196+ self._default_charset = charset.strip().decode()
197+ part = await self.fetch_next_part()
198+ self._last_part = part
199 return self._last_part
200
201 async def release(self) -> None:
202@@ -623,19 +660,16 @@ class MultipartReader:
203 return type(self)(headers, self._content)
204 return self.multipart_reader_cls(headers, self._content)
205 else:
206- return self.part_reader_cls(self._boundary, headers, self._content)
207-
208- def _get_boundary(self) -> str:
209- mimetype = parse_mimetype(self.headers[CONTENT_TYPE])
210-
211- assert mimetype.type == "multipart", "multipart/* content type expected"
212-
213- if "boundary" not in mimetype.parameters:
214- raise ValueError(
215- "boundary missed for Content-Type: %s" % self.headers[CONTENT_TYPE]
216+ return self.part_reader_cls(
217+ self._boundary,
218+ headers,
219+ self._content,
220+ subtype=self._mimetype.subtype,
221+ default_charset=self._default_charset,
222 )
223
224- boundary = mimetype.parameters["boundary"]
225+ def _get_boundary(self) -> str:
226+ boundary = self._mimetype.parameters["boundary"]
227 if len(boundary) > 70:
228 raise ValueError("boundary %r is too long (70 chars max)" % boundary)
229
230@@ -726,6 +760,7 @@ class MultipartWriter(Payload):
231 super().__init__(None, content_type=ctype)
232
233 self._parts: List[_Part] = []
234+ self._is_form_data = subtype == "form-data"
235
236 def __enter__(self) -> "MultipartWriter":
237 return self
238@@ -803,32 +838,38 @@ class MultipartWriter(Payload):
239
240 def append_payload(self, payload: Payload) -> Payload:
241 """Adds a new body part to multipart writer."""
242- # compression
243- encoding: Optional[str] = payload.headers.get(
244- CONTENT_ENCODING,
245- "",
246- ).lower()
247- if encoding and encoding not in ("deflate", "gzip", "identity"):
248- raise RuntimeError(f"unknown content encoding: {encoding}")
249- if encoding == "identity":
250- encoding = None
251-
252- # te encoding
253- te_encoding: Optional[str] = payload.headers.get(
254- CONTENT_TRANSFER_ENCODING,
255- "",
256- ).lower()
257- if te_encoding not in ("", "base64", "quoted-printable", "binary"):
258- raise RuntimeError(
259- "unknown content transfer encoding: {}" "".format(te_encoding)
260+ encoding: Optional[str] = None
261+ te_encoding: Optional[str] = None
262+ if self._is_form_data:
263+ # https://datatracker.ietf.org/doc/html/rfc7578#section-4.7
264+ # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8
265+ assert (
266+ not {CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TRANSFER_ENCODING}
267+ & payload.headers.keys()
268 )
269- if te_encoding == "binary":
270- te_encoding = None
271-
272- # size
273- size = payload.size
274- if size is not None and not (encoding or te_encoding):
275- payload.headers[CONTENT_LENGTH] = str(size)
276+ # Set default Content-Disposition in case user doesn't create one
277+ if CONTENT_DISPOSITION not in payload.headers:
278+ name = f"section-{len(self._parts)}"
279+ payload.set_content_disposition("form-data", name=name)
280+ else:
281+ # compression
282+ encoding = payload.headers.get(CONTENT_ENCODING, "").lower()
283+ if encoding and encoding not in ("deflate", "gzip", "identity"):
284+ raise RuntimeError(f"unknown content encoding: {encoding}")
285+ if encoding == "identity":
286+ encoding = None
287+
288+ # te encoding
289+ te_encoding = payload.headers.get(CONTENT_TRANSFER_ENCODING, "").lower()
290+ if te_encoding not in ("", "base64", "quoted-printable", "binary"):
291+ raise RuntimeError(f"unknown content transfer encoding: {te_encoding}")
292+ if te_encoding == "binary":
293+ te_encoding = None
294+
295+ # size
296+ size = payload.size
297+ if size is not None and not (encoding or te_encoding):
298+ payload.headers[CONTENT_LENGTH] = str(size)
299
300 self._parts.append((payload, encoding, te_encoding)) # type: ignore[arg-type]
301 return payload
302@@ -886,6 +927,11 @@ class MultipartWriter(Payload):
303 async def write(self, writer: Any, close_boundary: bool = True) -> None:
304 """Write body."""
305 for part, encoding, te_encoding in self._parts:
306+ if self._is_form_data:
307+ # https://datatracker.ietf.org/doc/html/rfc7578#section-4.2
308+ assert CONTENT_DISPOSITION in part.headers
309+ assert "name=" in part.headers[CONTENT_DISPOSITION]
310+
311 await writer.write(b"--" + self._boundary + b"\r\n")
312 await writer.write(part._binary_headers)
313
314diff --git a/tests/test_multipart.py b/tests/test_multipart.py
315index cc3f5ff..1d036fb 100644
316--- a/tests/test_multipart.py
317+++ b/tests/test_multipart.py
318@@ -942,6 +942,58 @@ class TestMultipartReader:
319 assert first.at_eof()
320 assert not second.at_eof()
321
322+ async def test_read_form_default_encoding(self) -> None:
323+ with Stream(
324+ b"--:\r\n"
325+ b'Content-Disposition: form-data; name="_charset_"\r\n\r\n'
326+ b"ascii"
327+ b"\r\n"
328+ b"--:\r\n"
329+ b'Content-Disposition: form-data; name="field1"\r\n\r\n'
330+ b"foo"
331+ b"\r\n"
332+ b"--:\r\n"
333+ b"Content-Type: text/plain;charset=UTF-8\r\n"
334+ b'Content-Disposition: form-data; name="field2"\r\n\r\n'
335+ b"foo"
336+ b"\r\n"
337+ b"--:\r\n"
338+ b'Content-Disposition: form-data; name="field3"\r\n\r\n'
339+ b"foo"
340+ b"\r\n"
341+ ) as stream:
342+ reader = aiohttp.MultipartReader(
343+ {CONTENT_TYPE: 'multipart/form-data;boundary=":"'},
344+ stream,
345+ )
346+ field1 = await reader.next()
347+ assert field1.name == "field1"
348+ assert field1.get_charset("default") == "ascii"
349+ field2 = await reader.next()
350+ assert field2.name == "field2"
351+ assert field2.get_charset("default") == "UTF-8"
352+ field3 = await reader.next()
353+ assert field3.name == "field3"
354+ assert field3.get_charset("default") == "ascii"
355+
356+ async def test_read_form_invalid_default_encoding(self) -> None:
357+ with Stream(
358+ b"--:\r\n"
359+ b'Content-Disposition: form-data; name="_charset_"\r\n\r\n'
360+ b"this-value-is-too-long-to-be-a-charset"
361+ b"\r\n"
362+ b"--:\r\n"
363+ b'Content-Disposition: form-data; name="field1"\r\n\r\n'
364+ b"foo"
365+ b"\r\n"
366+ ) as stream:
367+ reader = aiohttp.MultipartReader(
368+ {CONTENT_TYPE: 'multipart/form-data;boundary=":"'},
369+ stream,
370+ )
371+ with pytest.raises(RuntimeError, match="Invalid default charset"):
372+ await reader.next()
373+
374
375 async def test_writer(writer) -> None:
376 assert writer.size == 7
377@@ -1228,6 +1280,25 @@ class TestMultipartWriter:
378 part = writer._parts[0][0]
379 assert part.headers[CONTENT_TYPE] == "test/passed"
380
381+ def test_set_content_disposition_after_append(self):
382+ writer = aiohttp.MultipartWriter("form-data")
383+ part = writer.append("some-data")
384+ part.set_content_disposition("form-data", name="method")
385+ assert 'name="method"' in part.headers[CONTENT_DISPOSITION]
386+
387+ def test_automatic_content_disposition(self):
388+ writer = aiohttp.MultipartWriter("form-data")
389+ writer.append_json(())
390+ part = payload.StringPayload("foo")
391+ part.set_content_disposition("form-data", name="second")
392+ writer.append_payload(part)
393+ writer.append("foo")
394+
395+ disps = tuple(p[0].headers[CONTENT_DISPOSITION] for p in writer._parts)
396+ assert 'name="section-0"' in disps[0]
397+ assert 'name="second"' in disps[1]
398+ assert 'name="section-2"' in disps[2]
399+
400 def test_with(self) -> None:
401 with aiohttp.MultipartWriter(boundary=":") as writer:
402 writer.append("foo")
403@@ -1278,7 +1349,6 @@ class TestMultipartWriter:
404 CONTENT_TYPE: "text/python",
405 },
406 )
407- content_length = part.size
408 await writer.write(stream)
409
410 assert part.headers[CONTENT_TYPE] == "text/python"
411@@ -1289,9 +1359,7 @@ class TestMultipartWriter:
412 assert headers == (
413 b"--:\r\n"
414 b"Content-Type: text/python\r\n"
415- b'Content-Disposition: attachments; filename="bug.py"\r\n'
416- b"Content-Length: %s"
417- b"" % (str(content_length).encode(),)
418+ b'Content-Disposition: attachments; filename="bug.py"'
419 )
420
421 async def test_set_content_disposition_override(self, buf, stream):
422@@ -1305,7 +1373,6 @@ class TestMultipartWriter:
423 CONTENT_TYPE: "text/python",
424 },
425 )
426- content_length = part.size
427 await writer.write(stream)
428
429 assert part.headers[CONTENT_TYPE] == "text/python"
430@@ -1316,9 +1383,7 @@ class TestMultipartWriter:
431 assert headers == (
432 b"--:\r\n"
433 b"Content-Type: text/python\r\n"
434- b'Content-Disposition: attachments; filename="bug.py"\r\n'
435- b"Content-Length: %s"
436- b"" % (str(content_length).encode(),)
437+ b'Content-Disposition: attachments; filename="bug.py"'
438 )
439
440 async def test_reset_content_disposition_header(self, buf, stream):
441@@ -1330,8 +1395,6 @@ class TestMultipartWriter:
442 headers={CONTENT_TYPE: "text/plain"},
443 )
444
445- content_length = part.size
446-
447 assert CONTENT_DISPOSITION in part.headers
448
449 part.set_content_disposition("attachments", filename="bug.py")
450@@ -1344,9 +1407,7 @@ class TestMultipartWriter:
451 b"--:\r\n"
452 b"Content-Type: text/plain\r\n"
453 b"Content-Disposition:"
454- b' attachments; filename="bug.py"\r\n'
455- b"Content-Length: %s"
456- b"" % (str(content_length).encode(),)
457+ b' attachments; filename="bug.py"'
458 )
459
460
461diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py
462index 5fdfb23..61739e9 100644
463--- a/tests/test_web_functional.py
464+++ b/tests/test_web_functional.py
465@@ -34,7 +34,8 @@ def fname(here):
466
467 def new_dummy_form():
468 form = FormData()
469- form.add_field("name", b"123", content_transfer_encoding="base64")
470+ with pytest.warns(DeprecationWarning, match="BytesPayload"):
471+ form.add_field("name", b"123", content_transfer_encoding="base64")
472 return form
473
474
475@@ -429,25 +430,6 @@ async def test_release_post_data(aiohttp_client) -> None:
476 await resp.release()
477
478
479-async def test_POST_DATA_with_content_transfer_encoding(aiohttp_client) -> None:
480- async def handler(request):
481- data = await request.post()
482- assert b"123" == data["name"]
483- return web.Response()
484-
485- app = web.Application()
486- app.router.add_post("/", handler)
487- client = await aiohttp_client(app)
488-
489- form = FormData()
490- form.add_field("name", b"123", content_transfer_encoding="base64")
491-
492- resp = await client.post("/", data=form)
493- assert 200 == resp.status
494-
495- await resp.release()
496-
497-
498 async def test_post_form_with_duplicate_keys(aiohttp_client) -> None:
499 async def handler(request):
500 data = await request.post()
501@@ -505,7 +487,8 @@ async def test_100_continue(aiohttp_client) -> None:
502 return web.Response()
503
504 form = FormData()
505- form.add_field("name", b"123", content_transfer_encoding="base64")
506+ with pytest.warns(DeprecationWarning, match="BytesPayload"):
507+ form.add_field("name", b"123", content_transfer_encoding="base64")
508
509 app = web.Application()
510 app.router.add_post("/", handler)
511@@ -683,7 +666,7 @@ async def test_upload_file(aiohttp_client) -> None:
512 app.router.add_post("/", handler)
513 client = await aiohttp_client(app)
514
515- resp = await client.post("/", data={"file": data})
516+ resp = await client.post("/", data={"file": io.BytesIO(data)})
517 assert 200 == resp.status
518
519 await resp.release()
520--
5212.25.1
522