Skip to content

Commit c207cad

Browse files
Fix limit on message tail (aio-libs#13501)
1 parent dfdcc7d commit c207cad

4 files changed

Lines changed: 341 additions & 6 deletions

File tree

CHANGES/13501.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed a limit on message tail after an upgrade request -- by :user:`Dreamsorcerer`.

aiohttp/web_protocol.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ class RequestHandler(BaseProtocol, Generic[_Request]):
183183
"_msg_queue_resume_size",
184184
"_msg_queue_paused",
185185
"_message_tail",
186+
"_read_bufsize",
186187
"_handler_waiter",
187188
"_waiter",
188189
"_task_handler",
@@ -224,6 +225,7 @@ def __init__(
224225
# Low-water mark: resume reading once the queue drains to half the limit
225226
# so we refill in batches instead of churning pause/resume per request.
226227
self._msg_queue_resume_size = MAX_MSG_QUEUE_SIZE // 2
228+
self._read_bufsize = read_bufsize
227229
# Set before super().__init__ so _reading_paused_for_msg_queue() is safe
228230
# if BaseProtocol ever triggers a resume during init.
229231
self._msg_queue_paused = False
@@ -454,6 +456,9 @@ def set_parser(
454456
self._payload_parser.feed_data(self._message_tail)
455457
self._message_tail = b""
456458

459+
if self._msg_queue_paused:
460+
self._resume_msg_queue_reading()
461+
457462
def eof_received(self) -> None:
458463
pass
459464

@@ -497,6 +502,11 @@ def data_received(self, data: bytes) -> None:
497502
# no parser, just store
498503
elif self._payload_parser is None and self._upgraded and data:
499504
self._message_tail += data
505+
if (
506+
not self._msg_queue_paused
507+
and len(self._message_tail) >= self._read_bufsize
508+
):
509+
self._pause_msg_queue_reading()
500510

501511
# feed payload
502512
elif data:
@@ -520,6 +530,10 @@ def _pause_msg_queue_reading(self) -> None:
520530
pass
521531

522532
def _resume_msg_queue_reading(self) -> None:
533+
# Tested empty-first so a read_bufsize of 0 cannot wedge the connection.
534+
if self._message_tail and len(self._message_tail) >= self._read_bufsize:
535+
return
536+
523537
if not self._upgraded:
524538
# Reparse buffered pipelined requests while still marked paused so
525539
# a refill past the limit does not re-pause an already-paused
@@ -822,19 +836,39 @@ async def finish_response(
822836
self._parser.set_upgraded(False)
823837
self._upgraded = False
824838
if self._message_tail:
825-
messages, upgraded, tail = self._parser.feed_data(self._message_tail)
839+
messages: Sequence[_MsgType]
840+
try:
841+
messages, upgraded, tail = self._parser.feed_data(
842+
self._message_tail
843+
)
844+
except HttpProcessingError as parse_exc:
845+
# Garbage (or an oversized request line) buffered behind the
846+
# upgrade: answer 400 instead of letting the error escape
847+
# and lose this response, like data_received() does.
848+
messages = [
849+
(
850+
_ErrInfo(
851+
status=400,
852+
exc=parse_exc,
853+
message=parse_exc.message,
854+
),
855+
EMPTY_PAYLOAD,
856+
)
857+
]
858+
upgraded = False
859+
tail = b""
826860
# A further upgrade request in the tail buffers its own remainder.
827861
self._upgraded = upgraded
828862
self._message_tail = tail
829863
for msg, payload in messages:
830864
self._request_count += 1
831865
self._messages.append((msg, payload))
832-
# Pause the transport, like in data_received().
833-
if (
834-
not self._msg_queue_paused
835-
and len(self._messages) >= self._max_msg_queue_size
836-
):
866+
if len(self._messages) >= self._max_msg_queue_size:
867+
# Pause the transport, like in data_received().
837868
self._pause_msg_queue_reading()
869+
elif self._msg_queue_paused:
870+
# Resume reading now the tail has been parsed.
871+
self._resume_msg_queue_reading()
838872
# This shouldn't be possible. If a future refactor results in this
839873
# failing, then the code may need to be updated to set the waiter.
840874
assert self._waiter is None

tests/test_web_functional.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@
2828
multipart,
2929
web,
3030
)
31+
from aiohttp._websocket.writer import WebSocketWriter
3132
from aiohttp.abc import AbstractResolver, ResolveResult
33+
from aiohttp.base_protocol import BaseProtocol
3234
from aiohttp.compression_utils import ZLibBackend, ZLibCompressObjProtocol
3335
from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE, TRANSFER_ENCODING
3436
from aiohttp.helpers import DEFAULT_CHUNK_SIZE, HeadersDictProxy
37+
from aiohttp.http import WSMsgType
3538
from aiohttp.streams import StreamReader
3639
from aiohttp.typedefs import Handler, Middleware
3740
from aiohttp.web_protocol import MAX_MSG_QUEUE_SIZE, RequestHandler
@@ -1901,6 +1904,221 @@ def raw_get(path: str) -> bytes:
19011904
assert len(set(handled)) == len(handled)
19021905

19031906

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+
19042122
async def test_declined_websocket_upgrade_reads_body(
19052123
aiohttp_server: AiohttpServer,
19062124
) -> None:

0 commit comments

Comments
 (0)