Compare commits
10 Commits
cd9fe12cab
...
84086a4380
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84086a4380 | ||
|
|
b52b1c91e2 | ||
|
|
0225ebb3ae | ||
|
|
2c3c3933f6 | ||
|
|
ca7b8ee617 | ||
|
|
905568411f | ||
|
|
af02d6afc0 | ||
|
|
13d5ee5148 | ||
|
|
7f381c5cf3 | ||
|
|
5b39f9a724 |
242
CVE-2024-27306.patch
Normal file
242
CVE-2024-27306.patch
Normal file
@ -0,0 +1,242 @@
|
||||
From 28335525d1eac015a7e7584137678cbb6ff19397 Mon Sep 17 00:00:00 2001
|
||||
From: Sam Bull <git@sambull.org>
|
||||
Date: Thu, 11 Apr 2024 15:54:45 +0100
|
||||
Subject: [PATCH] Escape filenames and paths in HTML when generating index
|
||||
pages (#8317) (#8319)
|
||||
|
||||
Co-authored-by: J. Nick Koston <nick@koston.org>
|
||||
(cherry picked from commit ffbc43233209df302863712b511a11bdb6001b0f)
|
||||
---
|
||||
CHANGES/8317.bugfix.rst | 1 +
|
||||
aiohttp/web_urldispatcher.py | 12 ++--
|
||||
tests/test_web_urldispatcher.py | 124 ++++++++++++++++++++++++++++----
|
||||
3 files changed, 118 insertions(+), 19 deletions(-)
|
||||
create mode 100644 CHANGES/8317.bugfix.rst
|
||||
|
||||
diff --git a/CHANGES/8317.bugfix.rst b/CHANGES/8317.bugfix.rst
|
||||
new file mode 100644
|
||||
index 0000000000..b24ef2aeb8
|
||||
--- /dev/null
|
||||
+++ b/CHANGES/8317.bugfix.rst
|
||||
@@ -0,0 +1 @@
|
||||
+Escaped filenames in static view -- by :user:`bdraco`.
|
||||
diff --git a/aiohttp/web_urldispatcher.py b/aiohttp/web_urldispatcher.py
|
||||
index 9969653344..954291f644 100644
|
||||
--- a/aiohttp/web_urldispatcher.py
|
||||
+++ b/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
|
||||
@@ -90,6 +92,8 @@
|
||||
_ExpectHandler = Callable[[Request], Awaitable[Optional[StreamResponse]]]
|
||||
_Resolve = Tuple[Optional["UrlMappingMatchInfo"], Set[str]]
|
||||
|
||||
+html_escape = functools.partial(html.escape, quote=True)
|
||||
+
|
||||
|
||||
class _InfoDict(TypedDict, total=False):
|
||||
path: str
|
||||
@@ -708,7 +712,7 @@ def _directory_as_html(self, filepath: Path) -> str:
|
||||
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 = []
|
||||
@@ -716,7 +720,7 @@ def _directory_as_html(self, filepath: Path) -> str:
|
||||
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():
|
||||
@@ -725,9 +729,7 @@ def _directory_as_html(self, filepath: Path) -> str:
|
||||
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>"
|
||||
diff --git a/tests/test_web_urldispatcher.py b/tests/test_web_urldispatcher.py
|
||||
index 76e533e473..0441890c10 100644
|
||||
--- a/tests/test_web_urldispatcher.py
|
||||
+++ b/tests/test_web_urldispatcher.py
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import pathlib
|
||||
+import sys
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
@@ -14,31 +15,38 @@
|
||||
|
||||
|
||||
@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(
|
||||
@@ -47,6 +55,7 @@ async def test_access_root_of_static_handler(
|
||||
show_index: bool,
|
||||
status: int,
|
||||
prefix: str,
|
||||
+ request_path: str,
|
||||
data: Optional[bytes],
|
||||
) -> None:
|
||||
# Tests the operation of static file server.
|
||||
@@ -72,7 +81,94 @@ async def test_access_root_of_static_handler(
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
# Request the root of the static directory.
|
||||
- async with await client.get(prefix) as r:
|
||||
+ 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
|
||||
+
|
||||
+
|
||||
+@pytest.mark.internal # Dependent on filesystem
|
||||
+@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)>.dir/</a></li>\n<li><a href="/%3Cimg%20sr'
|
||||
+ b'c=0%20onerror=alert(1)%3E.txt"><img src=0 onerror=alert(1)>.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"><img src=0 onerror=alert(1)>.dir/</a></li>\n<li><a href="/stat'
|
||||
+ b'ic/%3Cimg%20src=0%20onerror=alert(1)%3E.txt"><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 /<img src=0 onerror=alert(1)>.dir</t"
|
||||
+ b"itle>\n</head>\n<body>\n<h1>Index of /<img src=0 onerror=alert(1)>.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: pathlib.Path,
|
||||
+ aiohttp_client: AiohttpClient,
|
||||
+ show_index: bool,
|
||||
+ status: int,
|
||||
+ prefix: str,
|
||||
+ request_path: str,
|
||||
+ data: Optional[bytes],
|
||||
+) -> 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:
|
||||
@ -0,0 +1,67 @@
|
||||
From 7eecdff163ccf029fbb1ddc9de4169d4aaeb6597 Mon Sep 17 00:00:00 2001
|
||||
From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com>
|
||||
Date: Mon, 15 Apr 2024 20:47:19 +0100
|
||||
Subject: [PATCH] [PR #8332/482e6cdf backport][3.9] Add set_content_disposition
|
||||
test (#8333)
|
||||
|
||||
**This is a backport of PR #8332 as merged into master
|
||||
(482e6cdf6516607360666a48c5828d3dbe959fbd).**
|
||||
|
||||
Co-authored-by: Oleg A <t0rr@mail.ru>
|
||||
---
|
||||
CHANGES/8332.bugfix.rst | 1 +
|
||||
aiohttp/multipart.py | 7 +++++--
|
||||
tests/test_multipart.py | 7 +++++++
|
||||
3 files changed, 13 insertions(+), 2 deletions(-)
|
||||
create mode 100644 CHANGES/8332.bugfix.rst
|
||||
|
||||
diff --git a/CHANGES/8332.bugfix.rst b/CHANGES/8332.bugfix.rst
|
||||
new file mode 100644
|
||||
index 0000000000..70cad26b42
|
||||
--- /dev/null
|
||||
+++ b/CHANGES/8332.bugfix.rst
|
||||
@@ -0,0 +1 @@
|
||||
+Fixed regression with adding Content-Disposition to form-data part after appending to writer -- by :user:`Dreamsorcerer`/:user:`Olegt0rr`.
|
||||
diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py
|
||||
index a43ec54571..fcdf16183c 100644
|
||||
--- a/aiohttp/multipart.py
|
||||
+++ b/aiohttp/multipart.py
|
||||
@@ -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)
|
||||
|
||||
diff --git a/tests/test_multipart.py b/tests/test_multipart.py
|
||||
index dbfaf74b9b..37ac54797f 100644
|
||||
--- a/tests/test_multipart.py
|
||||
+++ b/tests/test_multipart.py
|
||||
@@ -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")
|
||||
@ -0,0 +1,73 @@
|
||||
From f21c6f2ca512a026ce7f0f6c6311f62d6a638866 Mon Sep 17 00:00:00 2001
|
||||
From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com>
|
||||
Date: Mon, 15 Apr 2024 21:54:12 +0100
|
||||
Subject: [PATCH] [PR #8335/5a6949da backport][3.9] Add Content-Disposition
|
||||
automatically (#8336)
|
||||
|
||||
**This is a backport of PR #8335 as merged into master
|
||||
(5a6949da642d1db6cf414fd0d1f70e54c7b7be14).**
|
||||
|
||||
Co-authored-by: Sam Bull <git@sambull.org>
|
||||
---
|
||||
CHANGES/8335.bugfix.rst | 1 +
|
||||
aiohttp/multipart.py | 4 ++++
|
||||
tests/test_multipart.py | 22 +++++++++++++++++-----
|
||||
3 files changed, 22 insertions(+), 5 deletions(-)
|
||||
create mode 100644 CHANGES/8335.bugfix.rst
|
||||
|
||||
diff --git a/CHANGES/8335.bugfix.rst b/CHANGES/8335.bugfix.rst
|
||||
new file mode 100644
|
||||
index 0000000000..cd93b864a5
|
||||
--- /dev/null
|
||||
+++ b/CHANGES/8335.bugfix.rst
|
||||
@@ -0,0 +1 @@
|
||||
+Added default Content-Disposition in multipart/form-data responses -- by :user:`Dreamsorcerer`.
|
||||
diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py
|
||||
index fcdf16183c..71fc2654a1 100644
|
||||
--- a/aiohttp/multipart.py
|
||||
+++ b/aiohttp/multipart.py
|
||||
@@ -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()
|
||||
diff --git a/tests/test_multipart.py b/tests/test_multipart.py
|
||||
index 37ac54797f..436b70957f 100644
|
||||
--- a/tests/test_multipart.py
|
||||
+++ b/tests/test_multipart.py
|
||||
@@ -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:
|
||||
511
CVE-2024-30251.patch
Normal file
511
CVE-2024-30251.patch
Normal file
@ -0,0 +1,511 @@
|
||||
From cebe526b9c34dc3a3da9140409db63014bc4cf19 Mon Sep 17 00:00:00 2001
|
||||
From: Sam Bull <git@sambull.org>
|
||||
Date: Sun, 7 Apr 2024 13:19:31 +0100
|
||||
Subject: [PATCH] Fix handling of multipart/form-data (#8280) (#8302)
|
||||
|
||||
https://datatracker.ietf.org/doc/html/rfc7578
|
||||
(cherry picked from commit 7d0be3fee540a3d4161ac7dc76422f1f5ea60104)
|
||||
---
|
||||
CHANGES/8280.bugfix.rst | 1 +
|
||||
CHANGES/8280.deprecation.rst | 2 +
|
||||
aiohttp/formdata.py | 12 +++-
|
||||
aiohttp/multipart.py | 121 +++++++++++++++++++++-----------
|
||||
tests/test_client_functional.py | 44 +-----------
|
||||
tests/test_multipart.py | 68 ++++++++++++++----
|
||||
tests/test_web_functional.py | 27 ++-----
|
||||
7 files changed, 155 insertions(+), 120 deletions(-)
|
||||
create mode 100644 CHANGES/8280.bugfix.rst
|
||||
create mode 100644 CHANGES/8280.deprecation.rst
|
||||
|
||||
diff --git a/CHANGES/8280.bugfix.rst b/CHANGES/8280.bugfix.rst
|
||||
new file mode 100644
|
||||
index 00000000000..3aebe36fe9e
|
||||
--- /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 00000000000..302dbb2fe2a
|
||||
--- /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 e7cd24ca9f7..2b75b3de72c 100644
|
||||
--- a/aiohttp/formdata.py
|
||||
+++ b/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 @@ 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))
|
||||
diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py
|
||||
index 4471dd4bb7e..a43ec545713 100644
|
||||
--- a/aiohttp/multipart.py
|
||||
+++ b/aiohttp/multipart.py
|
||||
@@ -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
|
||||
diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py
|
||||
index 8a9a4e184be..dbb2dff5ac4 100644
|
||||
--- a/tests/test_client_functional.py
|
||||
+++ b/tests/test_client_functional.py
|
||||
@@ -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
|
||||
|
||||
diff --git a/tests/test_multipart.py b/tests/test_multipart.py
|
||||
index f9d130e7949..dbfaf74b9b7 100644
|
||||
--- a/tests/test_multipart.py
|
||||
+++ b/tests/test_multipart.py
|
||||
@@ -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"'
|
||||
)
|
||||
|
||||
|
||||
diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py
|
||||
index 04fc2e35fd1..ee61537068b 100644
|
||||
--- a/tests/test_web_functional.py
|
||||
+++ b/tests/test_web_functional.py
|
||||
@@ -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()
|
||||
198
CVE-2024-42367.patch
Normal file
198
CVE-2024-42367.patch
Normal file
@ -0,0 +1,198 @@
|
||||
From f98240ad2279c3e97b65eddce40d37948f383416 Mon Sep 17 00:00:00 2001
|
||||
From: "J. Nick Koston" <nick@koston.org>
|
||||
Date: Thu, 8 Aug 2024 11:19:28 -0500
|
||||
Subject: [PATCH] Do not follow symlinks for compressed file variants (#8652)
|
||||
|
||||
Co-authored-by: Steve Repsher <steverep@users.noreply.github.com>
|
||||
(cherry picked from commit b0536ae6babf160105d4025ea87c02b9fa5629f1)
|
||||
---
|
||||
CHANGES/8652.bugfix.rst | 1 +
|
||||
aiohttp/web_fileresponse.py | 9 ++++++--
|
||||
tests/test_web_sendfile.py | 37 ++++++++++++++++++++-------------
|
||||
tests/test_web_urldispatcher.py | 32 ++++++++++++++++++++++++++++
|
||||
4 files changed, 63 insertions(+), 16 deletions(-)
|
||||
create mode 100644 CHANGES/8652.bugfix.rst
|
||||
|
||||
diff --git a/CHANGES/8652.bugfix.rst b/CHANGES/8652.bugfix.rst
|
||||
new file mode 100644
|
||||
index 0000000..3a1003e
|
||||
--- /dev/null
|
||||
+++ b/CHANGES/8652.bugfix.rst
|
||||
@@ -0,0 +1 @@
|
||||
+Fixed incorrectly following symlinks for compressed file variants -- by :user:`steverep`.
|
||||
diff --git a/aiohttp/web_fileresponse.py b/aiohttp/web_fileresponse.py
|
||||
index 6496ffa..acb0579 100644
|
||||
--- a/aiohttp/web_fileresponse.py
|
||||
+++ b/aiohttp/web_fileresponse.py
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
import pathlib
|
||||
+from stat import S_ISREG
|
||||
from typing import ( # noqa
|
||||
IO,
|
||||
TYPE_CHECKING,
|
||||
@@ -136,12 +137,16 @@ class FileResponse(StreamResponse):
|
||||
if check_for_gzipped_file:
|
||||
gzip_path = filepath.with_name(filepath.name + ".gz")
|
||||
try:
|
||||
- return gzip_path, gzip_path.stat(), True
|
||||
+ st = gzip_path.lstat()
|
||||
+ if S_ISREG(st.st_mode):
|
||||
+ return gzip_path, st, True
|
||||
except OSError:
|
||||
# Fall through and try the non-gzipped file
|
||||
pass
|
||||
|
||||
- return filepath, filepath.stat(), False
|
||||
+ st = filepath.lstat()
|
||||
+ if S_ISREG(st.st_mode):
|
||||
+ return filepath, st, False
|
||||
|
||||
async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
|
||||
loop = asyncio.get_event_loop()
|
||||
diff --git a/tests/test_web_sendfile.py b/tests/test_web_sendfile.py
|
||||
index 2817e08..da9e6ae 100644
|
||||
--- a/tests/test_web_sendfile.py
|
||||
+++ b/tests/test_web_sendfile.py
|
||||
@@ -1,10 +1,13 @@
|
||||
from pathlib import Path
|
||||
+from stat import S_IFREG, S_IRUSR, S_IWUSR
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp import hdrs
|
||||
from aiohttp.test_utils import make_mocked_coro, make_mocked_request
|
||||
from aiohttp.web_fileresponse import FileResponse
|
||||
|
||||
+MOCK_MODE = S_IFREG | S_IRUSR | S_IWUSR
|
||||
+
|
||||
|
||||
def test_using_gzip_if_header_present_and_file_available(loop) -> None:
|
||||
request = make_mocked_request(
|
||||
@@ -12,8 +15,9 @@ def test_using_gzip_if_header_present_and_file_available(loop) -> None:
|
||||
)
|
||||
|
||||
gz_filepath = mock.create_autospec(Path, spec_set=True)
|
||||
- gz_filepath.stat.return_value.st_size = 1024
|
||||
- gz_filepath.stat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ gz_filepath.lstat.return_value.st_size = 1024
|
||||
+ gz_filepath.lstat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ gz_filepath.lstat.return_value.st_mode = MOCK_MODE
|
||||
|
||||
filepath = mock.create_autospec(Path, spec_set=True)
|
||||
filepath.name = "logo.png"
|
||||
@@ -33,14 +37,16 @@ def test_gzip_if_header_not_present_and_file_available(loop) -> None:
|
||||
request = make_mocked_request("GET", "http://python.org/logo.png", headers={})
|
||||
|
||||
gz_filepath = mock.create_autospec(Path, spec_set=True)
|
||||
- gz_filepath.stat.return_value.st_size = 1024
|
||||
- gz_filepath.stat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ gz_filepath.lstat.return_value.st_size = 1024
|
||||
+ gz_filepath.lstat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ gz_filepath.lstat.return_value.st_mode = MOCK_MODE
|
||||
|
||||
filepath = mock.create_autospec(Path, spec_set=True)
|
||||
filepath.name = "logo.png"
|
||||
filepath.with_name.return_value = gz_filepath
|
||||
- filepath.stat.return_value.st_size = 1024
|
||||
- filepath.stat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ filepath.lstat.return_value.st_size = 1024
|
||||
+ filepath.lstat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ filepath.lstat.return_value.st_mode = MOCK_MODE
|
||||
|
||||
file_sender = FileResponse(filepath)
|
||||
file_sender._path = filepath
|
||||
@@ -56,13 +62,14 @@ def test_gzip_if_header_not_present_and_file_not_available(loop) -> None:
|
||||
request = make_mocked_request("GET", "http://python.org/logo.png", headers={})
|
||||
|
||||
gz_filepath = mock.create_autospec(Path, spec_set=True)
|
||||
- gz_filepath.stat.side_effect = OSError(2, "No such file or directory")
|
||||
+ gz_filepath.lstat.side_effect = OSError(2, "No such file or directory")
|
||||
|
||||
filepath = mock.create_autospec(Path, spec_set=True)
|
||||
filepath.name = "logo.png"
|
||||
filepath.with_name.return_value = gz_filepath
|
||||
- filepath.stat.return_value.st_size = 1024
|
||||
- filepath.stat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ filepath.lstat.return_value.st_size = 1024
|
||||
+ filepath.lstat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ filepath.lstat.return_value.st_mode = MOCK_MODE
|
||||
|
||||
file_sender = FileResponse(filepath)
|
||||
file_sender._path = filepath
|
||||
@@ -80,13 +87,14 @@ def test_gzip_if_header_present_and_file_not_available(loop) -> None:
|
||||
)
|
||||
|
||||
gz_filepath = mock.create_autospec(Path, spec_set=True)
|
||||
- gz_filepath.stat.side_effect = OSError(2, "No such file or directory")
|
||||
+ gz_filepath.lstat.side_effect = OSError(2, "No such file or directory")
|
||||
|
||||
filepath = mock.create_autospec(Path, spec_set=True)
|
||||
filepath.name = "logo.png"
|
||||
filepath.with_name.return_value = gz_filepath
|
||||
- filepath.stat.return_value.st_size = 1024
|
||||
- filepath.stat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ filepath.lstat.return_value.st_size = 1024
|
||||
+ filepath.lstat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ filepath.lstat.return_value.st_mode = MOCK_MODE
|
||||
|
||||
file_sender = FileResponse(filepath)
|
||||
file_sender._path = filepath
|
||||
@@ -103,8 +111,9 @@ def test_status_controlled_by_user(loop) -> None:
|
||||
|
||||
filepath = mock.create_autospec(Path, spec_set=True)
|
||||
filepath.name = "logo.png"
|
||||
- filepath.stat.return_value.st_size = 1024
|
||||
- filepath.stat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ filepath.lstat.return_value.st_size = 1024
|
||||
+ filepath.lstat.return_value.st_mtime_ns = 1603733507222449291
|
||||
+ filepath.lstat.return_value.st_mode = MOCK_MODE
|
||||
|
||||
file_sender = FileResponse(filepath, status=203)
|
||||
file_sender._path = filepath
|
||||
diff --git a/tests/test_web_urldispatcher.py b/tests/test_web_urldispatcher.py
|
||||
index 0441890..62cb0de 100644
|
||||
--- a/tests/test_web_urldispatcher.py
|
||||
+++ b/tests/test_web_urldispatcher.py
|
||||
@@ -440,6 +440,38 @@ async def test_access_symlink_loop(
|
||||
assert r.status == 404
|
||||
|
||||
|
||||
+async def test_access_compressed_file_as_symlink(
|
||||
+ tmp_path: pathlib.Path, aiohttp_client: AiohttpClient
|
||||
+) -> None:
|
||||
+ """Test that compressed file variants as symlinks are ignored."""
|
||||
+ private_file = tmp_path / "private.txt"
|
||||
+ private_file.write_text("private info")
|
||||
+ www_dir = tmp_path / "www"
|
||||
+ www_dir.mkdir()
|
||||
+ gz_link = www_dir / "file.txt.gz"
|
||||
+ gz_link.symlink_to(f"../{private_file.name}")
|
||||
+
|
||||
+ app = web.Application()
|
||||
+ app.router.add_static("/", www_dir)
|
||||
+ client = await aiohttp_client(app)
|
||||
+
|
||||
+ # Symlink should be ignored; response reflects missing uncompressed file.
|
||||
+ resp = await client.get(f"/{gz_link.stem}", auto_decompress=False)
|
||||
+ assert resp.status == 404
|
||||
+ resp.release()
|
||||
+
|
||||
+ # Again symlin is ignored, and then uncompressed is served.
|
||||
+ txt_file = gz_link.with_suffix("")
|
||||
+ txt_file.write_text("public data")
|
||||
+ resp = await client.get(f"/{txt_file.name}")
|
||||
+ assert resp.status == 200
|
||||
+ assert resp.headers.get("Content-Encoding") is None
|
||||
+ assert resp.content_type == "text/plain"
|
||||
+ assert await resp.text() == "public data"
|
||||
+ resp.release()
|
||||
+ await client.close()
|
||||
+
|
||||
+
|
||||
async def test_access_special_resource(
|
||||
tmp_path: pathlib.Path, aiohttp_client: AiohttpClient
|
||||
) -> None:
|
||||
--
|
||||
2.33.0
|
||||
|
||||
109
CVE-2024-52304.patch
Normal file
109
CVE-2024-52304.patch
Normal file
@ -0,0 +1,109 @@
|
||||
From 259edc369075de63e6f3a4eaade058c62af0df71 Mon Sep 17 00:00:00 2001
|
||||
From: "J. Nick Koston" <nick@koston.org>
|
||||
Date: Wed, 13 Nov 2024 08:50:36 -0600
|
||||
Subject: [PATCH] [PR #9851/541d86d backport][3.10] Fix incorrect parsing of
|
||||
chunk extensions with the pure Python parser (#9853)
|
||||
---
|
||||
CHANGES/9851.bugfix.rst | 1 +
|
||||
aiohttp/http_parser.py | 7 ++++++
|
||||
tests/test_http_parser.py | 51 ++++++++++++++++++++++++++++++++++++++-
|
||||
3 files changed, 58 insertions(+), 1 deletion(-)
|
||||
create mode 100644 CHANGES/9851.bugfix.rst
|
||||
|
||||
diff --git a/CHANGES/9851.bugfix.rst b/CHANGES/9851.bugfix.rst
|
||||
new file mode 100644
|
||||
index 0000000..02541a9
|
||||
--- /dev/null
|
||||
+++ b/CHANGES/9851.bugfix.rst
|
||||
@@ -0,0 +1 @@
|
||||
+Fixed incorrect parsing of chunk extensions with the pure Python parser -- by :user:`bdraco`.
|
||||
diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py
|
||||
index d7b8dac..deee4f5 100644
|
||||
--- a/aiohttp/http_parser.py
|
||||
+++ b/aiohttp/http_parser.py
|
||||
@@ -833,6 +833,13 @@ 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 (ext := chunk[i:pos]):
|
||||
+ exc = BadHttpMessage(
|
||||
+ f"Unexpected LF in chunk-extension: {ext!r}"
|
||||
+ )
|
||||
+ set_exception(self.payload, exc)
|
||||
+ raise exc
|
||||
else:
|
||||
size_b = chunk[:pos]
|
||||
|
||||
diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py
|
||||
index 0417fa4..d348bae 100644
|
||||
--- a/tests/test_http_parser.py
|
||||
+++ b/tests/test_http_parser.py
|
||||
@@ -13,6 +13,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,
|
||||
@@ -1337,7 +1338,55 @@ def test_parse_chunked_payload_empty_body_than_another_chunked(
|
||||
assert b"second" == b"".join(d for d in payload._buffer)
|
||||
|
||||
|
||||
-def test_partial_url(parser: Any) -> None:
|
||||
+@pytest.mark.skipif(NO_EXTENSIONS, reason="Only tests C parser.")
|
||||
+async def test_parse_chunked_payload_with_lf_in_extensions_c_parser(
|
||||
+ loop: asyncio.AbstractEventLoop, protocol: BaseProtocol
|
||||
+) -> None:
|
||||
+ """Test the C-parser with a chunked payload that has a LF in the chunk extensions."""
|
||||
+ # The C parser will raise a BadHttpMessage from feed_data
|
||||
+ parser = HttpRequestParserC(
|
||||
+ protocol,
|
||||
+ loop,
|
||||
+ 2**16,
|
||||
+ 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"
|
||||
+ )
|
||||
+ with pytest.raises(http_exceptions.BadHttpMessage, match="\\\\nxx"):
|
||||
+ parser.feed_data(payload)
|
||||
+
|
||||
+
|
||||
+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,
|
||||
+ 2**16,
|
||||
+ 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())
|
||||
+
|
||||
+
|
||||
+def test_partial_url(parser: HttpRequestParser) -> None:
|
||||
messages, upgrade, tail = parser.feed_data(b"GET /te")
|
||||
assert len(messages) == 0
|
||||
messages, upgrade, tail = parser.feed_data(b"st HTTP/1.1\r\n\r\n")
|
||||
--
|
||||
2.41.0
|
||||
|
||||
57
Fix-Python-parser-to-mark-responses-without-length-a.patch
Normal file
57
Fix-Python-parser-to-mark-responses-without-length-a.patch
Normal file
@ -0,0 +1,57 @@
|
||||
From 3223e1209285d96cfe5ac92c68653c5690e6e721 Mon Sep 17 00:00:00 2001
|
||||
From: =?UTF-8?q?=E8=82=96=E5=9C=A8?= <xiaozai@kylinos.cn>
|
||||
Date: Mon, 6 May 2024 20:30:09 +0800
|
||||
Subject: [PATCH] Fix Python parser to mark responses without length as closing
|
||||
|
||||
---
|
||||
CHANGES/8320.bugfix.rst | 1 +
|
||||
aiohttp/http_parser.py | 11 ++++++++++-
|
||||
tests/test_http_parser.py | 2 +-
|
||||
3 files changed, 12 insertions(+), 2 deletions(-)
|
||||
create mode 100644 CHANGES/8320.bugfix.rst
|
||||
|
||||
diff --git a/CHANGES/8320.bugfix.rst b/CHANGES/8320.bugfix.rst
|
||||
new file mode 100644
|
||||
index 0000000..3823e24
|
||||
--- /dev/null
|
||||
+++ b/CHANGES/8320.bugfix.rst
|
||||
@@ -0,0 +1 @@
|
||||
+Fixed the pure python parser to mark a connection as closing when a response has no length -- by :user:`Dreamsorcerer`
|
||||
diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py
|
||||
index 1877f55..d7b8dac 100644
|
||||
--- a/aiohttp/http_parser.py
|
||||
+++ b/aiohttp/http_parser.py
|
||||
@@ -703,7 +703,16 @@ class HttpResponseParser(HttpParser[RawResponseMessage]):
|
||||
) = self.parse_headers(lines)
|
||||
|
||||
if close is None:
|
||||
- close = version_o <= HttpVersion10
|
||||
+ if version_o <= HttpVersion10:
|
||||
+ close = True
|
||||
+ # https://www.rfc-editor.org/rfc/rfc9112.html#name-message-body-length
|
||||
+ elif 100 <= status_i < 200 or status_i in {204, 304}:
|
||||
+ close = False
|
||||
+ elif hdrs.CONTENT_LENGTH in headers or hdrs.TRANSFER_ENCODING in headers:
|
||||
+ close = False
|
||||
+ else:
|
||||
+ # https://www.rfc-editor.org/rfc/rfc9112.html#section-6.3-2.8
|
||||
+ close = True
|
||||
|
||||
return RawResponseMessage(
|
||||
version_o,
|
||||
diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py
|
||||
index b931730..0417fa4 100644
|
||||
--- a/tests/test_http_parser.py
|
||||
+++ b/tests/test_http_parser.py
|
||||
@@ -743,7 +743,7 @@ def test_http_request_parser(parser) -> None:
|
||||
assert msg.version == (1, 1)
|
||||
assert msg.headers == CIMultiDict()
|
||||
assert msg.raw_headers == ()
|
||||
- assert not msg.should_close
|
||||
+ assert msg.should_close
|
||||
assert msg.compression is None
|
||||
assert not msg.upgrade
|
||||
assert not msg.chunked
|
||||
--
|
||||
2.33.0
|
||||
|
||||
@ -1,11 +1,24 @@
|
||||
%global _empty_manifest_terminate_build 0
|
||||
Name: python-aiohttp
|
||||
Version: 3.9.3
|
||||
Release: 1
|
||||
Release: 6
|
||||
Summary: Async http client/server framework (asyncio)
|
||||
License: Apache 2
|
||||
URL: https://github.com/aio-libs/aiohttp
|
||||
Source0: %{pypi_source aiohttp}
|
||||
# https://github.com/aio-libs/aiohttp/commit/28335525d1eac015a7e7584137678cbb6ff19397
|
||||
Patch0: CVE-2024-27306.patch
|
||||
# https://github.com/aio-libs/aiohttp/commit/cebe526b9c34dc3a3da9140409db63014bc4cf19
|
||||
Patch1: CVE-2024-30251.patch
|
||||
# https://github.com/aio-libs/aiohttp/commit/7eecdff163ccf029fbb1ddc9de4169d4aaeb6597
|
||||
Patch2: CVE-2024-30251-PR-8332-482e6cdf-backport-3.9-Add-set_content_dispos.patch
|
||||
# https://github.com/aio-libs/aiohttp/commit/f21c6f2ca512a026ce7f0f6c6311f62d6a638866
|
||||
Patch3: CVE-2024-30251-PR-8335-5a6949da-backport-3.9-Add-Content-Dispositio.patch
|
||||
# https://github.com/aio-libs/aiohttp/commit/9ba9a4e531599b9cb2f8cc80effbde40c7eab0bd
|
||||
Patch4: Fix-Python-parser-to-mark-responses-without-length-a.patch
|
||||
Patch5: CVE-2024-42367.patch
|
||||
#https://github.com/aio-libs/aiohttp/commit/259edc369075de63e6f3a4eaade058c62af0df71.patch
|
||||
Patch6: CVE-2024-52304.patch
|
||||
|
||||
Requires: python3-attrs
|
||||
Requires: python3-charset-normalizer
|
||||
@ -81,6 +94,21 @@ mv %{buildroot}/doclist.lst .
|
||||
%{_docdir}/*
|
||||
|
||||
%changelog
|
||||
* Tue Nov 19 2024 changtao <changtao@kylinos.cn> - 3.9.3-6
|
||||
- Fix CVE-2024-52304
|
||||
|
||||
* Fri Oct 11 2024 yaoxin <yao_xin001@hoperun.com> - 3.9.3-5
|
||||
- Fix CVE-2024-42367
|
||||
|
||||
* Mon May 06 2024 xiaozai <xiaozai@kylinos.cn> - 3.9.3-4
|
||||
- Fix Python parser to mark responses without length as closing
|
||||
|
||||
* Mon May 06 2024 yaoxin <yao_xin001@hoperun.com> - 3.9.3-3
|
||||
- Fix CVE-2024-30251
|
||||
|
||||
* Mon Apr 22 2024 yaoxin <yao_xin001@hoperun.com> - 3.9.3-2
|
||||
- Fix CVE-2024-27306
|
||||
|
||||
* Wed Jan 31 2024 yaoxin <yao_xin001@hoperun.com> - 3.9.3-1
|
||||
- Upgrade to 3.9.3 for fix CVE-2024-23334 and CVE-2024-23829
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user