!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:
commit
c1f180fec5
353
CVE-2023-47627.patch
Normal file
353
CVE-2023-47627.patch
Normal 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
60
CVE-2023-49082.patch
Normal 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
205
CVE-2024-23334.patch
Normal 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
343
CVE-2024-23829.patch
Normal 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
229
CVE-2024-27306.patch
Normal 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)>.dir/</a></li>\n<li><a href="/%3Cimg%20sr'
|
||||||
|
+ b'c=0%20onerror=alert(1)%3E.txt"><img src=0 onerror=alert(1)>.txt</a></l'
|
||||||
|
+ b"i>\n</ul>\n</body>\n</html>",
|
||||||
|
+ ),
|
||||||
|
+ pytest.param(
|
||||||
|
+ True,
|
||||||
|
+ 200,
|
||||||
|
+ "/static",
|
||||||
|
+ "/static",
|
||||||
|
+ b"<html>\n<head>\n<title>Index of /.</title>\n</head>\n<body>\n<h1>Index of"
|
||||||
|
+ b' /.</h1>\n<ul>\n<li><a href="/static/%3Cimg%20src=0%20onerror=alert(1)%3E.'
|
||||||
|
+ b'dir"><img src=0 onerror=alert(1)>.dir/</a></li>\n<li><a href="/stat'
|
||||||
|
+ b'ic/%3Cimg%20src=0%20onerror=alert(1)%3E.txt"><img src=0 onerror=alert(1)&'
|
||||||
|
+ b"gt;.txt</a></li>\n</ul>\n</body>\n</html>",
|
||||||
|
+ id="index_static",
|
||||||
|
+ ),
|
||||||
|
+ pytest.param(
|
||||||
|
+ True,
|
||||||
|
+ 200,
|
||||||
|
+ "/static",
|
||||||
|
+ "/static/<img src=0 onerror=alert(1)>.dir",
|
||||||
|
+ b"<html>\n<head>\n<title>Index of /<img src=0 onerror=alert(1)>.dir</t"
|
||||||
|
+ b"itle>\n</head>\n<body>\n<h1>Index of /<img src=0 onerror=alert(1)>.di"
|
||||||
|
+ b'r</h1>\n<ul>\n<li><a href="/static/%3Cimg%20src=0%20onerror=alert(1)%3E.di'
|
||||||
|
+ b'r/my_file_in_dir">my_file_in_dir</a></li>\n</ul>\n</body>\n</html>',
|
||||||
|
+ id="index_subdir",
|
||||||
|
+ ),
|
||||||
|
+ ],
|
||||||
|
+)
|
||||||
|
+async def test_access_root_of_static_handler_xss(
|
||||||
|
+ tmp_path: pathlib.Path,
|
||||||
|
+ aiohttp_client,
|
||||||
|
+ 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:
|
||||||
56
CVE-2024-30251-Followup-01.patch
Normal file
56
CVE-2024-30251-Followup-01.patch
Normal 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")
|
||||||
62
CVE-2024-30251-Followup-02.patch
Normal file
62
CVE-2024-30251-Followup-02.patch
Normal 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
421
CVE-2024-30251.patch
Normal 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
|
||||||
|
|
||||||
|
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user