!66 [sync] PR-64: Fix CVE-2023-47627,CVE-2023-49082,CVE-2024-23334,CVE-2024-23829,CVE-20…

From: @openeuler-sync-bot 
Reviewed-by: @lyn1001 
Signed-off-by: @lyn1001
This commit is contained in:
openeuler-ci-bot 2025-03-06 02:43:47 +00:00 committed by Gitee
commit c1f180fec5
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
9 changed files with 1741 additions and 1 deletions

353
CVE-2023-47627.patch Normal file
View File

@ -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 <git@sambull.org>
---
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")

60
CVE-2023-49082.patch Normal file
View File

@ -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"

205
CVE-2024-23334.patch Normal file
View File

@ -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",
[

343
CVE-2024-23829.patch Normal file
View File

@ -0,0 +1,343 @@
From: Sam Bull <git@sambull.org>
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 <pajod@users.noreply.github.com>
Co-authored-by: Sviatoslav Sydorenko (Святослав Сидоренко)
<sviat@redhat.com>
(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'=":<G>(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]

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

@ -0,0 +1,229 @@
From: Sam Bull <git@sambull.org>
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 <nick@koston.org>
(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"<h1>{index_of}</h1>"
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(
- '<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 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"<html>\n<head>\n<title>Index of /.</title>\n"
- b"</head>\n<body>\n<h1>Index of /.</h1>\n<ul>\n"
- b'<li><a href="/my_dir">my_dir/</a></li>\n'
- b'<li><a href="/my_file">my_file</a></li>\n'
- b"</ul>\n</body>\n</html>",
- id="index_root",
+ "/",
+ b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
+ b' /.</h1>\n<ul>\n<li><a href="/my_dir">my_dir/</a></li>\n<li><a href="/my_file">'
+ b"my_file</a></li>\n</ul>\n</body>\n</html>",
),
pytest.param(
True,
200,
"/static",
- b"<html>\n<head>\n<title>Index of /.</title>\n"
- b"</head>\n<body>\n<h1>Index of /.</h1>\n<ul>\n"
- b'<li><a href="/static/my_dir">my_dir/</a></li>\n'
- b'<li><a href="/static/my_file">my_file</a></li>\n'
- b"</ul>\n</body>\n</html>",
+ "/static",
+ b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
+ b' /.</h1>\n<ul>\n<li><a href="/static/my_dir">my_dir/</a></li>\n<li><a href="'
+ b'/static/my_file">my_file</a></li>\n</ul>\n</body>\n</html>',
id="index_static",
),
+ pytest.param(
+ True,
+ 200,
+ "/static",
+ "/static/my_dir",
+ b"<html>\n<head>\n<title>Index of /my_dir</title>\n</head>\n<body>\n<h1>"
+ b'Index of /my_dir</h1>\n<ul>\n<li><a href="/static/my_dir/my_file_in_dir">'
+ b"my_file_in_dir</a></li>\n</ul>\n</body>\n</html>",
+ id="index_subdir",
+ ),
],
)
async def test_access_root_of_static_handler(
- tmp_dir_path, aiohttp_client, show_index, status, prefix, data
+ tmp_dir_path, aiohttp_client, show_index, status, prefix, request_path, data
) -> None:
# Tests the operation of static file server.
# Try to access the root of static file server, and make
@@ -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"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
+ b' /.</h1>\n<ul>\n<li><a href="/%3Cimg%20src=0%20onerror=alert(1)%3E.dir">&l'
+ b't;img src=0 onerror=alert(1)&gt;.dir/</a></li>\n<li><a href="/%3Cimg%20sr'
+ b'c=0%20onerror=alert(1)%3E.txt">&lt;img src=0 onerror=alert(1)&gt;.txt</a></l'
+ b"i>\n</ul>\n</body>\n</html>",
+ ),
+ pytest.param(
+ True,
+ 200,
+ "/static",
+ "/static",
+ b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
+ b' /.</h1>\n<ul>\n<li><a href="/static/%3Cimg%20src=0%20onerror=alert(1)%3E.'
+ b'dir">&lt;img src=0 onerror=alert(1)&gt;.dir/</a></li>\n<li><a href="/stat'
+ b'ic/%3Cimg%20src=0%20onerror=alert(1)%3E.txt">&lt;img src=0 onerror=alert(1)&'
+ b"gt;.txt</a></li>\n</ul>\n</body>\n</html>",
+ id="index_static",
+ ),
+ pytest.param(
+ True,
+ 200,
+ "/static",
+ "/static/<img src=0 onerror=alert(1)>.dir",
+ b"<html>\n<head>\n<title>Index of /&lt;img src=0 onerror=alert(1)&gt;.dir</t"
+ b"itle>\n</head>\n<body>\n<h1>Index of /&lt;img src=0 onerror=alert(1)&gt;.di"
+ b'r</h1>\n<ul>\n<li><a href="/static/%3Cimg%20src=0%20onerror=alert(1)%3E.di'
+ b'r/my_file_in_dir">my_file_in_dir</a></li>\n</ul>\n</body>\n</html>',
+ id="index_subdir",
+ ),
+ ],
+)
+async def test_access_root_of_static_handler_xss(
+ tmp_path: 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:

View File

@ -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")

View File

@ -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:

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

@ -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

View File

@ -1,7 +1,7 @@
%global _empty_manifest_terminate_build 0 %global _empty_manifest_terminate_build 0
Name: python-aiohttp Name: python-aiohttp
Version: 3.7.4 Version: 3.7.4
Release: 5 Release: 6
Summary: Async http client/server framework (asyncio) Summary: Async http client/server framework (asyncio)
License: Apache 2 License: Apache 2
URL: https://github.com/aio-libs/aiohttp URL: https://github.com/aio-libs/aiohttp
@ -10,6 +10,14 @@ Patch0: change-require-chardet-package-version.patch
Patch1: CVE-2023-47641.patch Patch1: CVE-2023-47641.patch
Patch2: CVE-2023-49081.patch Patch2: CVE-2023-49081.patch
Patch3: CVE-2024-52304.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-attrs
BuildRequires: python3-chardet BuildRequires: python3-chardet
@ -80,6 +88,9 @@ mv %{buildroot}/doclist.lst .
%{_docdir}/* %{_docdir}/*
%changelog %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 * Wed Nov 20 2024 Deyuan Fan <fandeyuan@kylinos.cn> - 3.7.4-5
- Fix CVE-2024-52304 - Fix CVE-2024-52304