diff --git a/CHANGES/13286.contrib.rst b/CHANGES/13286.contrib.rst new file mode 100644 index 00000000000..45da1b147d0 --- /dev/null +++ b/CHANGES/13286.contrib.rst @@ -0,0 +1,3 @@ +Made the test suite skip the symlink tests when the host does not permit creating +symlinks, rather than failing with ``OSError: [WinError 1314]`` on Windows without +``SeCreateSymbolicLinkPrivilege`` -- by :user:`Zuhef`. diff --git a/CHANGES/13294.doc.rst b/CHANGES/13294.doc.rst new file mode 100644 index 00000000000..d96752234e2 --- /dev/null +++ b/CHANGES/13294.doc.rst @@ -0,0 +1,3 @@ +Corrected the documented default for :class:`~aiohttp.web.WebSocketResponse`'s +``writer_limit`` parameter, which is ``262144`` (256 KiB) rather than the +``65536`` (64 KiB) that was previously documented -- by :user:`LALITH0110`. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index d273f6b88d2..d843e49b5f9 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -243,6 +243,7 @@ Konstantin Valetov Krzysztof Blazewicz Kyrylo Perevozchikov Kyungmin Lee +Lalith Kothuru Lars P. Søndergaard Lee LieWhite Liu Hua @@ -446,6 +447,7 @@ Yuvi Panda Zainab Lawal Zeal Wierslee Zlatan Sičanica +Zuhef Ahmed Łukasz Setla Марк Коренберг Семён Марьясин diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py index 88934197978..8fef7735436 100644 --- a/aiohttp/client_reqrep.py +++ b/aiohttp/client_reqrep.py @@ -81,6 +81,7 @@ _CONNECTION_CLOSED_EXCEPTION = ClientConnectionError("Connection closed") _CONTAINS_CONTROL_CHAR_RE = re.compile(r"[^-!#$%&'*+.^_`|~0-9a-zA-Z]") _DIGITS_RE = re.compile(r"\d+", re.ASCII) +_LINK_PARAM_RE = re.compile(r"^([^\s=]+)\s*=\s*(?:(['\"])(.*?)\2|(\S*))$", re.M) @frozen_dataclass_decorator @@ -497,12 +498,12 @@ def links(self) -> "MultiDictProxy[MultiDictProxy[str | URL]]": link: MultiDict[str | URL] = MultiDict() for param in params: - match = re.match(r"^\s*(\S*)\s*=\s*(['\"]?)(.*?)(\2)\s*$", param, re.M) + match = _LINK_PARAM_RE.match(param.strip()) if match is None: # Malformed param continue - key, _, value, _ = match.groups() + key, _, value_quoted, value_unquoted = match.groups() - link.add(key, value) + link.add(key, value_unquoted if value_quoted is None else value_quoted) key = link.get("rel", url) diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index fe5ea9b5ab6..04354988efd 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -79,6 +79,7 @@ [ \t]* (?: "( (?:[^"\\]|\\.)* )" # group 1: top-level quoted-string + [ \t]* (?:,|\Z) | ( # group 2: unquoted element (?: (?<=[^\s]=) {_QUOTED_STRING} # parameter quoted value @@ -86,8 +87,8 @@ | [^,] # any non-comma character )+? ) + (?:,|\Z) ) - [ \t]* (?:,|\Z) """, re.VERBOSE, ) diff --git a/docs/web_reference.rst b/docs/web_reference.rst index 6b6d6aa06c3..d8913bbe18f 100644 --- a/docs/web_reference.rst +++ b/docs/web_reference.rst @@ -970,7 +970,7 @@ and :ref:`aiohttp-web-signals` handlers:: .. class:: WebSocketResponse(*, timeout=10.0, receive_timeout=None, \ autoclose=True, autoping=True, heartbeat=None, \ protocols=(), compress=True, max_msg_size=4194304, \ - writer_limit=65536, decode_text=True) + writer_limit=262144, decode_text=True) :canonical: aiohttp.web_ws.WebSocketResponse Class for handling server-side websockets, inherited from @@ -984,7 +984,6 @@ and :ref:`aiohttp-web-signals` handlers:: To enable back-pressure from slow websocket clients treat methods :meth:`ping`, :meth:`pong`, :meth:`send_str`, :meth:`send_bytes`, :meth:`send_json`, :meth:`send_frame` as coroutines. - By default write buffer size is set to 64k. :param bool autoping: Automatically send :const:`~aiohttp.WSMsgType.PONG` on @@ -1029,7 +1028,7 @@ and :ref:`aiohttp-web-signals` handlers:: ``request.transport.close()`` to avoid leaking resources. - :param int writer_limit: maximum size of write buffer, 64 KB by default. + :param int writer_limit: maximum size of write buffer, 256 KiB by default. Once the buffer is full, the websocket will pause to drain the buffer. diff --git a/tests/conftest.py b/tests/conftest.py index 524c201983f..6e18199bed6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -206,6 +206,18 @@ async def loop_debug_mode() -> AsyncIterator[None]: loop.set_debug(False) +@pytest.fixture(scope="session") +def symlinks_supported() -> None: + """Skip tests if symlinks not support (e.g. Windows permission problem).""" + with TemporaryDirectory() as tmp_dir: + target = Path(tmp_dir) / "symlink-target" + target.touch() + try: + (Path(tmp_dir) / "symlink").symlink_to(target) + except OSError as exc: # pragma: no cover + pytest.skip(f"requires privilege to create symlinks: {exc}") + + @pytest.fixture def unix_sockname( tmp_path: Path, tmp_path_factory: pytest.TempPathFactory diff --git a/tests/test_benchmarks_client_response.py b/tests/test_benchmarks_client_response.py new file mode 100644 index 00000000000..cc5afc242ac --- /dev/null +++ b/tests/test_benchmarks_client_response.py @@ -0,0 +1,39 @@ +"""codspeed benchmarks for ClientResponse.""" + +from typing import TYPE_CHECKING + +import pytest + +from aiohttp.client_reqrep import _LINK_PARAM_RE + +if TYPE_CHECKING: + from pytest_codspeed import BenchmarkFixture +else: + pytest_codspeed = pytest.importorskip("pytest_codspeed") + BenchmarkFixture = pytest_codspeed.BenchmarkFixture + + +@pytest.mark.parametrize( + "value", + ( + pytest.param("\n\t" * 13367 + "\x00", id="embedded_newlines"), + pytest.param('\x00="' + "\t" * 14436 + "\x00", id="null_key_quote"), + pytest.param( + ("a=" * 6000) + "'" + ("b" * 6000) + " " + "b", + id="key_swallows_equals", + ), + pytest.param( + "key=" + (" " * 16000) + "'" + ("x" * 16000) + " " + "x", + id="whitespace_run_after_equals", + ), + ), +) +def test_link_param_pattern_redos_payload( + value: str, benchmark: "BenchmarkFixture" +) -> None: + # None of these payloads describe a valid link param; they must not match. + assert _LINK_PARAM_RE.match(value) is None + + @benchmark + def _run() -> None: + _LINK_PARAM_RE.match(value) diff --git a/tests/test_benchmarks_helpers.py b/tests/test_benchmarks_helpers.py new file mode 100644 index 00000000000..857380544ec --- /dev/null +++ b/tests/test_benchmarks_helpers.py @@ -0,0 +1,36 @@ +"""codspeed benchmarks for aiohttp.helpers.""" + +from typing import TYPE_CHECKING + +import pytest + +from aiohttp.helpers import _LIST_ELEMENT_RE + +if TYPE_CHECKING: + from pytest_codspeed import BenchmarkFixture +else: + pytest_codspeed = pytest.importorskip("pytest_codspeed") + BenchmarkFixture = pytest_codspeed.BenchmarkFixture + + +@pytest.mark.parametrize( + "value", + ( + pytest.param( + "a" + (" " * 16000) + "x", + id="whitespace_run_after_content", + ), + pytest.param( + r'\="(' * 16309, + id="interleaved_quote_paren_triggers", + ), + ), +) +def test_list_element_pattern_redos_payload( + value: str, benchmark: "BenchmarkFixture" +) -> None: + assert len(list(_LIST_ELEMENT_RE.finditer(value))) == 1 + + @benchmark + def _run() -> None: + list(_LIST_ELEMENT_RE.finditer(value)) diff --git a/tests/test_urldispatch.py b/tests/test_urldispatch.py index c0bd809f3a3..3200120593f 100644 --- a/tests/test_urldispatch.py +++ b/tests/test_urldispatch.py @@ -464,7 +464,9 @@ def test_add_static_append_version_non_exists_file_without_slash( def test_add_static_append_version_follow_symlink( - router: web.UrlDispatcher, tmp_path: pathlib.Path + router: web.UrlDispatcher, + tmp_path: pathlib.Path, + symlinks_supported: None, ) -> None: # Tests the access to a symlink, in static folder with apeend_version symlink_path = tmp_path / "append_version_symlink" @@ -486,7 +488,9 @@ def test_add_static_append_version_follow_symlink( def test_add_static_append_version_not_follow_symlink( - router: web.UrlDispatcher, tmp_path: pathlib.Path + router: web.UrlDispatcher, + tmp_path: pathlib.Path, + symlinks_supported: None, ) -> None: # Tests the access to a symlink, in static folder with apeend_version diff --git a/tests/test_web_urldispatcher.py b/tests/test_web_urldispatcher.py index 9fa98637f5e..25802b6bb5c 100644 --- a/tests/test_web_urldispatcher.py +++ b/tests/test_web_urldispatcher.py @@ -199,7 +199,9 @@ async def test_access_root_of_static_handler_xss( async def test_follow_symlink( - tmp_path: pathlib.Path, aiohttp_client: AiohttpClient + tmp_path: pathlib.Path, + aiohttp_client: AiohttpClient, + symlinks_supported: None, ) -> None: # Tests the access to a symlink, in static folder data = "hello world" @@ -257,7 +259,9 @@ async def test_follow_symlink_directory_traversal( async def test_follow_symlink_directory_traversal_after_normalization( - tmp_path: pathlib.Path, aiohttp_client: AiohttpClient + tmp_path: pathlib.Path, + aiohttp_client: AiohttpClient, + symlinks_supported: None, ) -> None: # Tests that break_symlink_sandbox does not allow directory transversal # after normalization @@ -518,7 +522,9 @@ def mock_open(self: pathlib.Path, *args: Any, **kwargs: Any) -> Any: async def test_access_symlink_loop( - tmp_path: pathlib.Path, aiohttp_client: AiohttpClient + tmp_path: pathlib.Path, + aiohttp_client: AiohttpClient, + symlinks_supported: None, ) -> None: # Tests the access to a looped symlink, which could not be resolved. my_dir_path = tmp_path / "my_symlink" @@ -536,7 +542,9 @@ async def test_access_symlink_loop( async def test_access_compressed_file_as_symlink( - tmp_path: pathlib.Path, aiohttp_client: AiohttpClient + tmp_path: pathlib.Path, + aiohttp_client: AiohttpClient, + symlinks_supported: None, ) -> None: """Test that compressed file variants as symlinks are ignored.""" private_file = tmp_path / "private.txt"