Skip to content

fix: guard RESP parsers against RecursionError on deeply nested replies (#4116) - #4144

Open
C1-BA-B1-F3 wants to merge 2 commits into
redis:masterfrom
C1-BA-B1-F3:fix/4116-resp-parser-recursion-depth-guard
Open

fix: guard RESP parsers against RecursionError on deeply nested replies (#4116)#4144
C1-BA-B1-F3 wants to merge 2 commits into
redis:masterfrom
C1-BA-B1-F3:fix/4116-resp-parser-recursion-depth-guard

Conversation

@C1-BA-B1-F3

@C1-BA-B1-F3 C1-BA-B1-F3 commented Jun 26, 2026

Copy link
Copy Markdown

Problem

The pure-Python RESP parser path uses recursive parsing for nested aggregate replies (arrays, sets, maps, push). A malicious Redis server, compromised proxy, or on-path actor can inject deeply nested RESP replies to trigger RecursionError, causing client-side denial of service.

Reproduction (from #4116):

payload = (b"*1\r\n" * 5000) + b"+OK\r\n"
# Feed to _RESP2Parser → RecursionError

Fix

Add MAX_NESTING_DEPTH = 100 to all four pure-Python parser classes:

  • _RESP2Parser / _AsyncRESP2Parser (resp2.py)
  • _RESP3Parser / _AsyncRESP3Parser (resp3.py)

When nesting depth exceeds the limit, raise InvalidResponse instead of allowing unbounded recursion. The depth limit of 100 is generous for legitimate Redis workloads while preventing stack exhaustion.

All aggregate types are guarded: * (array), ~ (set), % (map), > (push).

Tests

12 new tests covering:

  • Normal nesting (10 levels) parses correctly
  • At-limit nesting (100 levels) parses correctly
  • Over-limit nesting (101+ levels) raises InvalidResponse
  • Massively nested (5000 levels) raises InvalidResponse, not RecursionError
  • All four parsers (sync RESP2, async RESP2, sync RESP3, async RESP3)
  • RESP3-specific types: set (~), map (%)

Fixes #4116


Note

Medium Risk
Touches core wire-protocol parsing on every connection; legitimate deeply nested replies above 100 levels would now fail, but the limit is unlikely for normal Redis workloads.

Overview
Adds a 100-level nesting cap on pure-Python RESP2/RESP3 parsers (sync and async) so malicious or pathological aggregate replies no longer blow the stack with RecursionError.

_read_response now tracks _depth and raises InvalidResponse when depth exceeds MAX_NESTING_DEPTH. RESP2 guards nested arrays (*); RESP3 also guards sets (~), maps (%), and push (>), with depth passed through recursive child reads.

New tests in test_resp_recursion.py cover normal and at-limit nesting, over-limit and very deep (5000-level) payloads, and all four parser classes plus RESP3 set/map types.

Reviewed by Cursor Bugbot for commit b4915b5. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread redis/asyncio/client.py Outdated
Comment thread redis/commands/core.py Outdated
return self.execute_command("BLPOP", *keys)
return self.execute_command(
"BLPOP", *keys, _blocking_timeout=timeout
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Zero blocking timeout breaks wait

Medium Severity

Blocking list commands pass _blocking_timeout=timeout where Redis uses 0 to mean block indefinitely. That value is forwarded as the socket read timeout, which on async clients becomes an immediate async_timeout(0) and on sync clients sets a zero-second/non-blocking socket read, so indefinite blocking commands fail instead of waiting for data.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 498b42d. Configure here.

@petyaslavova

Copy link
Copy Markdown
Collaborator

I think this PR as well is poluted with additional changes meant to be added to other fixes. Please clean up.

The pure-Python RESP parser path uses recursive parsing for nested
aggregate replies (arrays, sets, maps, push). A malicious server or
compromised proxy can inject deeply nested RESP replies to trigger
RecursionError, causing client-side denial of service.

Add MAX_NESTING_DEPTH (100) to all four parser classes:
- _RESP2Parser / _AsyncRESP2Parser
- _RESP3Parser / _AsyncRESP3Parser

When nesting depth is exceeded, raise InvalidResponse instead of
allowing unbounded recursion.

Fixes redis#4116
@C1-BA-B1-F3
C1-BA-B1-F3 force-pushed the fix/4116-resp-parser-recursion-depth-guard branch from 498b42d to 32859c2 Compare July 3, 2026 10:04
@C1-BA-B1-F3

Copy link
Copy Markdown
Author

Thanks @petyaslavova! Branch cleaned up — now only contains the RESP RecursionError guard changes. The unrelated commits have been removed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

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

Comment thread redis/_parsers/resp3.py
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.

@petyaslavova

Copy link
Copy Markdown
Collaborator

Thanks for cleaning up the branch. The approach is right and the tests exercise the real parser path via SocketBuffer, which is exactly what we want for a security regression. Before merging, please add a non-regression test for a burst of top-level RESP3 push frames so we prove the guard doesn't falsely trip on legitimate push traffic (you can borrow the scenario from #4200).

One thing for a follow-up (not required here): the push continuation in resp3.py recurses without incrementing depth, so a flood of consecutive push frames is a separate recursion vector outside this issue's scope. Once the push-burst test is in and CI (ruff + tests) is green, this should be ready for another review.

Comment thread redis/_parsers/resp3.py
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

redis-py pure-Python parser hits RecursionError on deeply nested RESP replies

3 participants