From 9d926e8cf77eb1c6c89c43ab46a19487b5691fd1 Mon Sep 17 00:00:00 2001 From: starlet-dx <15929766099@163.com> Date: Thu, 6 Mar 2025 09:49:47 +0800 Subject: [PATCH] Fix CVE-2023-47627,CVE-2023-49082,CVE-2024-23334,CVE-2024-23829,CVE-2024-27306 and CVE-2024-30251 (cherry picked from commit dabdc40effcfef17ad7e2a967edf709e75859b23) --- CVE-2023-47627.patch | 353 ++++++++++++++++++++++++++ CVE-2023-49082.patch | 60 +++++ CVE-2024-23334.patch | 205 +++++++++++++++ CVE-2024-23829.patch | 343 +++++++++++++++++++++++++ CVE-2024-27306.patch | 229 +++++++++++++++++ CVE-2024-30251-Followup-01.patch | 56 ++++ CVE-2024-30251-Followup-02.patch | 62 +++++ CVE-2024-30251.patch | 421 +++++++++++++++++++++++++++++++ python-aiohttp.spec | 13 +- 9 files changed, 1741 insertions(+), 1 deletion(-) create mode 100644 CVE-2023-47627.patch create mode 100644 CVE-2023-49082.patch create mode 100644 CVE-2024-23334.patch create mode 100644 CVE-2024-23829.patch create mode 100644 CVE-2024-27306.patch create mode 100644 CVE-2024-30251-Followup-01.patch create mode 100644 CVE-2024-30251-Followup-02.patch create mode 100644 CVE-2024-30251.patch diff --git a/CVE-2023-47627.patch b/CVE-2023-47627.patch new file mode 100644 index 0000000..d32c708 --- /dev/null +++ b/CVE-2023-47627.patch @@ -0,0 +1,353 @@ +From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> +Date: Fri, 6 Oct 2023 17:11:40 +0100 +Subject: Update Python parser for RFCs 9110/9112 (#7662) + +**This is a backport of PR #7661 as merged into 3.9 +(85713a4894610e848490915e5871ad71199348e2).** + +None + +Co-authored-by: Sam Bull +--- + aiohttp/http_parser.py | 85 ++++++++++++++++++++++++---------------- + tests/test_http_parser.py | 99 +++++++++++++++++++++++++++++++++++++++++++++-- + 2 files changed, 146 insertions(+), 38 deletions(-) + +diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py +index 71ba815..0406bf4 100644 +--- a/aiohttp/http_parser.py ++++ b/aiohttp/http_parser.py +@@ -5,7 +5,7 @@ import re + import string + import zlib + from enum import IntEnum +-from typing import Any, List, Optional, Tuple, Type, Union ++from typing import Any, Final, List, Optional, Pattern, Tuple, Type, Union + + from multidict import CIMultiDict, CIMultiDictProxy, istr + from yarl import URL +@@ -45,16 +45,16 @@ __all__ = ( + + ASCIISET = set(string.printable) + +-# See https://tools.ietf.org/html/rfc7230#section-3.1.1 +-# and https://tools.ietf.org/html/rfc7230#appendix-B ++# See https://www.rfc-editor.org/rfc/rfc9110.html#name-overview ++# and https://www.rfc-editor.org/rfc/rfc9110.html#name-tokens + # + # method = token + # tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / + # "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA + # token = 1*tchar + METHRE = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+") +-VERSRE = re.compile(r"HTTP/(\d+).(\d+)") +-HDRRE = re.compile(rb"[\x00-\x1F\x7F()<>@,;:\[\]={} \t\\\\\"]") ++VERSRE: Final[Pattern[str]] = re.compile(r"HTTP/(\d).(\d)") ++HDRRE: Final[Pattern[bytes]] = re.compile(rb"[\x00-\x1F\x7F()<>@,;:\[\]={} \t\"\\]") + + RawRequestMessage = collections.namedtuple( + "RawRequestMessage", +@@ -132,8 +132,11 @@ class HeadersParser: + except ValueError: + raise InvalidHeader(line) from None + +- bname = bname.strip(b" \t") +- bvalue = bvalue.lstrip() ++ # https://www.rfc-editor.org/rfc/rfc9112.html#section-5.1-2 ++ if {bname[0], bname[-1]} & {32, 9}: # {" ", "\t"} ++ raise InvalidHeader(line) ++ ++ bvalue = bvalue.lstrip(b" \t") + if HDRRE.search(bname): + raise InvalidHeader(bname) + if len(bname) > self.max_field_size: +@@ -154,6 +157,7 @@ class HeadersParser: + # consume continuation lines + continuation = line and line[0] in (32, 9) # (' ', '\t') + ++ # Deprecated: https://www.rfc-editor.org/rfc/rfc9112.html#name-obsolete-line-folding + if continuation: + bvalue_lst = [bvalue] + while continuation: +@@ -188,10 +192,14 @@ class HeadersParser: + str(header_length), + ) + +- bvalue = bvalue.strip() ++ bvalue = bvalue.strip(b" \t") + name = bname.decode("utf-8", "surrogateescape") + value = bvalue.decode("utf-8", "surrogateescape") + ++ # https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5-5 ++ if "\n" in value or "\r" in value or "\x00" in value: ++ raise InvalidHeader(bvalue) ++ + headers.add(name, value) + raw_headers.append((bname, bvalue)) + +@@ -304,13 +312,13 @@ class HttpParser(abc.ABC): + # payload length + length = msg.headers.get(CONTENT_LENGTH) + if length is not None: +- try: +- length = int(length) +- except ValueError: +- raise InvalidHeader(CONTENT_LENGTH) +- if length < 0: ++ # Shouldn't allow +/- or other number formats. ++ # https://www.rfc-editor.org/rfc/rfc9110#section-8.6-2 ++ if not length.strip(" \t").isdigit(): + raise InvalidHeader(CONTENT_LENGTH) + ++ length = int(length) ++ + # do not support old websocket spec + if SEC_WEBSOCKET_KEY1 in msg.headers: + raise InvalidHeader(SEC_WEBSOCKET_KEY1) +@@ -447,6 +455,24 @@ class HttpParser(abc.ABC): + upgrade = False + chunked = False + ++ # https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5-6 ++ # https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf ++ singletons = ( ++ hdrs.CONTENT_LENGTH, ++ hdrs.CONTENT_LOCATION, ++ hdrs.CONTENT_RANGE, ++ hdrs.CONTENT_TYPE, ++ hdrs.ETAG, ++ hdrs.HOST, ++ hdrs.MAX_FORWARDS, ++ hdrs.SERVER, ++ hdrs.TRANSFER_ENCODING, ++ hdrs.USER_AGENT, ++ ) ++ bad_hdr = next((h for h in singletons if len(headers.getall(h, ())) > 1), None) ++ if bad_hdr is not None: ++ raise BadHttpMessage(f"Duplicate '{bad_hdr}' header found.") ++ + # keep-alive + conn = headers.get(hdrs.CONNECTION) + if conn: +@@ -489,7 +515,7 @@ class HttpRequestParser(HttpParser): + # request line + line = lines[0].decode("utf-8", "surrogateescape") + try: +- method, path, version = line.split(None, 2) ++ method, path, version = line.split(maxsplit=2) + except ValueError: + raise BadStatusLine(line) from None + +@@ -506,14 +532,10 @@ class HttpRequestParser(HttpParser): + raise BadStatusLine(method) + + # version +- try: +- if version.startswith("HTTP/"): +- n1, n2 = version[5:].split(".", 1) +- version_o = HttpVersion(int(n1), int(n2)) +- else: +- raise BadStatusLine(version) +- except Exception: +- raise BadStatusLine(version) ++ match = VERSRE.match(version) ++ if match is None: ++ raise BadStatusLine(line) ++ version_o = HttpVersion(int(match.group(1)), int(match.group(2))) + + # read headers + ( +@@ -563,12 +585,12 @@ class HttpResponseParser(HttpParser): + def parse_message(self, lines: List[bytes]) -> Any: + line = lines[0].decode("utf-8", "surrogateescape") + try: +- version, status = line.split(None, 1) ++ version, status = line.split(maxsplit=1) + except ValueError: + raise BadStatusLine(line) from None + + try: +- status, reason = status.split(None, 1) ++ status, reason = status.split(maxsplit=1) + except ValueError: + reason = "" + +@@ -584,13 +606,9 @@ class HttpResponseParser(HttpParser): + version_o = HttpVersion(int(match.group(1)), int(match.group(2))) + + # The status code is a three-digit number +- try: +- status_i = int(status) +- except ValueError: +- raise BadStatusLine(line) from None +- +- if status_i > 999: ++ if len(status) != 3 or not status.isdigit(): + raise BadStatusLine(line) ++ status_i = int(status) + + # read headers + ( +@@ -725,14 +743,13 @@ class HttpPayloadParser: + else: + size_b = chunk[:pos] + +- try: +- size = int(bytes(size_b), 16) +- except ValueError: ++ if not size_b.isdigit(): + exc = TransferEncodingError( + chunk[:pos].decode("ascii", "surrogateescape") + ) + self.payload.set_exception(exc) +- raise exc from None ++ raise exc ++ size = int(bytes(size_b), 16) + + chunk = chunk[pos + 2 :] + if size == 0: # eof marker +diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py +index ab39cdd..214afe8 100644 +--- a/tests/test_http_parser.py ++++ b/tests/test_http_parser.py +@@ -3,6 +3,7 @@ + import asyncio + from unittest import mock + from urllib.parse import quote ++from typing import Any + + import pytest + from multidict import CIMultiDict +@@ -365,6 +366,83 @@ def test_invalid_name(parser) -> None: + parser.feed_data(text) + + ++def test_cve_2023_37276(parser: Any) -> None: ++ text = b"""POST / HTTP/1.1\r\nHost: localhost:8080\r\nX-Abc: \rxTransfer-Encoding: chunked\r\n\r\n""" ++ if isinstance(parser, HttpRequestParserPy): ++ with pytest.raises(aiohttp.http_exceptions.InvalidHeader): ++ parser.feed_data(text) ++ else: ++ pytest.xfail("Regression test for Py parser. May match C behaviour later.") ++ ++ ++@pytest.mark.parametrize( ++ "hdr", ++ ( ++ "Content-Length: -5", # https://www.rfc-editor.org/rfc/rfc9110.html#name-content-length ++ "Content-Length: +256", ++ "Foo: abc\rdef", # https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5-5 ++ "Bar: abc\ndef", ++ "Baz: abc\x00def", ++ "Foo : bar", # https://www.rfc-editor.org/rfc/rfc9112.html#section-5.1-2 ++ "Foo\t: bar", ++ ), ++) ++def test_bad_headers(parser: Any, hdr: str) -> None: ++ text = f"POST / HTTP/1.1\r\n{hdr}\r\n\r\n".encode() ++ if isinstance(parser, HttpRequestParserPy): ++ with pytest.raises(aiohttp.http_exceptions.InvalidHeader): ++ parser.feed_data(text) ++ elif hdr != "Foo : bar": ++ with pytest.raises(aiohttp.http_exceptions.BadHttpMessage): ++ parser.feed_data(text) ++ else: ++ pytest.xfail("Regression test for Py parser. May match C behaviour later.") ++ ++ ++def test_bad_chunked_py(loop: Any, protocol: Any) -> None: ++ """Test that invalid chunked encoding doesn't allow content-length to be used.""" ++ parser = HttpRequestParserPy( ++ protocol, ++ loop, ++ 2**16, ++ max_line_size=8190, ++ max_field_size=8190, ++ ) ++ text = ( ++ b"GET / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\r\n0_2e\r\n\r\n" ++ + b"GET / HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\n\r\n0\r\n\r\n" ++ ) ++ messages, upgrade, tail = parser.feed_data(text) ++ assert isinstance(messages[0][1].exception(), http_exceptions.TransferEncodingError) ++ ++ ++@pytest.mark.skipif( ++ "HttpRequestParserC" not in dir(aiohttp.http_parser), ++ reason="C based HTTP parser not available", ++) ++def test_bad_chunked_c(loop: Any, protocol: Any) -> None: ++ """C parser behaves differently. Maybe we should align them later.""" ++ parser = HttpRequestParserC( ++ protocol, ++ loop, ++ 2**16, ++ max_line_size=8190, ++ max_field_size=8190, ++ ) ++ text = ( ++ b"GET / HTTP/1.1\r\nHost: a\r\nTransfer-Encoding: chunked\r\n\r\n0_2e\r\n\r\n" ++ + b"GET / HTTP/1.1\r\nHost: a\r\nContent-Length: 5\r\n\r\n0\r\n\r\n" ++ ) ++ with pytest.raises(http_exceptions.BadHttpMessage): ++ parser.feed_data(text) ++ ++ ++def test_whitespace_before_header(parser: Any) -> None: ++ text = b"GET / HTTP/1.1\r\n\tContent-Length: 1\r\n\r\nX" ++ with pytest.raises(http_exceptions.BadHttpMessage): ++ parser.feed_data(text) ++ ++ + @pytest.mark.parametrize("size", [40960, 8191]) + def test_max_header_field_size(parser, size) -> None: + name = b"t" * size +@@ -546,6 +624,11 @@ def test_http_request_parser_bad_version(parser) -> None: + parser.feed_data(b"GET //get HT/11\r\n\r\n") + + ++def test_http_request_parser_bad_version_number(parser: Any) -> None: ++ with pytest.raises(http_exceptions.BadHttpMessage): ++ parser.feed_data(b"GET /test HTTP/12.3\r\n\r\n") ++ ++ + @pytest.mark.parametrize("size", [40965, 8191]) + def test_http_request_max_status_line(parser, size) -> None: + path = b"t" * (size - 5) +@@ -613,6 +696,11 @@ def test_http_response_parser_bad_version(response) -> None: + response.feed_data(b"HT/11 200 Ok\r\n\r\n") + + ++def test_http_response_parser_bad_version_number(response) -> None: ++ with pytest.raises(http_exceptions.BadHttpMessage): ++ response.feed_data(b"HTTP/12.3 200 Ok\r\n\r\n") ++ ++ + def test_http_response_parser_no_reason(response) -> None: + msg = response.feed_data(b"HTTP/1.1 200\r\n\r\n")[0][0][0] + +@@ -627,17 +715,20 @@ def test_http_response_parser_bad(response) -> None: + + + def test_http_response_parser_code_under_100(response) -> None: +- msg = response.feed_data(b"HTTP/1.1 99 test\r\n\r\n")[0][0][0] +- assert msg.code == 99 ++ if isinstance(response, HttpResponseParserPy): ++ with pytest.raises(aiohttp.http_exceptions.BadStatusLine): ++ response.feed_data(b"HTTP/1.1 99 test\r\n\r\n") ++ else: ++ pytest.xfail("Regression test for Py parser. May match C behaviour later.") + + + def test_http_response_parser_code_above_999(response) -> None: +- with pytest.raises(http_exceptions.BadHttpMessage): ++ with pytest.raises(http_exceptions.BadStatusLine): + response.feed_data(b"HTTP/1.1 9999 test\r\n\r\n") + + + def test_http_response_parser_code_not_int(response) -> None: +- with pytest.raises(http_exceptions.BadHttpMessage): ++ with pytest.raises(http_exceptions.BadStatusLine): + response.feed_data(b"HTTP/1.1 ttt test\r\n\r\n") + + diff --git a/CVE-2023-49082.patch b/CVE-2023-49082.patch new file mode 100644 index 0000000..6c4ba29 --- /dev/null +++ b/CVE-2023-49082.patch @@ -0,0 +1,60 @@ +From: Ben Kallus <49924171+kenballus@users.noreply.github.com> +Date: Wed, 18 Oct 2023 12:18:35 -0400 +Subject: Backport 493f06797654c383242f0e8007f6e06b818a1fbc to 3.9 (#7730) + +--- + aiohttp/http_parser.py | 6 ++++-- + tests/test_http_parser.py | 9 ++++++++- + 2 files changed, 12 insertions(+), 3 deletions(-) + +diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py +index 3862bbe..8e5e816 100644 +--- a/aiohttp/http_parser.py ++++ b/aiohttp/http_parser.py +@@ -55,7 +55,9 @@ ASCIISET = set(string.printable) + # token = 1*tchar + METHRE = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+") + VERSRE: Final[Pattern[str]] = re.compile(r"HTTP/(\d).(\d)") +-HDRRE: Final[Pattern[bytes]] = re.compile(rb"[\x00-\x1F\x7F()<>@,;:\[\]={} \t\"\\]") ++HDRRE: Final[Pattern[bytes]] = re.compile( ++ rb"[\x00-\x1F\x7F-\xFF()<>@,;:\[\]={} \t\"\\]" ++) + + RawRequestMessage = collections.namedtuple( + "RawRequestMessage", +@@ -523,7 +525,7 @@ class HttpRequestParser(HttpParser): + # request line + line = lines[0].decode("utf-8", "surrogateescape") + try: +- method, path, version = line.split(maxsplit=2) ++ method, path, version = line.split(" ", maxsplit=2) + except ValueError: + raise BadStatusLine(line) from None + +diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py +index d584f15..9d65b2f 100644 +--- a/tests/test_http_parser.py ++++ b/tests/test_http_parser.py +@@ -397,6 +397,7 @@ def test_cve_2023_37276(parser: Any) -> None: + "Baz: abc\x00def", + "Foo : bar", # https://www.rfc-editor.org/rfc/rfc9112.html#section-5.1-2 + "Foo\t: bar", ++ "\xffoo: bar", + ), + ) + def test_bad_headers(parser: Any, hdr: str) -> None: +@@ -562,7 +563,13 @@ def test_http_request_bad_status_line(parser) -> None: + parser.feed_data(text) + + +-def test_http_request_upgrade(parser) -> None: ++def test_http_request_bad_status_line_whitespace(parser: Any) -> None: ++ text = b"GET\n/path\fHTTP/1.1\r\n\r\n" ++ with pytest.raises(http_exceptions.BadStatusLine): ++ parser.feed_data(text) ++ ++ ++def test_http_request_upgrade(parser: Any) -> None: + text = ( + b"GET /test HTTP/1.1\r\n" + b"connection: upgrade\r\n" diff --git a/CVE-2024-23334.patch b/CVE-2024-23334.patch new file mode 100644 index 0000000..ee7ba07 --- /dev/null +++ b/CVE-2024-23334.patch @@ -0,0 +1,205 @@ +From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> +Date: Sun, 28 Jan 2024 18:38:58 +0000 +Subject: Validate static paths (#8080) + +**This is a backport of PR #8079 as merged into master +(1c335944d6a8b1298baf179b7c0b3069f10c514b).** +--- + aiohttp/web_urldispatcher.py | 18 ++++++-- + docs/web_advanced.rst | 16 ++++++-- + docs/web_reference.rst | 12 ++++-- + tests/test_web_urldispatcher.py | 91 +++++++++++++++++++++++++++++++++++++++++ + 4 files changed, 127 insertions(+), 10 deletions(-) + +diff --git a/aiohttp/web_urldispatcher.py b/aiohttp/web_urldispatcher.py +index 2afd72f..48557d5 100644 +--- a/aiohttp/web_urldispatcher.py ++++ b/aiohttp/web_urldispatcher.py +@@ -589,9 +589,14 @@ class StaticResource(PrefixResource): + url = url / filename + + if append_version: ++ unresolved_path = self._directory.joinpath(filename) + try: +- filepath = self._directory.joinpath(filename).resolve() +- if not self._follow_symlinks: ++ if self._follow_symlinks: ++ normalized_path = Path(os.path.normpath(unresolved_path)) ++ normalized_path.relative_to(self._directory) ++ filepath = normalized_path.resolve() ++ else: ++ filepath = unresolved_path.resolve() + filepath.relative_to(self._directory) + except (ValueError, FileNotFoundError): + # ValueError for case when path point to symlink +@@ -656,8 +661,13 @@ class StaticResource(PrefixResource): + # /static/\\machine_name\c$ or /static/D:\path + # where the static dir is totally different + raise HTTPForbidden() +- filepath = self._directory.joinpath(filename).resolve() +- if not self._follow_symlinks: ++ unresolved_path = self._directory.joinpath(filename) ++ if self._follow_symlinks: ++ normalized_path = Path(os.path.normpath(unresolved_path)) ++ normalized_path.relative_to(self._directory) ++ filepath = normalized_path.resolve() ++ else: ++ filepath = unresolved_path.resolve() + filepath.relative_to(self._directory) + except (ValueError, FileNotFoundError) as error: + # relatively safe +diff --git a/docs/web_advanced.rst b/docs/web_advanced.rst +index 01a3341..f5e082f 100644 +--- a/docs/web_advanced.rst ++++ b/docs/web_advanced.rst +@@ -136,12 +136,22 @@ instead could be enabled with ``show_index`` parameter set to ``True``:: + + web.static('/prefix', path_to_static_folder, show_index=True) + +-When a symlink from the static directory is accessed, the server responses to +-client with ``HTTP/404 Not Found`` by default. To allow the server to follow +-symlinks, parameter ``follow_symlinks`` should be set to ``True``:: ++When a symlink that leads outside the static directory is accessed, the server ++responds to the client with ``HTTP/404 Not Found`` by default. To allow the server to ++follow symlinks that lead outside the static root, the parameter ``follow_symlinks`` ++should be set to ``True``:: + + web.static('/prefix', path_to_static_folder, follow_symlinks=True) + ++.. caution:: ++ ++ Enabling ``follow_symlinks`` can be a security risk, and may lead to ++ a directory transversal attack. You do NOT need this option to follow symlinks ++ which point to somewhere else within the static directory, this option is only ++ used to break out of the security sandbox. Enabling this option is highly ++ discouraged, and only expected to be used for edge cases in a local ++ development setting where remote users do not have access to the server. ++ + When you want to enable cache busting, + parameter ``append_version`` can be set to ``True`` + +diff --git a/docs/web_reference.rst b/docs/web_reference.rst +index 4073eb2..add37cd 100644 +--- a/docs/web_reference.rst ++++ b/docs/web_reference.rst +@@ -1802,9 +1802,15 @@ Router is any object that implements :class:`AbstractRouter` interface. + by default it's not allowed and HTTP/403 will + be returned on directory access. + +- :param bool follow_symlinks: flag for allowing to follow symlinks from +- a directory, by default it's not allowed and +- HTTP/404 will be returned on access. ++ :param bool follow_symlinks: flag for allowing to follow symlinks that lead ++ outside the static root directory, by default it's not allowed and ++ HTTP/404 will be returned on access. Enabling ``follow_symlinks`` ++ can be a security risk, and may lead to a directory transversal attack. ++ You do NOT need this option to follow symlinks which point to somewhere ++ else within the static directory, this option is only used to break out ++ of the security sandbox. Enabling this option is highly discouraged, ++ and only expected to be used for edge cases in a local development ++ setting where remote users do not have access to the server. + + :param bool append_version: flag for adding file version (hash) + to the url query string, this value will +diff --git a/tests/test_web_urldispatcher.py b/tests/test_web_urldispatcher.py +index 0ba2e7c..e6269ef 100644 +--- a/tests/test_web_urldispatcher.py ++++ b/tests/test_web_urldispatcher.py +@@ -120,6 +120,97 @@ async def test_follow_symlink(tmp_dir_path, aiohttp_client) -> None: + assert (await r.text()) == data + + ++async def test_follow_symlink_directory_traversal( ++ tmp_path: pathlib.Path, aiohttp_client ++) -> None: ++ # Tests that follow_symlinks does not allow directory transversal ++ data = "private" ++ ++ private_file = tmp_path / "private_file" ++ private_file.write_text(data) ++ ++ safe_path = tmp_path / "safe_dir" ++ safe_path.mkdir() ++ ++ app = web.Application() ++ ++ # Register global static route: ++ app.router.add_static("/", str(safe_path), follow_symlinks=True) ++ client = await aiohttp_client(app) ++ ++ await client.start_server() ++ # We need to use a raw socket to test this, as the client will normalize ++ # the path before sending it to the server. ++ reader, writer = await asyncio.open_connection(client.host, client.port) ++ writer.write(b"GET /../private_file HTTP/1.1\r\n\r\n") ++ response = await reader.readuntil(b"\r\n\r\n") ++ assert b"404 Not Found" in response ++ writer.close() ++ await writer.wait_closed() ++ await client.close() ++ ++ ++async def test_follow_symlink_directory_traversal_after_normalization( ++ tmp_path: pathlib.Path, aiohttp_client ++) -> None: ++ # Tests that follow_symlinks does not allow directory transversal ++ # after normalization ++ # ++ # Directory structure ++ # |-- secret_dir ++ # | |-- private_file (should never be accessible) ++ # | |-- symlink_target_dir ++ # | |-- symlink_target_file (should be accessible via the my_symlink symlink) ++ # | |-- sandbox_dir ++ # | |-- my_symlink -> symlink_target_dir ++ # ++ secret_path = tmp_path / "secret_dir" ++ secret_path.mkdir() ++ ++ # This file is below the symlink target and should not be reachable ++ private_file = secret_path / "private_file" ++ private_file.write_text("private") ++ ++ symlink_target_path = secret_path / "symlink_target_dir" ++ symlink_target_path.mkdir() ++ ++ sandbox_path = symlink_target_path / "sandbox_dir" ++ sandbox_path.mkdir() ++ ++ # This file should be reachable via the symlink ++ symlink_target_file = symlink_target_path / "symlink_target_file" ++ symlink_target_file.write_text("readable") ++ ++ my_symlink_path = sandbox_path / "my_symlink" ++ pathlib.Path(str(my_symlink_path)).symlink_to(str(symlink_target_path), True) ++ ++ app = web.Application() ++ ++ # Register global static route: ++ app.router.add_static("/", str(sandbox_path), follow_symlinks=True) ++ client = await aiohttp_client(app) ++ ++ await client.start_server() ++ # We need to use a raw socket to test this, as the client will normalize ++ # the path before sending it to the server. ++ reader, writer = await asyncio.open_connection(client.host, client.port) ++ writer.write(b"GET /my_symlink/../private_file HTTP/1.1\r\n\r\n") ++ response = await reader.readuntil(b"\r\n\r\n") ++ assert b"404 Not Found" in response ++ writer.close() ++ await writer.wait_closed() ++ ++ reader, writer = await asyncio.open_connection(client.host, client.port) ++ writer.write(b"GET /my_symlink/symlink_target_file HTTP/1.1\r\n\r\n") ++ response = await reader.readuntil(b"\r\n\r\n") ++ assert b"200 OK" in response ++ response = await reader.readuntil(b"readable") ++ assert response == b"readable" ++ writer.close() ++ await writer.wait_closed() ++ await client.close() ++ ++ + @pytest.mark.parametrize( + "dir_name,filename,data", + [ diff --git a/CVE-2024-23829.patch b/CVE-2024-23829.patch new file mode 100644 index 0000000..1a0d781 --- /dev/null +++ b/CVE-2024-23829.patch @@ -0,0 +1,343 @@ +From: Sam Bull +Date: Sun, 28 Jan 2024 17:09:58 +0000 +Subject: Improve validation in HTTP parser (#8074) (#8078) +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 8bit + +Co-authored-by: Paul J. Dorn +Co-authored-by: Sviatoslav Sydorenko (Святослав Сидоренко) + +(cherry picked from commit 33ccdfb0a12690af5bb49bda2319ec0907fa7827) +--- + CONTRIBUTORS.txt | 1 + + aiohttp/http_parser.py | 28 +++++---- + tests/test_http_parser.py | 155 +++++++++++++++++++++++++++++++++++++++++++++- + 3 files changed, 169 insertions(+), 15 deletions(-) + +diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt +index ad63ce9..45fee0e 100644 +--- a/CONTRIBUTORS.txt ++++ b/CONTRIBUTORS.txt +@@ -215,6 +215,7 @@ Panagiotis Kolokotronis + Pankaj Pandey + Pau Freixes + Paul Colomiets ++Paul J. Dorn + Paulius Šileikis + Paulus Schoutsen + Pavel Kamaev +diff --git a/aiohttp/http_parser.py b/aiohttp/http_parser.py +index 8e5e816..2d58947 100644 +--- a/aiohttp/http_parser.py ++++ b/aiohttp/http_parser.py +@@ -53,11 +53,10 @@ ASCIISET = set(string.printable) + # tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / + # "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA + # token = 1*tchar +-METHRE = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+") +-VERSRE: Final[Pattern[str]] = re.compile(r"HTTP/(\d).(\d)") +-HDRRE: Final[Pattern[bytes]] = re.compile( +- rb"[\x00-\x1F\x7F-\xFF()<>@,;:\[\]={} \t\"\\]" +-) ++_TCHAR_SPECIALS: Final[str] = re.escape("!#$%&'*+-.^_`|~") ++TOKENRE: Final[Pattern[str]] = re.compile(f"[0-9A-Za-z{_TCHAR_SPECIALS}]+") ++VERSRE: Final[Pattern[str]] = re.compile(r"HTTP/(\d)\.(\d)", re.ASCII) ++DIGITS: Final[Pattern[str]] = re.compile(r"\d+", re.ASCII) + + RawRequestMessage = collections.namedtuple( + "RawRequestMessage", +@@ -122,6 +121,7 @@ class HeadersParser: + self, lines: List[bytes] + ) -> Tuple["CIMultiDictProxy[str]", RawHeaders]: + headers = CIMultiDict() # type: CIMultiDict[str] ++ # note: "raw" does not mean inclusion of OWS before/after the field value + raw_headers = [] + + lines_idx = 1 +@@ -135,13 +135,14 @@ class HeadersParser: + except ValueError: + raise InvalidHeader(line) from None + ++ if len(bname) == 0: ++ raise InvalidHeader(bname) ++ + # https://www.rfc-editor.org/rfc/rfc9112.html#section-5.1-2 + if {bname[0], bname[-1]} & {32, 9}: # {" ", "\t"} + raise InvalidHeader(line) + + bvalue = bvalue.lstrip(b" \t") +- if HDRRE.search(bname): +- raise InvalidHeader(bname) + if len(bname) > self.max_field_size: + raise LineTooLong( + "request header name {}".format( +@@ -150,6 +151,9 @@ class HeadersParser: + str(self.max_field_size), + str(len(bname)), + ) ++ name = bname.decode("utf-8", "surrogateescape") ++ if not TOKENRE.fullmatch(name): ++ raise InvalidHeader(bname) + + header_length = len(bvalue) + +@@ -196,7 +200,6 @@ class HeadersParser: + ) + + bvalue = bvalue.strip(b" \t") +- name = bname.decode("utf-8", "surrogateescape") + value = bvalue.decode("utf-8", "surrogateescape") + + # https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5-5 +@@ -317,7 +320,8 @@ class HttpParser(abc.ABC): + if length is not None: + # Shouldn't allow +/- or other number formats. + # https://www.rfc-editor.org/rfc/rfc9110#section-8.6-2 +- if not length.strip(" \t").isdigit(): ++ # msg.headers is already stripped of leading/trailing wsp ++ if not DIGITS.fullmatch(length): + raise InvalidHeader(CONTENT_LENGTH) + + length = int(length) +@@ -538,7 +542,7 @@ class HttpRequestParser(HttpParser): + path_part, _question_mark_separator, qs_part = path_part.partition("?") + + # method +- if not METHRE.match(method): ++ if not TOKENRE.fullmatch(method): + raise BadStatusLine(method) + + # version +@@ -615,8 +619,8 @@ class HttpResponseParser(HttpParser): + raise BadStatusLine(line) + version_o = HttpVersion(int(match.group(1)), int(match.group(2))) + +- # The status code is a three-digit number +- if len(status) != 3 or not status.isdigit(): ++ # The status code is a three-digit ASCII number, no padding ++ if len(status) != 3 or not DIGITS.fullmatch(status): + raise BadStatusLine(line) + status_i = int(status) + +diff --git a/tests/test_http_parser.py b/tests/test_http_parser.py +index 9d65b2f..6073371 100644 +--- a/tests/test_http_parser.py ++++ b/tests/test_http_parser.py +@@ -3,7 +3,8 @@ + import asyncio + from unittest import mock + from urllib.parse import quote +-from typing import Any ++from contextlib import nullcontext ++from typing import Any, Dict, List + + import pytest + from multidict import CIMultiDict +@@ -102,6 +103,20 @@ test2: data\r + assert not msg.upgrade + + ++def test_parse_unusual_request_line(parser) -> None: ++ if not isinstance(response, HttpResponseParserPy): ++ pytest.xfail("Regression test for Py parser. May match C behaviour later.") ++ text = b"#smol //a HTTP/1.3\r\n\r\n" ++ messages, upgrade, tail = parser.feed_data(text) ++ assert len(messages) == 1 ++ msg, _ = messages[0] ++ assert msg.compression is None ++ assert not msg.upgrade ++ assert msg.method == "#smol" ++ assert msg.path == "//a" ++ assert msg.version == (1, 3) ++ ++ + def test_parse(parser) -> None: + text = b"GET /test HTTP/1.1\r\n\r\n" + messages, upgrade, tail = parser.feed_data(text) +@@ -365,6 +380,51 @@ def test_headers_content_length_err_2(parser) -> None: + parser.feed_data(text) + + ++_pad: Dict[bytes, str] = { ++ b"": "empty", ++ # not a typo. Python likes triple zero ++ b"\000": "NUL", ++ b" ": "SP", ++ b" ": "SPSP", ++ # not a typo: both 0xa0 and 0x0a in case of 8-bit fun ++ b"\n": "LF", ++ b"\xa0": "NBSP", ++ b"\t ": "TABSP", ++} ++ ++ ++@pytest.mark.parametrize("hdr", [b"", b"foo"], ids=["name-empty", "with-name"]) ++@pytest.mark.parametrize("pad2", _pad.keys(), ids=["post-" + n for n in _pad.values()]) ++@pytest.mark.parametrize("pad1", _pad.keys(), ids=["pre-" + n for n in _pad.values()]) ++def test_invalid_header_spacing(parser, pad1: bytes, pad2: bytes, hdr: bytes) -> None: ++ text = b"GET /test HTTP/1.1\r\n" b"%s%s%s: value\r\n\r\n" % (pad1, hdr, pad2) ++ if isinstance(parser, HttpRequestParserPy): ++ expectation = pytest.raises(http_exceptions.InvalidHeader) ++ else: ++ pytest.xfail("Regression test for Py parser. May match C behaviour later.") ++ if pad1 == pad2 == b"" and hdr != b"": ++ # one entry in param matrix is correct: non-empty name, not padded ++ expectation = nullcontext() ++ if pad1 == pad2 == hdr == b"": ++ if not isinstance(response, HttpResponseParserPy): ++ pytest.xfail("Regression test for Py parser. May match C behaviour later.") ++ # work around pytest.raises not working ++ try: ++ parser.feed_data(text) ++ except aiohttp.http_exceptions.InvalidHeader: ++ pass ++ except aiohttp.http_exceptions.BadHttpMessage: ++ pass ++ ++ ++def test_empty_header_name(parser) -> None: ++ if not isinstance(response, HttpResponseParserPy): ++ pytest.xfail("Regression test for Py parser. May match C behaviour later.") ++ text = b"GET /test HTTP/1.1\r\n" b":test\r\n\r\n" ++ with pytest.raises(http_exceptions.BadHttpMessage): ++ parser.feed_data(text) ++ ++ + def test_invalid_header(parser) -> None: + text = b"GET /test HTTP/1.1\r\n" b"test line\r\n\r\n" + with pytest.raises(http_exceptions.BadHttpMessage): +@@ -387,11 +447,27 @@ def test_cve_2023_37276(parser: Any) -> None: + pytest.xfail("Regression test for Py parser. May match C behaviour later.") + + ++@pytest.mark.parametrize( ++ "rfc9110_5_6_2_token_delim", ++ r'"(),/:;<=>?@[\]{}', ++) ++def test_bad_header_name(parser: Any, rfc9110_5_6_2_token_delim: str) -> None: ++ text = f"POST / HTTP/1.1\r\nhead{rfc9110_5_6_2_token_delim}er: val\r\n\r\n".encode() ++ expectation = pytest.raises(http_exceptions.BadHttpMessage) ++ if rfc9110_5_6_2_token_delim == ":": ++ # Inserting colon into header just splits name/value earlier. ++ expectation = nullcontext() ++ with expectation: ++ parser.feed_data(text) ++ ++ + @pytest.mark.parametrize( + "hdr", + ( + "Content-Length: -5", # https://www.rfc-editor.org/rfc/rfc9110.html#name-content-length + "Content-Length: +256", ++ "Content-Length: \N{superscript one}", ++ "Content-Length: \N{mathematical double-struck digit one}", + "Foo: abc\rdef", # https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5-5 + "Bar: abc\ndef", + "Baz: abc\x00def", +@@ -563,6 +639,42 @@ def test_http_request_bad_status_line(parser) -> None: + parser.feed_data(text) + + ++_num: Dict[bytes, str] = { ++ # dangerous: accepted by Python int() ++ # unicodedata.category("\U0001D7D9") == 'Nd' ++ "\N{mathematical double-struck digit one}".encode(): "utf8digit", ++ # only added for interop tests, refused by Python int() ++ # unicodedata.category("\U000000B9") == 'No' ++ "\N{superscript one}".encode(): "utf8number", ++ "\N{superscript one}".encode("latin-1"): "latin1number", ++} ++ ++ ++@pytest.mark.parametrize("nonascii_digit", _num.keys(), ids=_num.values()) ++def test_http_request_bad_status_line_number( ++ parser: Any, nonascii_digit: bytes ++) -> None: ++ text = b"GET /digit HTTP/1." + nonascii_digit + b"\r\n\r\n" ++ if isinstance(parser, HttpRequestParserPy): ++ exception = http_exceptions.BadStatusLine ++ else: ++ exception = http_exceptions.BadHttpMessage ++ with pytest.raises(exception): ++ parser.feed_data(text) ++ ++ ++def test_http_request_bad_status_line_separator(parser: Any) -> None: ++ # single code point, old, multibyte NFKC, multibyte NFKD ++ utf8sep = "\N{arabic ligature sallallahou alayhe wasallam}".encode() ++ text = b"GET /ligature HTTP/1" + utf8sep + b"1\r\n\r\n" ++ if isinstance(parser, HttpRequestParserPy): ++ exception = http_exceptions.BadStatusLine ++ else: ++ exception = http_exceptions.BadHttpMessage ++ with pytest.raises(exception): ++ parser.feed_data(text) ++ ++ + def test_http_request_bad_status_line_whitespace(parser: Any) -> None: + text = b"GET\n/path\fHTTP/1.1\r\n\r\n" + with pytest.raises(http_exceptions.BadStatusLine): +@@ -584,6 +696,31 @@ def test_http_request_upgrade(parser: Any) -> None: + assert tail == b"some raw data" + + ++def test_http_request_parser_utf8_request_line(parser) -> None: ++ if not isinstance(response, HttpResponseParserPy): ++ pytest.xfail("Regression test for Py parser. May match C behaviour later.") ++ messages, upgrade, tail = parser.feed_data( ++ # note the truncated unicode sequence ++ b"GET /P\xc3\xbcnktchen\xa0\xef\xb7 HTTP/1.1\r\n" + ++ # for easier grep: ASCII 0xA0 more commonly known as non-breaking space ++ # note the leading and trailing spaces ++ "sTeP: \N{latin small letter sharp s}nek\t\N{no-break space} " ++ "\r\n\r\n".encode() ++ ) ++ msg = messages[0][0] ++ ++ assert msg.method == "GET" ++ assert msg.path == "/Pünktchen\udca0\udcef\udcb7" ++ assert msg.version == (1, 1) ++ assert msg.headers == CIMultiDict([("STEP", "ßnek\t\xa0")]) ++ assert msg.raw_headers == ((b"sTeP", "ßnek\t\xa0".encode()),) ++ assert not msg.should_close ++ assert msg.compression is None ++ assert not msg.upgrade ++ assert not msg.chunked ++ assert msg.url.path == URL("/P%C3%BCnktchen\udca0\udcef\udcb7").path ++ ++ + def test_http_request_parser_utf8(parser) -> None: + text = "GET /path HTTP/1.1\r\nx-test:тест\r\n\r\n".encode() + messages, upgrade, tail = parser.feed_data(text) +@@ -633,9 +770,15 @@ def test_http_request_parser_two_slashes(parser) -> None: + assert not msg.chunked + + +-def test_http_request_parser_bad_method(parser) -> None: ++@pytest.mark.parametrize( ++ "rfc9110_5_6_2_token_delim", ++ [bytes([i]) for i in rb'"(),/:;<=>?@[\]{}'], ++) ++def test_http_request_parser_bad_method( ++ parser, rfc9110_5_6_2_token_delim: bytes ++) -> None: + with pytest.raises(http_exceptions.BadStatusLine): +- parser.feed_data(b'=":(e),[T];?" /get HTTP/1.1\r\n\r\n') ++ parser.feed_data(rfc9110_5_6_2_token_delim + b'ET" /get HTTP/1.1\r\n\r\n') + + + def test_http_request_parser_bad_version(parser) -> None: +@@ -751,6 +894,12 @@ def test_http_response_parser_code_not_int(response) -> None: + response.feed_data(b"HTTP/1.1 ttt test\r\n\r\n") + + ++@pytest.mark.parametrize("nonascii_digit", _num.keys(), ids=_num.values()) ++def test_http_response_parser_code_not_ascii(response, nonascii_digit: bytes) -> None: ++ with pytest.raises(http_exceptions.BadStatusLine): ++ response.feed_data(b"HTTP/1.1 20" + nonascii_digit + b" test\r\n\r\n") ++ ++ + def test_http_request_chunked_payload(parser) -> None: + text = b"GET /test HTTP/1.1\r\n" b"transfer-encoding: chunked\r\n\r\n" + msg, payload = parser.feed_data(text)[0][0] diff --git a/CVE-2024-27306.patch b/CVE-2024-27306.patch new file mode 100644 index 0000000..52b007e --- /dev/null +++ b/CVE-2024-27306.patch @@ -0,0 +1,229 @@ +From: Sam Bull +Date: Thu, 11 Apr 2024 15:54:45 +0100 +Subject: Escape filenames and paths in HTML when generating index pages + (#8317) (#8319) + +Co-authored-by: J. Nick Koston +(cherry picked from commit ffbc43233209df302863712b511a11bdb6001b0f) +--- + aiohttp/web_urldispatcher.py | 12 ++-- + tests/test_web_urldispatcher.py | 125 +++++++++++++++++++++++++++++++++++----- + 2 files changed, 117 insertions(+), 20 deletions(-) + +diff --git a/aiohttp/web_urldispatcher.py b/aiohttp/web_urldispatcher.py +index 48557d5..c6fd34d 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 +@@ -85,6 +87,8 @@ _WebHandler = Callable[[Request], Awaitable[StreamResponse]] + _ExpectHandler = Callable[[Request], Awaitable[None]] + _Resolve = Tuple[Optional[AbstractMatchInfo], Set[str]] + ++html_escape = functools.partial(html.escape, quote=True) ++ + + class _InfoDict(TypedDict, total=False): + path: str +@@ -702,7 +706,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"

{index_of}

" + + index_list = [] +@@ -710,7 +714,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(): +@@ -719,9 +723,7 @@ class StaticResource(PrefixResource): + file_name = _file.name + + index_list.append( +- '
  • {name}
  • '.format( +- url=file_url, name=file_name +- ) ++ f'
  • {html_escape(file_name)}
  • ' + ) + ul = "
      \n{}\n
    ".format("\n".join(index_list)) + body = f"\n{h1}\n{ul}\n" +diff --git a/tests/test_web_urldispatcher.py b/tests/test_web_urldispatcher.py +index e6269ef..c46c76c 100644 +--- a/tests/test_web_urldispatcher.py ++++ b/tests/test_web_urldispatcher.py +@@ -7,6 +7,7 @@ import sys + import tempfile + from unittest import mock + from unittest.mock import MagicMock ++from typing import Optional + + import pytest + +@@ -32,35 +33,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"\n\nIndex of /.\n" +- b"\n\n

    Index of /.

    \n\n\n", +- id="index_root", ++ "/", ++ b"\n\nIndex of /.\n\n\n

    Index of" ++ b' /.

    \n\n\n", + ), + pytest.param( + True, + 200, + "/static", +- b"\n\nIndex of /.\n" +- b"\n\n

    Index of /.

    \n\n\n", ++ "/static", ++ b"\n\nIndex of /.\n\n\n

    Index of" ++ b' /.

    \n\n\n', + id="index_static", + ), ++ pytest.param( ++ True, ++ 200, ++ "/static", ++ "/static/my_dir", ++ b"\n\nIndex of /my_dir\n\n\n

    " ++ b'Index of /my_dir

    \n\n\n", ++ 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 +@@ -85,7 +93,94 @@ async def test_access_root_of_static_handler( + client = await aiohttp_client(app) + + # Request the root of the static directory. +- r = await client.get(prefix) ++ 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"\n\nIndex of /.\n\n\n

    Index of" ++ b' /.

    \n\n\n", ++ ), ++ pytest.param( ++ True, ++ 200, ++ "/static", ++ "/static", ++ b"\n\nIndex of /.\n\n\n

    Index of" ++ b' /.

    \n\n\n", ++ id="index_static", ++ ), ++ pytest.param( ++ True, ++ 200, ++ "/static", ++ "/static/.dir", ++ b"\n\nIndex 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, ++ 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. ++ r = await client.get(request_path) + assert r.status == status + + if data: diff --git a/CVE-2024-30251-Followup-01.patch b/CVE-2024-30251-Followup-01.patch new file mode 100644 index 0000000..4f2b745 --- /dev/null +++ b/CVE-2024-30251-Followup-01.patch @@ -0,0 +1,56 @@ +From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> +Date: Mon, 15 Apr 2024 20:47:19 +0100 +Subject: 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> +--- + aiohttp/multipart.py | 7 +++++-- + tests/test_multipart.py | 7 +++++++ + 2 files changed, 12 insertions(+), 2 deletions(-) + +diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py +index f2c4ead..ac7dfdb 100644 +--- a/aiohttp/multipart.py ++++ b/aiohttp/multipart.py +@@ -841,8 +841,6 @@ class MultipartWriter(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() +@@ -923,6 +921,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) + +diff --git a/tests/test_multipart.py b/tests/test_multipart.py +index e17817d..89db7f8 100644 +--- a/tests/test_multipart.py ++++ b/tests/test_multipart.py +@@ -1122,6 +1122,13 @@ class TestMultipartWriter: + 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") diff --git a/CVE-2024-30251-Followup-02.patch b/CVE-2024-30251-Followup-02.patch new file mode 100644 index 0000000..84a5a99 --- /dev/null +++ b/CVE-2024-30251-Followup-02.patch @@ -0,0 +1,62 @@ +From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> +Date: Mon, 15 Apr 2024 21:54:12 +0100 +Subject: 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> +--- + aiohttp/multipart.py | 4 ++++ + tests/test_multipart.py | 22 +++++++++++++++++----- + 2 files changed, 21 insertions(+), 5 deletions(-) + +diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py +index ac7dfdb..ac7a459 100644 +--- a/aiohttp/multipart.py ++++ b/aiohttp/multipart.py +@@ -845,6 +845,10 @@ class MultipartWriter(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 89db7f8..cff9c08 100644 +--- a/tests/test_multipart.py ++++ b/tests/test_multipart.py +@@ -1122,12 +1122,24 @@ class TestMultipartWriter: + 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: diff --git a/CVE-2024-30251.patch b/CVE-2024-30251.patch new file mode 100644 index 0000000..7fb0563 --- /dev/null +++ b/CVE-2024-30251.patch @@ -0,0 +1,421 @@ +From: Sam Bull <git@sambull.org> +Date: Sun, 7 Apr 2024 13:19:31 +0100 +Subject: Fix handling of multipart/form-data (#8280) (#8302) + +https://datatracker.ietf.org/doc/html/rfc7578 +(cherry picked from commit 7d0be3fee540a3d4161ac7dc76422f1f5ea60104) +--- + aiohttp/formdata.py | 1 - + aiohttp/multipart.py | 121 ++++++++++++++++++++++++++-------------- + tests/test_client_functional.py | 44 +-------------- + tests/test_multipart.py | 68 +++++++++++++++++----- + tests/test_web_functional.py | 2 +- + 5 files changed, 137 insertions(+), 99 deletions(-) + +diff --git a/aiohttp/formdata.py b/aiohttp/formdata.py +index 900716b..f160e5c 100644 +--- a/aiohttp/formdata.py ++++ b/aiohttp/formdata.py +@@ -79,7 +79,6 @@ class FormData: + "content_transfer_encoding must be an instance" + " of str. Got: %s" % content_transfer_encoding + ) +- headers[hdrs.CONTENT_TRANSFER_ENCODING] = content_transfer_encoding + self._is_multipart = True + + self._fields.append((type_options, headers, value)) +diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py +index 9e1ca92..f2c4ead 100644 +--- a/aiohttp/multipart.py ++++ b/aiohttp/multipart.py +@@ -251,13 +251,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 +@@ -325,6 +334,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: +@@ -440,7 +451,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 + +@@ -474,7 +486,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]: +@@ -528,9 +540,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 = ( + None + ) # type: Optional[Union['MultipartReader', BodyPartReader]] +@@ -586,7 +606,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: +@@ -621,19 +658,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) + +@@ -724,6 +758,7 @@ class MultipartWriter(Payload): + super().__init__(None, content_type=ctype) + + self._parts = [] # type: List[_Part] ++ self._is_form_data = subtype == "form-data" + + def __enter__(self) -> "MultipartWriter": + return self +@@ -801,32 +836,36 @@ class MultipartWriter(Payload): + + def append_payload(self, payload: Payload) -> Payload: + """Adds a new body part to multipart writer.""" +- # compression +- encoding = payload.headers.get( +- CONTENT_ENCODING, +- "", +- ).lower() # type: Optional[str] +- 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() # type: Optional[str] +- 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 + return payload +diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py +index bd83098..5dc0fdf 100644 +--- a/tests/test_client_functional.py ++++ b/tests/test_client_functional.py +@@ -1154,48 +1154,6 @@ async def test_POST_DATA_with_charset_post(aiohttp_client) -> None: + 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() +@@ -1243,7 +1201,7 @@ async def test_POST_FILES(aiohttp_client, fname) -> None: + 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() + +diff --git a/tests/test_multipart.py b/tests/test_multipart.py +index 6c3f121..e17817d 100644 +--- a/tests/test_multipart.py ++++ b/tests/test_multipart.py +@@ -784,6 +784,58 @@ class TestMultipartReader: + assert first.at_eof() + assert not second.at_eof() + ++ async def test_read_form_default_encoding(self) -> None: ++ stream = 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" ++ ) ++ 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: ++ stream = 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" ++ ) ++ 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 +@@ -1120,7 +1172,6 @@ class TestMultipartWriter: + CONTENT_TYPE: "text/python", + }, + ) +- content_length = part.size + await writer.write(stream) + + assert part.headers[CONTENT_TYPE] == "text/python" +@@ -1131,9 +1182,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): +@@ -1147,7 +1196,6 @@ class TestMultipartWriter: + CONTENT_TYPE: "text/python", + }, + ) +- content_length = part.size + await writer.write(stream) + + assert part.headers[CONTENT_TYPE] == "text/python" +@@ -1158,9 +1206,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): +@@ -1172,8 +1218,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") +@@ -1186,9 +1230,7 @@ class TestMultipartWriter: + b"--:\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Disposition:" +- b" attachments; filename=\"bug.py\"; filename*=utf-8''bug.py\r\n" +- b"Content-Length: %s" +- b"" % (str(content_length).encode(),) ++ b" attachments; filename=\"bug.py\"; filename*=utf-8''bug.py" + ) + + +diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py +index a28fcd4..3541629 100644 +--- a/tests/test_web_functional.py ++++ b/tests/test_web_functional.py +@@ -634,7 +634,7 @@ async def test_upload_file(aiohttp_client) -> None: + 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 + + diff --git a/python-aiohttp.spec b/python-aiohttp.spec index 038df26..f8f5671 100644 --- a/python-aiohttp.spec +++ b/python-aiohttp.spec @@ -1,7 +1,7 @@ %global _empty_manifest_terminate_build 0 Name: python-aiohttp Version: 3.7.4 -Release: 5 +Release: 6 Summary: Async http client/server framework (asyncio) License: Apache 2 URL: https://github.com/aio-libs/aiohttp @@ -10,6 +10,14 @@ Patch0: change-require-chardet-package-version.patch Patch1: CVE-2023-47641.patch Patch2: CVE-2023-49081.patch Patch3: CVE-2024-52304.patch +Patch4: CVE-2023-47627.patch +Patch5: CVE-2023-49082.patch +Patch6: CVE-2024-23334.patch +Patch7: CVE-2024-23829.patch +Patch8: CVE-2024-27306.patch +Patch9: CVE-2024-30251.patch +Patch10: CVE-2024-30251-Followup-01.patch +Patch11: CVE-2024-30251-Followup-02.patch BuildRequires: python3-attrs BuildRequires: python3-chardet @@ -80,6 +88,9 @@ mv %{buildroot}/doclist.lst . %{_docdir}/* %changelog +* Thu Mar 06 2025 yaoxin <1024769339@qq.com> - 3.7.4-6 +- Fix CVE-2023-47627,CVE-2023-49082,CVE-2024-23334,CVE-2024-23829,CVE-2024-27306 and CVE-2024-30251 + * Wed Nov 20 2024 Deyuan Fan <fandeyuan@kylinos.cn> - 3.7.4-5 - Fix CVE-2024-52304 -- Gitee