|
28 | 28 | multipart, |
29 | 29 | web, |
30 | 30 | ) |
| 31 | +from aiohttp._websocket.writer import WebSocketWriter |
31 | 32 | from aiohttp.abc import AbstractResolver, ResolveResult |
| 33 | +from aiohttp.base_protocol import BaseProtocol |
32 | 34 | from aiohttp.compression_utils import ZLibBackend, ZLibCompressObjProtocol |
33 | 35 | from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE, TRANSFER_ENCODING |
34 | 36 | from aiohttp.helpers import DEFAULT_CHUNK_SIZE, HeadersDictProxy |
| 37 | +from aiohttp.http import WSMsgType |
35 | 38 | from aiohttp.streams import StreamReader |
36 | 39 | from aiohttp.typedefs import Handler, Middleware |
37 | 40 | from aiohttp.web_protocol import MAX_MSG_QUEUE_SIZE, RequestHandler |
@@ -1901,6 +1904,221 @@ def raw_get(path: str) -> bytes: |
1901 | 1904 | assert len(set(handled)) == len(handled) |
1902 | 1905 |
|
1903 | 1906 |
|
| 1907 | +async def test_upgrade_tail_is_byte_limited( |
| 1908 | + aiohttp_server: AiohttpServer, |
| 1909 | + monkeypatch: pytest.MonkeyPatch, |
| 1910 | +) -> None: |
| 1911 | + """Bytes buffered behind an in-flight upgrade must not grow unbounded. |
| 1912 | +
|
| 1913 | + A request the parser flagged as an upgrade has no payload, so everything |
| 1914 | + arriving while its handler runs is buffered whole until the handler either |
| 1915 | + prepares a websocket or answers normally. Only ``read_bufsize`` of it may be |
| 1916 | + held before reading is paused, regardless of how much is sent. |
| 1917 | + """ |
| 1918 | + # Above the default so a ceiling that ignores read_bufsize fails the lower |
| 1919 | + # bound, and well below what is sent so no cap at all fails the upper one. |
| 1920 | + read_bufsize = 1024 * 1024 |
| 1921 | + handler_started = asyncio.Event() |
| 1922 | + release_handler = asyncio.Event() |
| 1923 | + reading_paused = asyncio.Event() |
| 1924 | + max_tail = 0 |
| 1925 | + data_received = RequestHandler.data_received |
| 1926 | + |
| 1927 | + def observe_data_received(self: RequestHandler[web.Request], data: bytes) -> None: |
| 1928 | + nonlocal max_tail |
| 1929 | + data_received(self, data) |
| 1930 | + if self._message_tail: |
| 1931 | + max_tail = max(max_tail, len(self._message_tail)) |
| 1932 | + if self._msg_queue_paused: |
| 1933 | + reading_paused.set() |
| 1934 | + |
| 1935 | + monkeypatch.setattr(RequestHandler, "data_received", observe_data_received) |
| 1936 | + |
| 1937 | + async def upgrade_handler(request: web.Request) -> web.Response: |
| 1938 | + handler_started.set() |
| 1939 | + await release_handler.wait() |
| 1940 | + return web.Response(text="declined") |
| 1941 | + |
| 1942 | + app = web.Application() |
| 1943 | + app.router.add_get("/upgrade", upgrade_handler) |
| 1944 | + server = await aiohttp_server(app, read_bufsize=read_bufsize) |
| 1945 | + |
| 1946 | + chunk = b"A" * (64 * 1024) |
| 1947 | + chunks = (4 * 1024 * 1024) // len(chunk) |
| 1948 | + |
| 1949 | + reader, writer = await asyncio.open_connection(server.host, server.port) |
| 1950 | + try: |
| 1951 | + writer.write( |
| 1952 | + b"GET /upgrade HTTP/1.1\r\nHost: localhost\r\n" |
| 1953 | + b"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n" |
| 1954 | + ) |
| 1955 | + await writer.drain() |
| 1956 | + await asyncio.wait_for(handler_started.wait(), 1) |
| 1957 | + |
| 1958 | + async def send_until_paused() -> None: |
| 1959 | + for _ in range(chunks): # pragma: no branch |
| 1960 | + if reading_paused.is_set(): |
| 1961 | + break |
| 1962 | + writer.write(chunk) |
| 1963 | + await writer.drain() |
| 1964 | + |
| 1965 | + sender = asyncio.create_task(send_until_paused()) |
| 1966 | + try: |
| 1967 | + # Only elapses if nothing caps the buffer, in which case the |
| 1968 | + # assertions below report what was actually buffered. |
| 1969 | + with suppress(asyncio.TimeoutError): |
| 1970 | + await asyncio.wait_for(reading_paused.wait(), 5) |
| 1971 | + finally: |
| 1972 | + sender.cancel() |
| 1973 | + with suppress(asyncio.CancelledError): |
| 1974 | + await sender |
| 1975 | + finally: |
| 1976 | + release_handler.set() |
| 1977 | + writer.close() |
| 1978 | + with suppress(ConnectionResetError, BrokenPipeError): |
| 1979 | + await writer.wait_closed() |
| 1980 | + |
| 1981 | + assert reading_paused.is_set(), f"reading never paused, buffered {max_tail} bytes" |
| 1982 | + # pause_reading() only takes effect after the read in flight, and asyncio |
| 1983 | + # reads at most DEFAULT_CHUNK_SIZE per call, so one extra chunk may land. |
| 1984 | + assert read_bufsize <= max_tail < read_bufsize + DEFAULT_CHUNK_SIZE |
| 1985 | + |
| 1986 | + |
| 1987 | +async def test_upgrade_tail_resumes_reading_after_websocket_prepare( |
| 1988 | + aiohttp_server: AiohttpServer, |
| 1989 | + monkeypatch: pytest.MonkeyPatch, |
| 1990 | +) -> None: |
| 1991 | + """A websocket paused while its tail filled up must keep reading. |
| 1992 | +
|
| 1993 | + A client may pipeline frames straight after the handshake request, filling |
| 1994 | + the tail buffer and pausing reading before the handler prepares. Once the |
| 1995 | + websocket owns the connection the tail is handed over, so reading has to |
| 1996 | + resume or whatever the client sent meanwhile is never read. |
| 1997 | + """ |
| 1998 | + handshake_seen = asyncio.Event() |
| 1999 | + reading_paused = asyncio.Event() |
| 2000 | + release_handler = asyncio.Event() |
| 2001 | + last_received = asyncio.Event() |
| 2002 | + received: list[str] = [] |
| 2003 | + data_received = RequestHandler.data_received |
| 2004 | + |
| 2005 | + def observe_data_received(self: RequestHandler[web.Request], data: bytes) -> None: |
| 2006 | + data_received(self, data) |
| 2007 | + if self._msg_queue_paused: |
| 2008 | + reading_paused.set() |
| 2009 | + |
| 2010 | + monkeypatch.setattr(RequestHandler, "data_received", observe_data_received) |
| 2011 | + |
| 2012 | + async def ws_handler(request: web.Request) -> web.WebSocketResponse: |
| 2013 | + handshake_seen.set() |
| 2014 | + await release_handler.wait() |
| 2015 | + ws = web.WebSocketResponse() |
| 2016 | + await ws.prepare(request) |
| 2017 | + async for msg in ws: # pragma: no branch |
| 2018 | + assert isinstance(msg.data, str) |
| 2019 | + received.append(msg.data) |
| 2020 | + if msg.data == "last": |
| 2021 | + last_received.set() |
| 2022 | + break |
| 2023 | + return ws |
| 2024 | + |
| 2025 | + app = web.Application() |
| 2026 | + app.router.add_get("/ws", ws_handler) |
| 2027 | + read_bufsize = 64 * 1024 |
| 2028 | + server = await aiohttp_server(app, read_bufsize=read_bufsize) |
| 2029 | + |
| 2030 | + # Enough pipelined frames to fill the tail, so reading must pause. |
| 2031 | + frame_payload = "B" * (32 * 1024) |
| 2032 | + frames = (read_bufsize // len(frame_payload)) + 2 |
| 2033 | + |
| 2034 | + reader, writer = await asyncio.open_connection(server.host, server.port) |
| 2035 | + try: |
| 2036 | + writer.write( |
| 2037 | + b"GET /ws HTTP/1.1\r\nHost: localhost\r\n" |
| 2038 | + b"Connection: Upgrade\r\nUpgrade: websocket\r\n" |
| 2039 | + b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" |
| 2040 | + b"Sec-WebSocket-Version: 13\r\n\r\n" |
| 2041 | + ) |
| 2042 | + await writer.drain() |
| 2043 | + await asyncio.wait_for(handshake_seen.wait(), 1) |
| 2044 | + |
| 2045 | + # Encode client frames off to the side, as tests/test_websocket_writer.py |
| 2046 | + # does, and put the bytes on the wire ourselves. |
| 2047 | + encoded = bytearray() |
| 2048 | + frame_transport = mock.create_autospec( |
| 2049 | + asyncio.Transport, spec_set=True, instance=True |
| 2050 | + ) |
| 2051 | + # Defaults to a truthy Mock, which WebSocketWriter reads as closing. |
| 2052 | + frame_transport.is_closing.return_value = False |
| 2053 | + frame_transport.write.side_effect = encoded.extend |
| 2054 | + ws_writer = WebSocketWriter( |
| 2055 | + mock.create_autospec(BaseProtocol, spec_set=True, instance=True), |
| 2056 | + frame_transport, |
| 2057 | + use_mask=True, |
| 2058 | + ) |
| 2059 | + |
| 2060 | + for _ in range(frames): |
| 2061 | + await ws_writer.send_frame(frame_payload.encode(), WSMsgType.TEXT) |
| 2062 | + writer.write(encoded) |
| 2063 | + await writer.drain() |
| 2064 | + await asyncio.wait_for(reading_paused.wait(), 5) |
| 2065 | + |
| 2066 | + # Sent while reading is paused, so this frame is only ever read if the |
| 2067 | + # handover to the websocket resumes the transport. |
| 2068 | + encoded.clear() |
| 2069 | + await ws_writer.send_frame(b"last", WSMsgType.TEXT) |
| 2070 | + writer.write(encoded) |
| 2071 | + await writer.drain() |
| 2072 | + release_handler.set() |
| 2073 | + |
| 2074 | + await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 5) |
| 2075 | + await asyncio.wait_for(last_received.wait(), 5) |
| 2076 | + finally: |
| 2077 | + release_handler.set() |
| 2078 | + writer.close() |
| 2079 | + with suppress(ConnectionResetError, BrokenPipeError): |
| 2080 | + await writer.wait_closed() |
| 2081 | + |
| 2082 | + assert received == [frame_payload] * frames + ["last"] |
| 2083 | + |
| 2084 | + |
| 2085 | +async def test_bad_pipelined_data_behind_declined_upgrade_answers_400( |
| 2086 | + aiohttp_server: AiohttpServer, |
| 2087 | +) -> None: |
| 2088 | + """Junk buffered behind an upgrade must not swallow the upgrade response. |
| 2089 | +
|
| 2090 | + The tail is only parsed once the handler answers, so a parse error there |
| 2091 | + has to be reported as a 400 like it would be from data_received(); letting |
| 2092 | + it escape loses the response that was about to be written. |
| 2093 | + """ |
| 2094 | + |
| 2095 | + async def upgrade_handler(request: web.Request) -> web.Response: |
| 2096 | + return web.Response(text="declined") |
| 2097 | + |
| 2098 | + app = web.Application() |
| 2099 | + app.router.add_get("/upgrade", upgrade_handler) |
| 2100 | + server = await aiohttp_server(app) |
| 2101 | + |
| 2102 | + reader, writer = await asyncio.open_connection(server.host, server.port) |
| 2103 | + try: |
| 2104 | + writer.write( |
| 2105 | + b"GET /upgrade HTTP/1.1\r\nHost: localhost\r\n" |
| 2106 | + b"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n" |
| 2107 | + b"\x00" * 64 |
| 2108 | + ) |
| 2109 | + await writer.drain() |
| 2110 | + response = await asyncio.wait_for(reader.read(), 5) |
| 2111 | + finally: |
| 2112 | + writer.close() |
| 2113 | + with suppress(ConnectionResetError, BrokenPipeError): |
| 2114 | + await writer.wait_closed() |
| 2115 | + |
| 2116 | + assert b"declined" in response, response |
| 2117 | + # A 400 only follows if the parse error was reported instead of escaping, |
| 2118 | + # and "declined" only arrives if it did not abort this response first. |
| 2119 | + assert b" 400 " in response, response |
| 2120 | + |
| 2121 | + |
1904 | 2122 | async def test_declined_websocket_upgrade_reads_body( |
1905 | 2123 | aiohttp_server: AiohttpServer, |
1906 | 2124 | ) -> None: |
|
0 commit comments