Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions redis/_parsers/resp2.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
class _RESP2Parser(_RESPBase):
"""RESP2 protocol implementation"""

MAX_NESTING_DEPTH = 100

def read_response(
self, disable_decoding=False, timeout: Union[float, object] = SENTINEL
):
Expand All @@ -27,7 +29,10 @@ def read_response(
return result

def _read_response(
self, disable_decoding=False, timeout: Union[float, object] = SENTINEL
self,
disable_decoding=False,
timeout: Union[float, object] = SENTINEL,
_depth=0,
):
raw = self._buffer.readline(timeout=timeout)
if not raw:
Expand Down Expand Up @@ -63,8 +68,16 @@ def _read_response(
elif byte == b"*" and response == b"-1":
return None
elif byte == b"*":
if _depth >= self.MAX_NESTING_DEPTH:
raise InvalidResponse(
f"Response nesting depth exceeded {self.MAX_NESTING_DEPTH}"
)
response = [
self._read_response(disable_decoding=disable_decoding, timeout=timeout)
self._read_response(
disable_decoding=disable_decoding,
timeout=timeout,
_depth=_depth + 1,
)
for i in range(int(response))
]
else:
Expand All @@ -78,6 +91,8 @@ def _read_response(
class _AsyncRESP2Parser(_AsyncRESPBase):
"""Async class for the RESP2 protocol"""

MAX_NESTING_DEPTH = 100

async def read_response(self, disable_decoding: bool = False):
if not self._connected:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
Expand All @@ -92,7 +107,7 @@ async def read_response(self, disable_decoding: bool = False):
return response

async def _read_response(
self, disable_decoding: bool = False
self, disable_decoding: bool = False, _depth: int = 0
) -> Union[EncodableT, ResponseError, None]:
raw = await self._readline()
response: Any
Expand Down Expand Up @@ -127,8 +142,12 @@ async def _read_response(
elif byte == b"*" and response == b"-1":
return None
elif byte == b"*":
if _depth >= self.MAX_NESTING_DEPTH:
raise InvalidResponse(
f"Response nesting depth exceeded {self.MAX_NESTING_DEPTH}"
)
response = [
(await self._read_response(disable_decoding))
(await self._read_response(disable_decoding, _depth=_depth + 1))
for _ in range(int(response)) # noqa
]
else:
Expand Down
69 changes: 60 additions & 9 deletions redis/_parsers/resp3.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
class _RESP3Parser(_RESPBase, PushNotificationsParser):
"""RESP3 protocol implementation"""

MAX_NESTING_DEPTH = 100

def __init__(self, socket_read_size):
super().__init__(socket_read_size)
self.pubsub_push_handler_func = self.handle_pubsub_push_response
Expand Down Expand Up @@ -61,6 +63,7 @@ def _read_response(
disable_decoding=False,
push_request=False,
timeout: Union[float, object] = SENTINEL,
_depth=0,
):
raw = self._buffer.readline(timeout=timeout)
if not raw:
Expand Down Expand Up @@ -106,41 +109,69 @@ def _read_response(
response = self._buffer.read(int(response), timeout=timeout)[4:]
# array response
elif byte == b"*":
if _depth >= self.MAX_NESTING_DEPTH:
raise InvalidResponse(
f"Response nesting depth exceeded {self.MAX_NESTING_DEPTH}"
)
response = [
self._read_response(disable_decoding=disable_decoding, timeout=timeout)
self._read_response(
disable_decoding=disable_decoding,
timeout=timeout,
_depth=_depth + 1,
)
for _ in range(int(response))
]
# set response
elif byte == b"~":
if _depth >= self.MAX_NESTING_DEPTH:
raise InvalidResponse(
f"Response nesting depth exceeded {self.MAX_NESTING_DEPTH}"
)
# redis can return unhashable types (like dict) in a set,
# so we return sets as list, all the time, for predictability
response = [
self._read_response(disable_decoding=disable_decoding, timeout=timeout)
self._read_response(
disable_decoding=disable_decoding,
timeout=timeout,
_depth=_depth + 1,
)
for _ in range(int(response))
]
# map response
elif byte == b"%":
if _depth >= self.MAX_NESTING_DEPTH:
raise InvalidResponse(
f"Response nesting depth exceeded {self.MAX_NESTING_DEPTH}"
)
# We cannot use a dict-comprehension to parse stream.
# Evaluation order of key:val expression in dict comprehension only
# became defined to be left-right in version 3.8
resp_dict = {}
for _ in range(int(response)):
key = self._read_response(
disable_decoding=disable_decoding, timeout=timeout
disable_decoding=disable_decoding,
timeout=timeout,
_depth=_depth + 1,
)
resp_dict[key] = self._read_response(
disable_decoding=disable_decoding,
push_request=push_request,
timeout=timeout,
_depth=_depth + 1,
)
response = resp_dict
# push response
elif byte == b">":
if _depth >= self.MAX_NESTING_DEPTH:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the push branch guards the recursion into the elements, but the tail call right below it (the "not a push_request, go read the actual reply" one) doesn't forward _depth, so it restarts at 0. a server/proxy dribbling out-of-band push frames back-to-back gets one stack frame per frame with the counter reset every time, which is the same RecursionError this PR is closing. same in _AsyncRESP3Parser.

raise InvalidResponse(
f"Response nesting depth exceeded {self.MAX_NESTING_DEPTH}"
)
response = [
self._read_response(
disable_decoding=disable_decoding,
push_request=push_request,
timeout=timeout,
_depth=_depth + 1,
)
for _ in range(int(response))
]
Expand All @@ -164,6 +195,8 @@ def _read_response(


class _AsyncRESP3Parser(_AsyncRESPBase, AsyncPushNotificationsParser):
MAX_NESTING_DEPTH = 100

def __init__(self, socket_read_size):
super().__init__(socket_read_size)
self.pubsub_push_handler_func = self.handle_pubsub_push_response
Expand All @@ -190,7 +223,7 @@ async def read_response(
return response

async def _read_response(
self, disable_decoding: bool = False, push_request: bool = False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Push continuation resets nesting depth

Medium Severity

After handling a RESP3 push reply, the follow-up _read_response call omits _depth, so nesting accounting restarts at zero. Nested aggregate parsing elsewhere increments _depth, so a hostile reply can combine deep nesting with pushes and grow recursion beyond the intended MAX_NESTING_DEPTH cap, weakening the DoS fix.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 32859c2. Configure here.

self, disable_decoding: bool = False, push_request: bool = False, _depth: int = 0
) -> Union[EncodableT, ResponseError, None]:
if not self._stream or not self.encoder:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
Expand Down Expand Up @@ -240,36 +273,54 @@ async def _read_response(
response = (await self._read(int(response)))[4:]
# array response
elif byte == b"*":
if _depth >= self.MAX_NESTING_DEPTH:
raise InvalidResponse(
f"Response nesting depth exceeded {self.MAX_NESTING_DEPTH}"
)
response = [
(await self._read_response(disable_decoding=disable_decoding))
(await self._read_response(disable_decoding, _depth=_depth + 1))
for _ in range(int(response))
]
# set response
elif byte == b"~":
if _depth >= self.MAX_NESTING_DEPTH:
raise InvalidResponse(
f"Response nesting depth exceeded {self.MAX_NESTING_DEPTH}"
)
# redis can return unhashable types (like dict) in a set,
# so we always convert to a list, to have predictable return types
response = [
(await self._read_response(disable_decoding=disable_decoding))
(await self._read_response(disable_decoding, _depth=_depth + 1))
for _ in range(int(response))
]
# map response
elif byte == b"%":
if _depth >= self.MAX_NESTING_DEPTH:
raise InvalidResponse(
f"Response nesting depth exceeded {self.MAX_NESTING_DEPTH}"
)
# We cannot use a dict-comprehension to parse stream.
# Evaluation order of key:val expression in dict comprehension only
# became defined to be left-right in version 3.8
resp_dict = {}
for _ in range(int(response)):
key = await self._read_response(disable_decoding=disable_decoding)
key = await self._read_response(
disable_decoding, _depth=_depth + 1
)
resp_dict[key] = await self._read_response(
disable_decoding=disable_decoding, push_request=push_request
disable_decoding, push_request=push_request, _depth=_depth + 1
)
response = resp_dict
# push response
elif byte == b">":
if _depth >= self.MAX_NESTING_DEPTH:
raise InvalidResponse(
f"Response nesting depth exceeded {self.MAX_NESTING_DEPTH}"
)
response = [
(
await self._read_response(
disable_decoding=disable_decoding, push_request=push_request
disable_decoding, push_request=push_request, _depth=_depth + 1
)
)
for _ in range(int(response))
Expand Down
Loading