Sync from SUSE:ALP:Source:Standard:1.0 python-aiohttp revision 874576f17acdaa4e6416ecd86c34de69

This commit is contained in:
Adrian Schröter 2024-12-23 20:49:26 +01:00
parent e2afee2832
commit 6268615311
5 changed files with 859 additions and 0 deletions

221
CVE-2024-27306.patch Normal file
View File

@ -0,0 +1,221 @@
Index: aiohttp-3.8.5/CHANGES/8317.bugfix.rst
===================================================================
--- /dev/null
+++ aiohttp-3.8.5/CHANGES/8317.bugfix.rst
@@ -0,0 +1 @@
+Escaped filenames in static view -- by :user:`bdraco`.
Index: aiohttp-3.8.5/aiohttp/web_urldispatcher.py
===================================================================
--- aiohttp-3.8.5.orig/aiohttp/web_urldispatcher.py
+++ aiohttp-3.8.5/aiohttp/web_urldispatcher.py
@@ -1,7 +1,9 @@
import abc
import asyncio
import base64
+import functools
import hashlib
+import html
import inspect
import keyword
import os
@@ -87,6 +89,8 @@ PATH_SEP: Final[str] = re.escape("/")
_ExpectHandler = Callable[[Request], Awaitable[None]]
_Resolve = Tuple[Optional["UrlMappingMatchInfo"], Set[str]]
+html_escape = functools.partial(html.escape, quote=True)
+
class _InfoDict(TypedDict, total=False):
path: str
@@ -696,7 +700,7 @@ class StaticResource(PrefixResource):
assert filepath.is_dir()
relative_path_to_dir = filepath.relative_to(self._directory).as_posix()
- index_of = f"Index of /{relative_path_to_dir}"
+ index_of = f"Index of /{html_escape(relative_path_to_dir)}"
h1 = f"<h1>{index_of}</h1>"
index_list = []
@@ -704,7 +708,7 @@ class StaticResource(PrefixResource):
for _file in sorted(dir_index):
# show file url as relative to static path
rel_path = _file.relative_to(self._directory).as_posix()
- file_url = self._prefix + "/" + rel_path
+ quoted_file_url = _quote_path(f"{self._prefix}/{rel_path}")
# if file is a directory, add '/' to the end of the name
if _file.is_dir():
@@ -713,9 +717,7 @@ class StaticResource(PrefixResource):
file_name = _file.name
index_list.append(
- '<li><a href="{url}">{name}</a></li>'.format(
- url=file_url, name=file_name
- )
+ f'<li><a href="{quoted_file_url}">{html_escape(file_name)}</a></li>'
)
ul = "<ul>\n{}\n</ul>".format("\n".join(index_list))
body = f"<body>\n{h1}\n{ul}\n</body>"
Index: aiohttp-3.8.5/tests/test_web_urldispatcher.py
===================================================================
--- aiohttp-3.8.5.orig/tests/test_web_urldispatcher.py
+++ aiohttp-3.8.5/tests/test_web_urldispatcher.py
@@ -35,35 +35,42 @@ def tmp_dir_path(request):
@pytest.mark.parametrize(
- "show_index,status,prefix,data",
+ "show_index,status,prefix,request_path,data",
[
- pytest.param(False, 403, "/", None, id="index_forbidden"),
+ pytest.param(False, 403, "/", "/", None, id="index_forbidden"),
pytest.param(
True,
200,
"/",
- b"<html>\n<head>\n<title>Index of /.</title>\n"
- b"</head>\n<body>\n<h1>Index of /.</h1>\n<ul>\n"
- b'<li><a href="/my_dir">my_dir/</a></li>\n'
- b'<li><a href="/my_file">my_file</a></li>\n'
- b"</ul>\n</body>\n</html>",
- id="index_root",
+ "/",
+ b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
+ b' /.</h1>\n<ul>\n<li><a href="/my_dir">my_dir/</a></li>\n<li><a href="/my_file">'
+ b"my_file</a></li>\n</ul>\n</body>\n</html>",
),
pytest.param(
True,
200,
"/static",
- b"<html>\n<head>\n<title>Index of /.</title>\n"
- b"</head>\n<body>\n<h1>Index of /.</h1>\n<ul>\n"
- b'<li><a href="/static/my_dir">my_dir/</a></li>\n'
- b'<li><a href="/static/my_file">my_file</a></li>\n'
- b"</ul>\n</body>\n</html>",
+ "/static",
+ b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
+ b' /.</h1>\n<ul>\n<li><a href="/static/my_dir">my_dir/</a></li>\n<li><a href="'
+ b'/static/my_file">my_file</a></li>\n</ul>\n</body>\n</html>',
id="index_static",
),
+ pytest.param(
+ True,
+ 200,
+ "/static",
+ "/static/my_dir",
+ b"<html>\n<head>\n<title>Index of /my_dir</title>\n</head>\n<body>\n<h1>"
+ b'Index of /my_dir</h1>\n<ul>\n<li><a href="/static/my_dir/my_file_in_dir">'
+ b"my_file_in_dir</a></li>\n</ul>\n</body>\n</html>",
+ id="index_subdir",
+ ),
],
)
async def test_access_root_of_static_handler(
- tmp_dir_path, aiohttp_client, show_index, status, prefix, data
+ tmp_dir_path, aiohttp_client, show_index, status, prefix, request_path, data
) -> None:
# Tests the operation of static file server.
# Try to access the root of static file server, and make
@@ -88,7 +95,7 @@ async def test_access_root_of_static_han
client = await aiohttp_client(app)
# Request the root of the static directory.
- r = await client.get(prefix)
+ r = await client.get(request_path)
assert r.status == status
if data:
@@ -97,6 +104,92 @@ async def test_access_root_of_static_han
assert read_ == data
+@pytest.mark.skipif(
+ not sys.platform.startswith("linux"),
+ reason="Invalid filenames on some filesystems (like Windows)",
+)
+@pytest.mark.parametrize(
+ "show_index,status,prefix,request_path,data",
+ [
+ pytest.param(False, 403, "/", "/", None, id="index_forbidden"),
+ pytest.param(
+ True,
+ 200,
+ "/",
+ "/",
+ b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
+ b' /.</h1>\n<ul>\n<li><a href="/%3Cimg%20src=0%20onerror=alert(1)%3E.dir">&l'
+ b't;img src=0 onerror=alert(1)&gt;.dir/</a></li>\n<li><a href="/%3Cimg%20sr'
+ b'c=0%20onerror=alert(1)%3E.txt">&lt;img src=0 onerror=alert(1)&gt;.txt</a></l'
+ b"i>\n</ul>\n</body>\n</html>",
+ ),
+ pytest.param(
+ True,
+ 200,
+ "/static",
+ "/static",
+ b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
+ b' /.</h1>\n<ul>\n<li><a href="/static/%3Cimg%20src=0%20onerror=alert(1)%3E.'
+ b'dir">&lt;img src=0 onerror=alert(1)&gt;.dir/</a></li>\n<li><a href="/stat'
+ b'ic/%3Cimg%20src=0%20onerror=alert(1)%3E.txt">&lt;img src=0 onerror=alert(1)&'
+ b"gt;.txt</a></li>\n</ul>\n</body>\n</html>",
+ id="index_static",
+ ),
+ pytest.param(
+ True,
+ 200,
+ "/static",
+ "/static/<img src=0 onerror=alert(1)>.dir",
+ b"<html>\n<head>\n<title>Index of /&lt;img src=0 onerror=alert(1)&gt;.dir</t"
+ b"itle>\n</head>\n<body>\n<h1>Index of /&lt;img src=0 onerror=alert(1)&gt;.di"
+ b'r</h1>\n<ul>\n<li><a href="/static/%3Cimg%20src=0%20onerror=alert(1)%3E.di'
+ b'r/my_file_in_dir">my_file_in_dir</a></li>\n</ul>\n</body>\n</html>',
+ id="index_subdir",
+ ),
+ ],
+)
+async def test_access_root_of_static_handler_xss(
+ tmp_path,
+ aiohttp_client,
+ show_index,
+ status,
+ prefix,
+ request_path,
+ data,
+) -> None:
+ # Tests the operation of static file server.
+ # Try to access the root of static file server, and make
+ # sure that correct HTTP statuses are returned depending if we directory
+ # index should be shown or not.
+ # Ensure that html in file names is escaped.
+ # Ensure that links are url quoted.
+ my_file = tmp_path / "<img src=0 onerror=alert(1)>.txt"
+ my_dir = tmp_path / "<img src=0 onerror=alert(1)>.dir"
+ my_dir.mkdir()
+ my_file_in_dir = my_dir / "my_file_in_dir"
+
+ with my_file.open("w") as fw:
+ fw.write("hello")
+
+ with my_file_in_dir.open("w") as fw:
+ fw.write("world")
+
+ app = web.Application()
+
+ # Register global static route:
+ app.router.add_static(prefix, str(tmp_path), show_index=show_index)
+ client = await aiohttp_client(app)
+
+ # Request the root of the static directory.
+ async with await client.get(request_path) as r:
+ assert r.status == status
+
+ if data:
+ assert r.headers["Content-Type"] == "text/html; charset=utf-8"
+ read_ = await r.read()
+ assert read_ == data
+
+
async def test_follow_symlink(tmp_dir_path, aiohttp_client) -> None:
# Tests the access to a symlink, in static folder
data = "hello world"

517
CVE-2024-30251.patch Normal file
View File

@ -0,0 +1,517 @@
Index: aiohttp-3.8.5/aiohttp/formdata.py
===================================================================
--- aiohttp-3.8.5.orig/aiohttp/formdata.py
+++ aiohttp-3.8.5/aiohttp/formdata.py
@@ -1,4 +1,5 @@
import io
+import warnings
from typing import Any, Iterable, List, Optional
from urllib.parse import urlencode
@@ -53,7 +54,12 @@ class FormData:
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 @@ class FormData:
"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))
Index: aiohttp-3.8.5/aiohttp/multipart.py
===================================================================
--- aiohttp-3.8.5.orig/aiohttp/multipart.py
+++ aiohttp-3.8.5/aiohttp/multipart.py
@@ -255,13 +255,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
# TODO: typeing.Deque is not supported by Python 3.5
@@ -329,6 +338,8 @@ class BodyPartReader:
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:
@@ -444,7 +455,8 @@ class BodyPartReader:
"""
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
@@ -478,7 +490,7 @@ class BodyPartReader:
"""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]:
@@ -533,9 +545,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
@@ -587,7 +607,24 @@ class MultipartReader:
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:
@@ -623,19 +660,16 @@ class MultipartReader:
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)
@@ -726,6 +760,7 @@ class MultipartWriter(Payload):
super().__init__(None, content_type=ctype)
self._parts: List[_Part] = []
+ self._is_form_data = subtype == "form-data"
def __enter__(self) -> "MultipartWriter":
return self
@@ -803,32 +838,38 @@ class MultipartWriter(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 (
+ 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)
+ # 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()
+ 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
@@ -886,6 +927,11 @@ class MultipartWriter(Payload):
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)
Index: aiohttp-3.8.5/tests/test_client_functional.py
===================================================================
--- aiohttp-3.8.5.orig/tests/test_client_functional.py
+++ aiohttp-3.8.5/tests/test_client_functional.py
@@ -1158,48 +1158,6 @@ async def test_POST_DATA_with_charset_po
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()
@@ -1249,7 +1207,7 @@ async def test_POST_FILES(aiohttp_client
client = await aiohttp_client(app)
with fname.open("rb") as f:
- resp = await client.post("/", data={"some": f, "test": b"data"}, chunked=True)
+ resp = await client.post("/", data={"some": f, "test": io.BytesIO(b"data")}, chunked=True)
assert 200 == resp.status
resp.close()
Index: aiohttp-3.8.5/tests/test_multipart.py
===================================================================
--- aiohttp-3.8.5.orig/tests/test_multipart.py
+++ aiohttp-3.8.5/tests/test_multipart.py
@@ -942,6 +942,58 @@ class TestMultipartReader:
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
@@ -1228,6 +1280,25 @@ class TestMultipartWriter:
part = writer._parts[0][0]
assert part.headers[CONTENT_TYPE] == "test/passed"
+ def test_set_content_disposition_after_append(self):
+ writer = aiohttp.MultipartWriter("form-data")
+ 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:
writer.append("foo")
@@ -1278,7 +1349,6 @@ class TestMultipartWriter:
CONTENT_TYPE: "text/python",
},
)
- content_length = part.size
await writer.write(stream)
assert part.headers[CONTENT_TYPE] == "text/python"
@@ -1289,9 +1359,7 @@ class TestMultipartWriter:
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):
@@ -1305,7 +1373,6 @@ class TestMultipartWriter:
CONTENT_TYPE: "text/python",
},
)
- content_length = part.size
await writer.write(stream)
assert part.headers[CONTENT_TYPE] == "text/python"
@@ -1316,9 +1383,7 @@ class TestMultipartWriter:
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):
@@ -1330,8 +1395,6 @@ class TestMultipartWriter:
headers={CONTENT_TYPE: "text/plain"},
)
- content_length = part.size
-
assert CONTENT_DISPOSITION in part.headers
part.set_content_disposition("attachments", filename="bug.py")
@@ -1344,9 +1407,7 @@ class TestMultipartWriter:
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"'
)
Index: aiohttp-3.8.5/tests/test_web_functional.py
===================================================================
--- aiohttp-3.8.5.orig/tests/test_web_functional.py
+++ aiohttp-3.8.5/tests/test_web_functional.py
@@ -34,7 +34,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
@@ -429,25 +430,6 @@ async def test_release_post_data(aiohttp
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()
@@ -505,7 +487,8 @@ async def test_100_continue(aiohttp_clie
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)
@@ -683,7 +666,7 @@ async def test_upload_file(aiohttp_clien
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()

91
CVE-2024-52304.patch Normal file
View File

@ -0,0 +1,91 @@
Index: aiohttp-3.8.5/CHANGES/9851.bugfix.rst
===================================================================
--- /dev/null
+++ aiohttp-3.8.5/CHANGES/9851.bugfix.rst
@@ -0,0 +1 @@
+Fixed incorrect parsing of chunk extensions with the pure Python parser -- by :user:`bdraco`.
Index: aiohttp-3.8.5/aiohttp/http_parser.py
===================================================================
--- aiohttp-3.8.5.orig/aiohttp/http_parser.py
+++ aiohttp-3.8.5/aiohttp/http_parser.py
@@ -26,7 +26,7 @@ from yarl import URL
from . import hdrs
from .base_protocol import BaseProtocol
-from .helpers import NO_EXTENSIONS, BaseTimerContext
+from .helpers import NO_EXTENSIONS, BaseTimerContext, set_exception
from .http_exceptions import (
BadHttpMessage,
BadStatusLine,
@@ -770,6 +770,14 @@ class HttpPayloadParser:
i = chunk.find(CHUNK_EXT, 0, pos)
if i >= 0:
size_b = chunk[:i] # strip chunk-extensions
+ # Verify no LF in the chunk-extension
+ if b"\n" in chunk[i:pos]:
+ ext = repr(chunk[i:pos])
+ exc = BadHttpMessage(
+ "Unexpected LF in chunk-extension: %s" % ext
+ )
+ set_exception(self.payload, exc)
+ raise exc
else:
size_b = chunk[:pos]
Index: aiohttp-3.8.5/tests/test_http_parser.py
===================================================================
--- aiohttp-3.8.5.orig/tests/test_http_parser.py
+++ aiohttp-3.8.5/tests/test_http_parser.py
@@ -12,6 +12,7 @@ from yarl import URL
import aiohttp
from aiohttp import http_exceptions, streams
+from aiohttp.base_protocol import BaseProtocol
from aiohttp.http_parser import (
NO_EXTENSIONS,
DeflateBuffer,
@@ -1202,3 +1203,27 @@ class TestDeflateBuffer:
dbuf.feed_eof()
assert buf.at_eof()
+
+
+async def test_parse_chunked_payload_with_lf_in_extensions_py_parser(
+ loop: asyncio.AbstractEventLoop, protocol: BaseProtocol
+) -> None:
+ """Test the py-parser with a chunked payload that has a LF in the chunk extensions."""
+ # The py parser will not raise the BadHttpMessage directly, but instead
+ # it will set the exception on the StreamReader.
+ parser = HttpRequestParserPy(
+ protocol,
+ loop,
+ max_line_size=8190,
+ max_field_size=8190,
+ )
+ payload = (
+ b"GET / HTTP/1.1\r\nHost: localhost:5001\r\n"
+ b"Transfer-Encoding: chunked\r\n\r\n2;\nxx\r\n4c\r\n0\r\n\r\n"
+ b"GET /admin HTTP/1.1\r\nHost: localhost:5001\r\n"
+ b"Transfer-Encoding: chunked\r\n\r\n0\r\n\r\n"
+ )
+ messages, _, _ = parser.feed_data(payload)
+ reader = messages[0][1]
+ assert isinstance(reader.exception(), http_exceptions.BadHttpMessage)
+ assert "\\nxx" in str(reader.exception())
Index: aiohttp-3.8.5/aiohttp/helpers.py
===================================================================
--- aiohttp-3.8.5.orig/aiohttp/helpers.py
+++ aiohttp-3.8.5/aiohttp/helpers.py
@@ -796,8 +796,10 @@ def set_result(fut: "asyncio.Future[_T]"
def set_exception(fut: "asyncio.Future[_T]", exc: BaseException) -> None:
- if not fut.done():
- fut.set_exception(exc)
+ if asyncio.isfuture(fut) and fut.done():
+ return
+
+ fut.set_exception(exc)
class ChainMapProxy(Mapping[str, Any]):

View File

@ -1,3 +1,24 @@
-------------------------------------------------------------------
Mon Dec 16 09:49:29 UTC 2024 - Daniel Garcia <daniel.garcia@suse.com>
- Add upstream patch CVE-2024-27306.patch, (bsc#1223098, CVE-2024-27306)
* gh#aio-libs/aiohttp#8319
-------------------------------------------------------------------
Thu Dec 12 09:05:37 UTC 2024 - Daniel Garcia <daniel.garcia@suse.com>
- Add upstream patch CVE-2024-30251.patch (bsc#1223726, CVE-2024-30251)
Include three upstream commits:
* gh#aio-libs/aiohttp@cebe526b9c34#diff-5954cadbd6b57b1921fc64d0e6a8f81717127873d9ccec33184d2f971fe6834f
* gh#aio-libs/aiohttp@7eecdff163cc#diff-d582bf292efb8e19696d88c895b99e0937687cb909d9d00b5c2f1d948a5cbae5
* gh#aio-libs/aiohttp@f21c6f2ca512#diff-d582bf292efb8e19696d88c895b99e0937687cb909d9d00b5c2f1d948a5cbae5
-------------------------------------------------------------------
Tue Nov 19 09:54:47 UTC 2024 - Daniel Garcia <daniel.garcia@suse.com>
- Add upstream patch CVE-2024-52304.patch, gh#aio-libs/aiohttp@259edc369075
(bsc#1233447, CVE-2024-52304)
-------------------------------------------------------------------
Mon Sep 11 20:43:01 UTC 2023 - Dirk Müller <dmueller@suse.com>

View File

@ -29,6 +29,15 @@ URL: https://github.com/aio-libs/aiohttp
Source: https://files.pythonhosted.org/packages/source/a/aiohttp/aiohttp-%{version}.tar.gz
# PATCH-FIX-UPSTREAM Update-update_query-calls-to-work-with-latest-yarl.patch gh#aio-libs/aiohttp#7260
Patch1: Update-update_query-calls-to-work-with-latest-yarl.patch
# PATCH-FIX-UPSTREAM CVE-2024-52304.patch bsc#1233447, gh#aio-libs/aiohttp@259edc369075
Patch2: CVE-2024-52304.patch
# PATCH-FIX-UPSTREAM CVE-2024-30251.patch bsc#1223726
# gh#aio-libs/aiohttp@cebe526b9c34#diff-5954cadbd6b57b1921fc64d0e6a8f81717127873d9ccec33184d2f971fe6834f
# gh#aio-libs/aiohttp@7eecdff163cc#diff-d582bf292efb8e19696d88c895b99e0937687cb909d9d00b5c2f1d948a5cbae5
# gh#aio-libs/aiohttp@f21c6f2ca512#diff-d582bf292efb8e19696d88c895b99e0937687cb909d9d00b5c2f1d948a5cbae5
Patch3: CVE-2024-30251.patch
# PATCH-FIX-UPSTREAM CVE-2024-27306.patch bsc#1223098, gh#aio-libs/aiohttp#8319
Patch4: CVE-2024-27306.patch
Requires: python-aiosignal >= 1.1.2
Requires: python-attrs >= 17.3.0
Requires: python-frozenlist >= 1.1.1