Skip to content

fix(#5895): bound RESP array nesting depth and element count in the Redis wrapper - #5902

Merged
robfrank merged 7 commits into
mainfrom
fix/5895-redis-resp-recursion-limit
Aug 7, 2026
Merged

fix(#5895): bound RESP array nesting depth and element count in the Redis wrapper#5902
robfrank merged 7 commits into
mainfrom
fix/5895-redis-resp-recursion-limit

Conversation

@lvca

@lvca lvca commented Aug 7, 2026

Copy link
Copy Markdown
Member

Summary

  • RedisNetworkExecutor.parseNext() decoded RESP arrays recursively with no bound on nesting depth. A ~47 KB payload of deeply nested arrays (*1\r\n repeated ~11,860 times) overflowed the connection thread's JVM stack with an uncaught StackOverflowError, reachable pre-authentication by anyone who can open the Redis port, since the whole message must be parsed before the NOAUTH check runs.
  • The client-declared array length (arraySize) was also unbounded: a single *2000000000\r\n header started a parse loop the client could keep alive indefinitely by trickling bytes, tying up a connection thread.
  • Both are now bounded by two new configurable settings mirroring Redis' own protocol limits: arcadedb.redis.maxMultiBulkDepth (default 32) and arcadedb.redis.maxMultiBulkLength (default 1,048,576, matching Redis' own hard cap on multibulk requests). A violation of either fails fast with a RESP -ERR Protocol error: ... reply, and the connection is closed rather than attempting to resynchronize on an untrusted stream position.

Test plan

  • New regression test RedisProtocolLimitsTest (raw-socket, modeled on RedisAuthenticationTest) reproduces both issues against unpatched code (StackOverflowError for the nesting case, SocketTimeoutException/hang for the oversized-length case) and passes against the fix.
  • Full redisw module test suite passes (48 tests, 0 failures).
  • Full project compiles (mvn compile).

Closes #5895

🤖 Generated with Claude Code

…edis wrapper

RedisNetworkExecutor.parseNext() recursed once per RESP array nesting level with no
depth limit, so a small (~47 KB) deeply-nested payload overflowed the connection
thread's JVM stack before the NOAUTH check could reject it. The client-declared array
length was equally unbounded, letting a single *2000000000\r\n header start a parse
loop the client could keep alive indefinitely.

Both are now bounded by two configurable settings mirroring Redis' own protocol
limits (arcadedb.redis.maxMultiBulkDepth, default 32; arcadedb.redis.maxMultiBulkLength,
default 1048576, matching Redis' own hard cap). Either violation fails fast with a RESP
error reply and closes the connection.
@mergify

mergify Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@codacy-production

codacy-production Bot commented Aug 7, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 5 complexity

Metric Results
Complexity 5

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review: fix(#5895): bound RESP array nesting depth and element count in the Redis wrapper

Solid fix for the two vectors described in the issue - the depth counter is threaded cleanly through parseNext(depth), the failure mode (fail fast + close, no resync attempt on an untrusted stream position) is the right call, and the new RedisProtocolLimitsTest reproduces both the stack overflow and the hung-parse-loop against real sockets. A few things worth a look before merging:

Security - the same vulnerability class is still open for bulk strings ($)

parseNext()'s $ branch (RedisNetworkExecutor.java:778-782) calls parseChars(Integer.parseInt(parseValueUntilLF())) with no cap at all:

```java
final String value = parseChars(Integer.parseInt(parseValueUntilLF()));
```

parseChars (line 860) loops for (int i = 0; i < size && !shutdown; ++i) readNext(). A client sending \$2000000000\r\n and then trickling bytes (or none at all) ties up the connection thread exactly like the *2000000000\r\n case this PR fixes for arrays - and it's reachable pre-auth for the same reason (the whole message is parsed before NOAUTH is checked). If the attacker does send the declared bytes, the shared value StringBuilder grows unbounded, which is also a memory-exhaustion vector. Since \$ bulk strings are on the hot path of essentially every command (GET, SET, the command name itself, ...), this is arguably a bigger attack surface than the array-nesting case that motivated the fix. Real Redis bounds this with proto-max-bulk-len (default 512MB); worth adding an equivalent arcadedb.redis.maxBulkLength-style cap (or reusing maxMultiBulkLength) and covering it with a regression test analogous to oversizedArrayLengthIsRejectedImmediately.

Minor: malformed (non-numeric) length escapes the new error handling

Integer.parseInt(parseValueUntilLF()) is used for *, \$, and : values. A non-numeric length (e.g. \$abc\r\n) throws an unchecked NumberFormatException, which isn't caught by any of the catch clauses in run() (EOFException, SocketException, SocketTimeoutException, RedisProtocolLimitException, IOException - all IOException-family). The connection thread would die with an uncaught exception instead of getting the same clean -ERR Protocol error + close treatment this PR just added for the two size-related cases. Pre-existing behavior, but since this PR is specifically hardening the protocol parser against malformed/hostile input, it'd be a natural place to also wrap this (or have RedisProtocolLimitException/a sibling cover it).

Minor: no lower-bound validation on the new settings

If arcadedb.redis.maxMultiBulkDepth is configured to 0 (or negative), every command would fail to parse: a flat command like PING is itself one level of array nesting (depth=1 when parsing its elements), so depth > maxMultiBulkDepth would trip immediately. Not a security issue, but a misconfiguration foot-gun worth a sanity check or doc note.

Design note (non-blocking)

Real Redis's request-side protocol doesn't actually support nested arrays as command elements at all - processMultibulkBuffer requires each element to start with \$, and rejects anything else with a protocol error. maxMultiBulkDepth is therefore a bespoke concept needed only because this parser generically recurses on * wherever it appears. The bounded-depth approach here is a reasonable, safe mitigation, but an alternative worth considering is simply rejecting a nested * as a command element outright (matching upstream Redis semantics) rather than permitting up to 32 levels of it - that would remove the recursion vector entirely instead of just capping it. Not required for this fix to be correct.

Test coverage

The two new tests are well-targeted (raw socket, reproduce-then-verify-fixed style, plus a following Jedis round-trip to confirm the listener/thread survives). Two gaps:

  • No test exercises a non-default configured value for maxMultiBulkDepth/maxMultiBulkLength to confirm the setting is actually wired up end-to-end, as opposed to just relying on the (also-untested-directly) default.
  • No test for the \$ bulk-string length vector noted above, since it isn't fixed yet.

Nits

  • GlobalConfiguration additions follow the existing REDIS_* style and doc-string conventions well.
  • The -ERR + e.getMessage() concatenation in run()'s new catch block produces the right wire format (verified against the test's startsWith("-ERR") / containsIgnoringCase("multibulk length") assertions).
  • RedisProtocolLimitException's javadoc {@link RedisNetworkExecutor#parseNext(int)} points at a private method - harmless, but some javadoc tooling warns on links to non-visible members.

Overall: good, well-tested fix for the two reported vectors with a sensible fail-closed design. I'd consider the bulk-string ($) gap above worth addressing before/alongside this, since it's the same bug class and likely higher-traffic code path than the array-nesting one.

…ength handling

Code review on the array-nesting fix flagged that RESP bulk strings ($) had the same
unbounded-length problem as arrays, but on a hotter path: the command name and every
argument (including GET/SET's own payloads) are bulk strings, so a $2000000000\r\n
header could tie up a connection thread the same way *2000000000\r\n did, or grow the
parse buffer without bound if the bytes were actually sent. Bounded by a new
arcadedb.redis.maxBulkLength setting (default 512MB, matching Redis' own
proto-max-bulk-len).

Also: a non-numeric length (e.g. $abc\r\n) used to throw an uncaught
NumberFormatException that killed the connection thread outright instead of getting
the clean -ERR Protocol error + close treatment added for the size-related cases; and
the three new limit settings now fall back to their default (with a warning) if
misconfigured below the minimum value a connection can actually parse with.
@lvca

lvca commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Addressed the review points:

  • Bulk-string ($) length was unbounded (same bug class, higher-traffic path) — fixed. New arcadedb.redis.maxBulkLength setting (default 512MB, matching Redis' proto-max-bulk-len), with a regression test mirroring the array-length one.
  • Non-numeric length escaping error handling — fixed. Integer.parseInt for all three length/integer sites (*, $, :) now goes through a helper that converts NumberFormatException into the same RedisProtocolLimitException fail-closed path, with a regression test.
  • No lower-bound validation on the new settings — fixed. A misconfigured value below what a connection can actually parse with now falls back to the built-in default with a warning log, instead of silently breaking every command.
  • Javadoc {@link} to a private member — fixed, switched to plain code font.
  • Test coverage gap on non-default configured values — fixed, added a test that lowers maxMultiBulkDepth and confirms the new limit is actually enforced end-to-end.
  • Design note (reject nested arrays entirely, matching upstream Redis semantics) — left as-is. It's a reasonable alternative and would remove the recursion vector rather than bound it, but the review itself flagged it non-blocking/not required, and the bounded-depth approach already closes the reported vector without changing accepted-command shape. Happy to revisit if there's a concrete reason to prefer it.

All 51 redisw tests pass, full project compiles.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

Solid, well-scoped security fix. The core mechanism is correct: bounding recursion depth via a counter checked at the top of parseNext() genuinely caps the JVM call-stack usage (so the 50k-deep test payload can no longer overflow the stack even though the input is much deeper than maxMultiBulkDepth), and bounding the client-declared array/bulk lengths before looping/reading prevents the unbounded parse-loop DoS. Closing the connection on a limit violation (rather than trying to resync) is the right call given the stream position can't be trusted afterward.

Nice touches

  • sanitizedLimit() guarding against a misconfigured floor (e.g. depth 0 would reject even a flat PING) is a thoughtful detail, and it's covered by a dedicated test.
  • Turning the previously-uncaught NumberFormatException on a malformed length ($abc\r\n) into a clean -ERR + close is a good catch from the second round of review mentioned in the commit message.
  • Test coverage is good: nesting overflow, oversized array length, oversized bulk length, malformed length, and an end-to-end "is the config actually wired" check for depth, each followed by a liveness check that a fresh connection still works.
  • New ArrayList<>() for a validated array size isn't pre-sized to arraySize, avoiding a needless large allocation on the accept path.

Found one issue worth fixing

docs/release-26.9.1.md:193 - the new Redis section ends with a dangling reference link:

[#5588](https://github.com/ArcadeData/arcadedb/issues/5588)

This is the only such trailing [#N] link anywhere in the file (grep confirms no other section uses this pattern), and #5588 is an unrelated issue - this PR closes #5895, which is already correctly referenced inline in the section heading. Looks like a stray copy-paste left over from drafting. Suggest just deleting that line.

Minor / non-blocking observations

  • Test coverage gap: configuredDepthLimitIsHonored verifies arcadedb.redis.maxMultiBulkDepth is actually wired end-to-end via a lowered limit, but there's no equivalent test for maxMultiBulkLength or maxBulkLength (the oversized-length tests only ever exercise the built-in defaults). Not a big deal since the code path is identical (sanitizedLimit + a single comparison), but worth adding for symmetry/regression safety given these are the two settings actually reachable pre-auth on the hottest path.
  • parseChars() memory shape (pre-existing, not introduced here): bulk strings are still built one char at a time into a StringBuilder, which is ~2x the wire size in the JVM (UTF-16) plus the final toString() copy. With the new 512MB maxBulkLength default, a single legitimate large SET could now transiently hold ~1GB+ for that one value. The PR's bound is still a clear improvement over "unbounded," and matching Redis' own 512MB default is a reasonable choice, but it might be worth a follow-up to read bulk strings into a byte[] directly instead of char-by-char, both for memory and for correctness with non-ASCII/binary payloads (the current (char) b cast will mangle any byte ≥ 0x80).
  • The floor of 1 used for maxMultiBulkLength/maxBulkLength in sanitizedLimit is reasonable (guards against 0/negative misconfiguration) but doesn't guarantee usability the way it does for depth (e.g. a maxBulkLength of 1 would still reject the 4-byte PING command name itself). Probably fine as documented behavior for an intentionally low config, just flagging since the depth floor's rationale doesn't fully carry over.

Overall: the fix addresses the reported DoS class correctly, is well tested, and the code matches the file's existing style. Just the stray doc link to clean up.

…gth limits

Second review round caught that appending the new Redis section directly after the
vector-index section left that section's closing [#5588] reference link stranded past
the new content instead of closing its own section - moved it back.

Also added symmetric end-to-end tests confirming arcadedb.redis.maxMultiBulkLength and
arcadedb.redis.maxBulkLength are actually wired to a non-default configured value
(configuredDepthLimitIsHonored already covered maxMultiBulkDepth this way).
@lvca

lvca commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Addressed round 2:

  • Orphaned [#5588] doc link — fixed, moved back to close the vector-index section it belongs to instead of trailing after the new Redis section.
  • Missing wiring tests for maxMultiBulkLength/maxBulkLength — fixed, added configuredMultiBulkLengthLimitIsHonored and configuredBulkLengthLimitIsHonored, mirroring the existing depth test.
  • parseChars() char-by-char UTF-16/binary-safety gap — real, but out of scope for this DoS fix and would touch how every command's arguments are read. Filed as Redis wrapper: RESP bulk strings are parsed char-by-char, mangling non-ASCII/binary payloads #5907 rather than folding into this PR.
  • Depth floor's rationale not fully carrying over to the length floors — agreed, it's documented/expected behavior for an intentionally low config rather than a bug; no code change.

All 53 redisw tests pass.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

Solid fix for a real pre-auth DoS in the Redis wire protocol (RedisNetworkExecutor). I traced through the parser logic and the config wiring in detail; nothing blocking. A few notes:

Correctness

  • The depth check (depth > maxMultiBulkDepth) fires before the byte is read each call, so recursion is bounded to maxMultiBulkDepth + 1 frames regardless of how deep the attacker's payload claims to be — confirmed this stops the stack overflow well before any real risk to the default JVM stack.
  • parseLength() correctly converts NumberFormatException into a clean RedisProtocolLimitException instead of letting it escape uncaught and kill the thread (this also silently fixes a pre-existing bug where a malformed : integer or $/* length crashed the connection thread).
  • RedisProtocolLimitException extends IOException, so it's caught cleanly by run()'s existing catch chain, ordered correctly before the generic IOException handler.
  • The sanitizedLimit() floor of 1 for all three settings is well reasoned — a depth floor of 1 still admits a flat PING (its single argument is nesting level 1), and the javadoc explains why 0 would be a footgun.
  • Double-checked the \r\n in the new GlobalConfiguration text-block descriptions (REDIS_MAX_MULTIBULK_LENGTH/REDIS_MAX_BULK_LENGTH) — they're correctly escaped as \\r\\n in the source, so the rendered help text will show the literal *2000000000\r\n string, not embedded control characters. (I initially misread this from the gh pr diff rendering; the actual file is correct.)

Minor, non-blocking observations

  • In parseNext, the arraySize <= 0 early-return branch (for *0 / *-1) is behaviorally a no-op vs. the old code — a non-positive arraySize already made the for loop execute zero times, and it's always <= maxMultiBulkLength too. Not wrong, just a bit of redundant code kept for the explanatory comment; fine either way.
  • sanitizedLimit() logs a WARNING per new connection when a setting is misconfigured, so a busy server with a bad config value could get log-spammy under high connection churn. Very unlikely to matter given it's a static misconfiguration, but worth knowing.
  • The null/empty RESP2 bulk string ($-1\r\n) still parses to "" rather than null — pre-existing behavior (parseChars with a negative size just iterates zero times), not introduced or worsened by this PR, so out of scope here.

Test coverage
Good — RedisProtocolLimitsTest covers all three limits, the malformed-length crash path, and (importantly) verifies the connection/listener stays healthy after each rejection via a follow-up Jedis AUTH+PING. The two "configured limit is honored" tests for multibulk-length and bulk-length are a nice addition over just trusting the default-value tests. Raw-socket approach is appropriate since Jedis wouldn't let you craft malformed RESP directly.

Docs & config
GlobalConfiguration entries are well-documented and mirror real Redis defaults (proto-max-multibulk-len = 1,048,576, proto-max-bulk-len = 512MB), which is a nice touch for anyone tuning against familiar Redis semantics. Release notes read clearly and the stranded [#5588] link from the prior section was correctly restored.

Nice, thorough writeup in the PR description and commit messages too — the three-commit progression (nesting depth → bulk-string length → wiring tests + doc fix) reads like genuine iterative hardening rather than a single rushed patch.

…nection

Round 3 review noted sanitizedLimit() logging a WARNING on every new connection when
a setting is misconfigured could get log-spammy on a busy server churning through
connections against a static bad value. The value is still re-read (and re-validated)
per connection since it can change at runtime, but the warning itself now only fires
once per setting per JVM.
@lvca

lvca commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Round 3 was clean (no blocking issues). Of the three minor/non-blocking notes:

  • Log-spam risk in sanitizedLimit() under connection churn — fixed, the warning now fires once per setting per JVM instead of once per connection (the value itself is still re-read/re-validated per connection since it can change at runtime).
  • Redundant arraySize <= 0 early-return — left as-is; it's a no-op vs. prior behavior as noted, kept for the explanatory comment/symmetry with the other two length checks.
  • $-1\r\n parses to "" not null — explicitly called out as pre-existing and out of scope; no change.

All 53 redisw tests pass, full project compiles.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

Solid, well-scoped fix for a real pre-auth DoS (issue #5895). The depth/length bounds are checked at the earliest possible point (right after the header line is parsed, before entering any loop), which correctly closes both the stack-overflow vector and the "declare a huge size, trickle bytes" CPU/parse-loop vector described in the PR. Good iterative hardening across the four commits (array depth -> bulk length -> malformed-length handling -> log-spam fix). Test coverage is thorough: it reproduces the crash/hang against the described scenarios and separately proves each of the three settings is actually wired end-to-end rather than only exercising defaults.

Possible issue: new error path bypasses the existing RESP error-sanitization convention

respErrorMessage() (used by executeCommand's generic catch block) explicitly strips embedded \r/\n before putting an exception message into a RESP -ERR line, precisely because RESP error replies are single-line:

static String respErrorMessage(final Throwable error) {
  ...
  return message.replace('\r', ' ').replace('\n', ' ');
}

The new RedisProtocolLimitException handler in run() doesn't reuse it:

} catch (final RedisProtocolLimitException e) {
  ...
  value.append("-ERR ").append(e.getMessage());
  appendCrLf();
  replyToClient(value);

parseLength()'s malformed-token message embeds the raw client-supplied token verbatim ("invalid " + what + " '" + raw + "'"). parseValueUntilLF() only treats \r as the start of a terminator - a bare \n (no preceding \r) falls through to the else branch and gets appended into the token as a literal character. So a header like $1\nA\r\n produces raw = "1\nA", and the resulting reply embeds an unescaped \n inside what's supposed to be a single RESP reply line. A strict client that scans specifically for \r\n (not bare \n) as the line terminator won't misparse this, so the practical exploitability looks low (single point-to-point connection, no cross-client effect) - but it's an inconsistency worth closing: reusing respErrorMessage()'s sanitization (or at least stripping \n too, since raw can only ever be \r-free but may contain \n) in the new catch block would make the two error-reply paths consistent and remove the gap entirely.

Minor / informational, pre-existing (not introduced by this PR)

  • parseChars(size) is called unconditionally for the $ (bulk string) case, and negative sizes now silently fall through to parseChars(negative) (loop doesn't execute) followed by an unconditional skipLF(). For an actual RESP null bulk string ($-1\r\n), there's no trailing \r\n after the header - it's a complete, self-terminated token - so skipLF() ends up consuming 2 bytes that actually belong to whatever follows, silently desyncing the stream instead of erroring cleanly. The * (array) path already special-cases arraySize <= 0 for exactly this reason (*-1 / *0); the $ path doesn't get the same treatment. This predates the PR (the old code had the identical parseChars(Integer.parseInt(...)) call), but since this PR is specifically about hardening this parser against malformed input, it might be worth a follow-up to reject/handle $-1 explicitly rather than let it corrupt the next parse.
  • No idle/read timeout is configured on the Redis server socket (RedisNetworkExecutor/ChannelBinaryServer - unlike BoltNetworkExecutor, which sets a handshake timeout). The new caps bound memory/CPU per declared size, but a client can still open a connection and hold a thread indefinitely by trickling bytes up to the (now-capped) maxBulkLength/maxMultiBulkLength, since SocketTimeoutException in run() is silently ignored and the loop just retries readNext() forever. Out of scope for this issue (Redis wrapper: unbounded RESP array recursion overflows the stack pre-auth (~47 KB); array size also unvalidated #5895 was specifically about the unbounded size letting the loop run past any thread's tolerance), but worth a separate ticket if slow-trickle connection exhaustion is a concern for this listener.

Nit

  • sanitizedLimit's floor of 1 for maxBulkLength permits a configured value as low as 1, which would reject essentially every real command (even the shortest command names are longer than 1 byte) without tripping the "misconfigured, falling back to default" warning. Not a bug, just a very permissive floor - might be worth a slightly higher floor or at least noting in the Javadoc/setting description that "usable" here means "parses without erroring," not "usable for real traffic."

Nice-to-haves already done well

  • The WARNED_MISCONFIGURED_LIMITS once-per-setting-per-JVM warning throttle is a good call for a busy server churning through connections against a static bad config.
  • Extending IOException for RedisProtocolLimitException (rather than the existing RedisException/ArcadeDBException) is well-justified in the Javadoc and fits cleanly into run()'s existing catch hierarchy without needing new control flow.
  • Default values (32 / 1,048,576 / 512MB) are sensibly chosen to mirror real Redis limits rather than invented numbers.

No blocking issues found; the one worth addressing before merge is the sanitization inconsistency above, since this PR's whole purpose is hardening this exact parser against adversarial input.

…ErrorMessage()

Round 4 review caught that RedisProtocolLimitException's catch block in run() embedded
e.getMessage() into the RESP -ERR line verbatim, unlike executeCommand's generic catch
block which strips \r/\n via respErrorMessage() precisely because RESP error replies
are single-line. parseLength()'s malformed-token message embeds the raw client-supplied
token, and parseValueUntilLF() only treats \r as the start of the CRLF terminator, so a
bare \n survives into the token (e.g. $1\nA\r\n) and would have been echoed unsanitized
into the reply. Now reuses respErrorMessage() for consistency, with a regression test
that reproduces the raw-\n reply against the unpatched code and confirms the fix.

Also documents that sanitizedLimit()'s floor of 1 for the length-based settings means
"parses at all", not "usable for real traffic" (per review nit).
@lvca

lvca commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Round 4 addressed:

All 54 redisw tests pass, full project compiles.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

Solid fix for a real pre-auth DoS in the Redis wire protocol (unbounded RESP array recursion leading to StackOverflowError, plus unbounded declared array/bulk lengths leading to thread-tie-up). The commit history shows this already went through several self-review rounds (nesting depth, then bulk-string length, then a doc link fix, then a log-spam fix, then CRLF-sanitization), and it shows: the implementation is careful and the regression tests are thorough (depth overflow, oversized multibulk/bulk length, malformed non-numeric length, CRLF-injection into the error reply, and settings actually wired end-to-end rather than just relying on defaults).

Code quality / correctness

  • RedisProtocolLimitException extends IOException is a reasonable choice so it flows through the existing IOException handling in run(), and the new catch block is correctly ordered before the generic catch (IOException e) (RedisNetworkExecutor.java:139).
  • parseLength() correctly closes the gap where a non-numeric length used to throw an uncaught NumberFormatException and kill the connection thread outright - good catch in the second review round.
  • respErrorMessage() reuse for the new error path (round 4 fix) is correct: parseLength's malformed-token message embeds the raw client-supplied token, and since parseValueUntilLF() only special-cases \r, a bare \n would otherwise survive into the token and break the RESP reply across two lines.
  • Minor: the depth check if (depth > maxMultiBulkDepth) (around line 802) is a slight off-by-one against the documented "default 32" - since depth starts at 0 for the top-level call, a payload can actually nest 33 levels deep before being rejected (0..32 all pass). Not a security concern given the huge margin versus the ~11,861-level overflow threshold, but worth a >= if the intent is exactly 32.
  • sanitizedLimit()'s once-per-JVM warning (WARNED_MISCONFIGURED_LIMITS) is a sensible tradeoff for avoiding log spam, and it's clearly documented as such.

Performance

  • Settings are read once per connection in the constructor (not on the hot per-message path), so no per-parse overhead beyond the depth counter and a couple of integer comparisons - good.
  • parseChars() still grows a StringBuilder one char at a time up to the declared bulk length; with the new 512MB default cap that's still up to ~1GB of UTF-16 char data buildable per connection if a client actually sends the bytes. This mirrors real Redis' own proto-max-bulk-len default exactly, so it's a deliberate, defensible choice and a strict improvement over the previous fully-unbounded state - just flagging that it (like real Redis) still relies on connection-count/memory limits elsewhere in the server for the "many connections each send ~512MB pre-auth" case, if that isn't already covered. Not a blocker for this PR, which is correctly scoped to the recursion/loop bugs in Redis wrapper: unbounded RESP array recursion overflows the stack pre-auth (~47 KB); array size also unvalidated #5895.

Security

  • The core issue (pre-auth stack overflow reachable by anyone who can open the port) is fixed correctly, and the fix appropriately treats a limit violation as "stream position no longer trustworthy" -> close the connection rather than trying to resync, which is the right call.
  • CRLF sanitization on the new error path prevents RESP reply-splitting via a crafted malformed-length token - a good defensive detail that was actually caught by the PR's own review process.

Test coverage

  • Good breadth: stack-overflow repro, oversized multibulk length, oversized bulk length, malformed non-numeric length, CRLF-injection in the reply, and three "is the setting actually wired, not just the default" tests. Each test also confirms the listener stays healthy afterward (a fresh Jedis connection still works), which is exactly the right thing to assert for a "close this connection, don't take down the listener" fix.
  • No test for the sanitizedLimit() fallback-with-warning path (setting a limit below the floor), but that's a minor gap in an otherwise thorough suite.

Docs / config

  • GlobalConfiguration entries follow the existing style (text blocks, "Default is N" convention) and the release notes are consistent with the code. The earlier round's orphaned-link fix looks correctly applied - the [#5588] reference now closes its own section before the new Redis content.

Overall: well-scoped, well-tested fix for a genuine pre-auth vulnerability. Only nit-level feedback above; nothing blocking.

Round 5 review noted the depth check (depth > maxMultiBulkDepth) admits one more
nesting level than documented, since depth starts at 0 for the top-level call: a
"default 32" setting actually let 33 levels through. Changed to >=, which required
raising maxMultiBulkDepth's sanitizedLimit floor from 1 to 2 (a flat command's single
argument is parsed at depth 1, so >= needs at least 2 to admit it).

Also added a regression test for sanitizedLimit's fallback-with-warning path itself
(the only untested branch in an otherwise thorough suite per the review), which
pipelines a malformed-shape command with a normal PING on the same connection to
distinguish "fell back to the default" from "used the broken configured value".
@lvca

lvca commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Round 5 addressed:

  • Off-by-one on the depth check vs. documented default — fixed. depth > maxMultiBulkDepth allowed one extra nesting level (depth starts at 0), so "default 32" actually admitted 33. Changed to >=; this required raising the depth setting's sanitizedLimit floor from 1 to 2, since a flat command's single argument is parsed at depth 1 and >= needs at least 2 to still admit it.
  • Missing test for the floor-fallback path itself — fixed, added misconfiguredDepthLimitFallsBackToDefault, which pipelines a malformed-shape command with a normal PING on the same connection to distinguish "fell back to the default (32)" from "used the broken configured value (1) verbatim."
  • parseChars() up to ~1GB per connection at the new 512MB cap — already tracked by Redis wrapper: RESP bulk strings are parsed char-by-char, mangling non-ASCII/binary payloads #5907 (filed after round 2); review explicitly notes it's a deliberate, defensible choice matching Redis' own default, not a blocker.

All 55 redisw tests pass, full project compiles.

@codacy-production

codacy-production Bot commented Aug 7, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 5 complexity

Metric Results
Complexity 5

View in Codacy

🟢 Coverage 93.75% diff coverage · -6.84% coverage variation

Metric Results
Coverage variation -6.84% coverage variation
Diff coverage 93.75% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (6bbb0e7) 152707 117904 77.21%
Head commit (d8c18a6) 185097 (+32390) 130253 (+12349) 70.37% (-6.84%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#5902) 48 45 93.75%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review: RESP array/bulk-string length bounds (fixes #5895)

Solid fix for a real pre-auth DoS: unbounded RESP array nesting could overflow the connection thread's stack, and unbounded declared lengths (*/$) could tie up a thread indefinitely. The three-commit history shows the scope grew appropriately as review caught the bulk-string case and the NumberFormatException gap. Overall this is well-structured, well-tested, and matches the existing code style closely.

Strengths

  • Depth-limit math is correct. parseNext(depth) checks depth >= maxMultiBulkDepth before consuming a byte, and the sanitizedLimit floor of 2 for maxMultiBulkDepth is exactly right: a flat single-argument command needs depth 1 to parse its argument, so a configured value of 1 would reject even PING. Good catch, and it's covered by misconfiguredDepthLimitFallsBackToDefault.
  • Exception hierarchy is deliberate and correct. RedisProtocolLimitException extends IOException (not RedisException) so it propagates cleanly through parseNext/parseChars, and is caught before the generic IOException handler in run() - necessary since Java requires the more specific catch first, and it is.
  • respErrorMessage() reuse for the new error path is correct - it strips embedded \r/\n, which matters here specifically because the malformed-length message embeds the raw client-supplied token (e.g. a non-numeric length containing a bare \n), and there's a dedicated regression test (malformedLengthReplyDoesNotEmbedRawNewline) for exactly that.
  • arraySize <= 0 short-circuit correctly preserves the pre-existing behavior for RESP2 null/empty arrays (*-1, *0) while skipping the max-length check for negative values, which is right (a negative declared length isn't the DoS vector being guarded against).
  • Good end-to-end wiring tests for all three new settings, not just the defaults.

Worth considering (non-blocking)

  1. Possible TCP RST race on the new error path. In the RedisProtocolLimitException catch block, replyToClient(value) is called immediately followed by close(). close() (via ChannelBinary/Channel) does a plain socket.close() with no SO_LINGER set. On Linux, closing a socket while there's still unread data in the receive buffer (which is exactly the situation here - the whole point is that we stopped parsing early without draining the rest of an oversized/malicious payload) can trigger an immediate RST instead of a graceful close. In practice the small -ERR ... reply is very likely already handed to the kernel send buffer by the time close() runs, so this will work reliably on loopback/low-latency links (consistent with the PR's reported 48/48 pass), but on higher-latency or congested connections there's a narrow race where the client could see a bare "connection reset" instead of the intended diagnostic message. Not a security issue (the stack overflow / thread-pinning is fixed either way), just a robustness gap in the "fail fast with a clear error" contract this PR is going for. If it matters, draining a bounded amount of remaining input (or a short SO_LINGER) before close would make delivery more reliable.
  2. parseValueUntilLF() itself has no length bound. It's used to read the very token this PR now bounds (the */$ length header) as well as simple strings (+), and it accumulates into value with no ceiling until a \r\n is seen. A client that sends a $ followed by a very long run of bytes with no CRLF (e.g. $ + megabytes of digits, never terminated) would still grow that buffer unbounded / hold the thread, before ever reaching the new maxBulkLength check - because the check only fires after parseLength successfully parses a value. This is pre-existing behavior and arguably out of scope for this PR, but since the PR is specifically about bounding attacker-controlled parsing costs in this exact code path, it might be worth a quick follow-up (e.g. reject a length line early once it exceeds a small fixed number of characters, since real length tokens are always short digit strings).
  3. Minor: WARNED_MISCONFIGURED_LIMITS warns only once per GlobalConfiguration enum value for the life of the JVM. That's a reasonable and clearly-documented tradeoff against log flooding, but worth being aware of operationally - if a setting is fixed and later misconfigured again on the same running server, there won't be a second warning.

Nits

  • None on style - the new code (single-statement ifs without braces, final usage, javadoc explaining the why) matches the surrounding conventions well.

Nice work tightening this down through the review rounds (especially catching the bulk-string case and the raw-newline-in-error-message edge case) - this closes a legitimate pre-auth DoS cleanly.

…ded-growth gap

Round 6 review noted that the new size/depth checks only fire once parseValueUntilLF()
has actually produced a token (it looks for a terminating CRLF), so a client that never
sends one - e.g. "$" followed by megabytes of digits with no \r\n - grows that buffer
unbounded and holds the thread before maxBulkLength/maxMultiBulkLength/maxMultiBulkDepth
ever get a value to check against. It's the same DoS class this PR fixes, just one level
up: the length token itself instead of the declared length.

Real RESP length/integer tokens and simple-string values are always short, so
parseValueUntilLF() now caps accumulation at a fixed 64 characters (a signed 64-bit
decimal has at most 20), throwing the same RedisProtocolLimitException as the other
checks. Regression test confirms this hangs on unpatched code (verified via
SocketTimeoutException) and is rejected cleanly with the fix.
@lvca

lvca commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Round 6 addressed:

  • parseValueUntilLF() itself unbounded — fixed, this is genuinely the same DoS class one level up: the length/integer token used to reach maxBulkLength/maxMultiBulkLength/maxMultiBulkDepth is itself read via an unbounded accumulator that only stops on a CRLF it may never receive. Added a fixed 64-character cap (real RESP length tokens and simple-string values are always short) and a regression test that hangs against unpatched code (confirmed via SocketTimeoutException) and is rejected cleanly with the fix.
  • TCP RST race on close() after the error reply — real but explicitly non-blocking; not fixing in this PR (would mean adding SO_LINGER/drain semantics unrelated to the recursion/size bugs this PR targets).
  • Once-per-JVM misconfiguration warning could miss a later re-misconfiguration — explicitly noted as a reasonable, documented tradeoff; no change.

All 56 redisw tests pass, full project compiles.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review

Solid, well-scoped security fix for #5895. The commit history shows this already went through several rounds of self-review (depth off-by-one, \r/\n sanitization in error replies, the unbounded-token-growth gap, log-spam throttling), and the result reads like it: the parsing hot path (parseNext, parseValueUntilLF, parseLength) is now bounded on every axis that mattered (recursion depth, declared array length, declared bulk length, and raw unterminated-token growth), with a consistent fail-fast-and-close story and a thorough regression suite (RedisProtocolLimitsTest) that reproduces each issue against the unpatched behavior.

A few non-blocking observations:

1. $-1\r\n (RESP null bulk string) desyncs the streamRedisNetworkExecutor.java, parseNext() around the $ branch (size = parseLength(...), parseChars(size), skipLF()). If size is negative, parseChars(size) returns "" immediately (the loop condition i < size is false right away), but skipLF() still unconditionally consumes the next two bytes expecting them to be the bulk string's trailing \r\n. For a genuine $-1\r\n there's no such trailing pair — those two bytes actually belong to whatever follows in the stream, so parsing desyncs from that point on instead of failing cleanly. This is pre-existing (same shape before this PR), and in practice RESP clients don't send $-1 as a request argument, so it's likely unreachable from real traffic — but since this PR is already hardening exactly this parsing path against adversarial input, it might be worth special-casing negative bulk length the same way the array branch already special-cases arraySize <= 0, rather than leaving one asymmetric edge case unguarded.

2. maxBulkLength's 512MB default matches Redis' proto-max-bulk-len, but the JVM memory profile isn't equivalentparseChars() builds the bulk-string body one byte at a time into a StringBuilder (UTF-16, so ~2 bytes of Java heap per wire byte, plus the buffer's own doubling-growth overhead during resize). A legitimate-looking 512MB bulk string can therefore transiently cost noticeably more than 512MB of heap per connection, and nothing here caps concurrent connections each doing this at once. Not a regression (the old code was unbounded), and the parity with Redis' own default is a reasonable choice, but it's worth a deliberate decision rather than an assumed one — either a smaller practical default for this deployment target, or an explicit note that operators sizing heap should account for the multiplier, not just the raw byte count.

3. Minor: parseChars()'s byte-at-a-time readNext() loop (each call doing a bounds check and occasional buffer refill) is fine for typical small command arguments, but is a comparatively expensive way to move up to 512MB through the parser now that the cap legitimizes that size. Not something this PR needs to fix, just flagging since "performance and lightweight on GC" is a stated project priority and large bulk strings now have an explicit, legitimate path through this code.

Nit: the depth/length constants and sanitizedLimit()'s floor reasoning are documented thoroughly in comments/Javadoc — genuinely readable, no notes there.

Overall: the core stack-overflow and unbounded-loop DoS vectors are closed correctly, the fix mirrors the wire protocol's own documented limits, error handling is consistent (single-line RESP replies, no raw exception messages leaking), and test coverage is thorough (including the trickier wiring/fallback/sanitization cases). The two substantive points above (#1 and #2) are worth a look but neither blocks this as currently a solid, defensive fix for a real pre-auth DoS.

@robfrank
robfrank merged commit 455e878 into main Aug 7, 2026
23 of 26 checks passed
@lvca

lvca commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Full CI run is complete. Summary for review:

All 7 automated review rounds addressed (see commit history and inline replies above) — the fix now bounds RESP array nesting depth, declared array/bulk-string length, and the raw length-token accumulator itself, with consistent fail-closed error handling (CRLF-sanitized single-line RESP replies) and 56 passing regression tests in RedisProtocolLimitsTest.

Two out-of-scope findings filed as follow-ups, not fixed here to keep this PR focused on the #5895 DoS class:

CI status: all checks green except three pre-existing failures, none touching redisw/ or this PR's diff:

  • integration-tests: PostgresWJdbcIT.parsingErrorMgmt (Postgres module)
  • unit-tests: 4 LSMVectorIndexRebuildTest/LSMVectorIndexRecoveryTest timing errors (~120s each) + CreatePropertyStatementExecutionTest.createHiddenProperty (SQL DDL)
  • ha-integration-tests: Issue5410AbandonedTicketReleaseIT + Issue5569SlotMergeDeleteRaftIT (HA/Raft)

Ready for your review, Luca.

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.58333% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.00%. Comparing base (6bbb0e7) to head (d8c18a6).
⚠️ Report is 16 commits behind head on main.

Files with missing lines Patch % Lines
.../java/com/arcadedb/redis/RedisNetworkExecutor.java 87.50% 3 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5902      +/-   ##
==========================================
- Coverage   68.39%   68.00%   -0.40%     
==========================================
  Files        1794     1795       +1     
  Lines      152707   152754      +47     
  Branches    32366    32376      +10     
==========================================
- Hits       104450   103878     -572     
- Misses      34843    35498     +655     
+ Partials    13414    13378      -36     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lvca
lvca deleted the fix/5895-redis-resp-recursion-limit branch August 7, 2026 14:57
@lvca lvca self-assigned this Aug 7, 2026
@lvca lvca added this to the 26.9.1 milestone Aug 7, 2026
lvca added a commit that referenced this pull request Aug 9, 2026
* fix: RESP parser correctness and idle-connection hardening (#5907, #5911, #5912)

Three follow-ups from the #5895/#5902 Redis wrapper security review:

- #5907: parseChars() widened each byte to a UTF-16 char via (char) b
  instead of decoding it, sign-extending any byte >= 0x80 and mangling
  non-ASCII/binary bulk strings. Now reads into a right-sized byte[]
  and decodes once with the same charset replyToClient() encodes with.
  respondValue()'s bulk-length header had the mirror-image bug (String
  char count instead of UTF-8 byte count), which was previously masked
  because the old parseChars happened to produce 1 char per input byte;
  fixed alongside since a wrong header on the way out desyncs a client
  exactly like a truncated bulk string would.

- #5911: the $ (bulk string) branch always called skipLF() after
  parseChars(), but a RESP2 null bulk string ($-1\r\n) has no trailing
  CRLF - it's a complete, self-terminated token. skipLF() then consumed
  the next token's leading byte, desyncing the parser. Mirrors the
  existing null/empty-array short-circuit. This makes a null command[0]
  reachable, which exposed an unguarded cmd.getClass() NPE outside any
  catch block in executeCommand(); guarded it.

- #5912: the Redis listener never configured a socket read timeout, so
  an unauthenticated client that opens a connection and never sends
  anything (or trickles bytes arbitrarily slowly) held the connection
  thread open indefinitely - a slow-connection-exhaustion vector
  distinct from the unbounded-declared-size DoS #5902 already fixes.
  Now sets NETWORK_SOCKET_TIMEOUT as a pre-auth read timeout, mirroring
  BoltNetworkExecutor's handshake-timeout pattern, and lifts it back to
  infinite once authentication succeeds (an authenticated RESP
  connection is expected to sit idle between commands).

Regression tests in RedisRespCorrectnessTest cover all three: a
multi-byte UTF-8 SET/GET round trip through both Jedis and raw RESP,
a null-bulk-string argument followed by AUTH/PING on the same
connection to prove the parser stays in sync, an idle unauthenticated
connection getting closed, and an idle authenticated connection NOT
getting closed.

* docs(#5911): clarify why every negative bulk length is treated as null

Addresses a review comment on #5965: the RESP2 spec only defines -1
as the null bulk string, but rejecting other negative sizes would need
its own protocol-error branch for no behavioral benefit over treating
them the same as the one negative value that is defined.

* fix(#5911): reject a null bulk-string command argument cleanly

Code review on #5965 pointed out that the #5911 fix makes $-1 reachable
as any array element, not just the command name - e.g. "SET key $-1"
or "GET $-1". defaultBucket is a ConcurrentHashMap, whose put/get
reject a null key or value outright, so those reached a raw
NullPointerException deep inside setVariable/getVariable instead of
one clear reply (still caught by executeCommand's generic catch, so
not a crash/hang, just an unhelpful message).

Reject any null argument uniformly, before dispatch, with a clean
"-ERR Protocol error: unexpected null bulk string argument" instead of
letting whichever handler touches it first fail differently depending
on which map/method it happens to call.

Also tags the two new idle-timeout tests @tag("slow") per CLAUDE.md
(they wait on a lowered NETWORK_SOCKET_TIMEOUT plus a 1.5s sleep),
matching the existing Issue5470BatchStreamStallIT precedent.

* fix(#5912): re-arm the pre-auth idle timeout after a failed re-AUTH

Code review on #5965 caught a state-machine gap: markAuthenticated()
lifts the socket's read timeout to infinite on success, but a
connection that authenticates once and then fails a *subsequent*
AUTH/HELLO re-authentication attempt had authenticatedUser reset to
null without the timeout being re-armed - leaving the connection
logically unauthenticated but with an infinite read timeout, breaking
the "unauthenticated implies bounded timeout" invariant #5912 relies
on.

Introduces markUnauthenticated(), used everywhere authenticatedUser
was being set back to null, which resets the socket to the same
bounded NETWORK_SOCKET_TIMEOUT the constructor arms pre-auth.

* docs/test: address remaining review notes on #5965

Third code-review round on #5965 came back with no blocking issues;
addresses the three cheap, low-priority points it raised:

- Documents that the null-argument guard in executeCommand() is
  deliberately depth-1-only (a $-1 nested inside a multibulk argument
  still surfaces as a raw ClassCastException, an obscure and
  already-non-crashing edge case no handler exercises today).
- Adds a TODO(perf) on respondValue()'s double UTF-8 encode (measure
  bytes for the header, then replyToClient() re-encodes the same text
  again), explaining why fixing it cleanly needs a larger change than
  this correctness fix warrants.
- Adds a regression test for a non-(-1) negative bulk length ($-5),
  locking in that "every negative size is null" is a tested decision.

* test: close the Jedis client in bulkStringWithMultibyteUtf8RoundTripsExactly

Fourth code-review round on #5965 caught that this test was the only
one in the file not using try-with-resources, leaking the client
connection (and the server-side RedisNetworkExecutor thread, which
now sits with an infinite read timeout post-auth per this PR's own
fix). No functional bug, just inconsistent with the rest of the file.

* docs: clarify swallowed SocketException and correct the Bolt comparison

Fifth code-review round on #5965 came back with no blocking issues;
addresses its two documentation notes:

- Explains why swallowing SocketException in markAuthenticated()/
  markUnauthenticated() is safe: setSoTimeout() only throws on an
  already-broken/closed socket, so there's no live connection left
  for the timeout state to matter on.
- Corrects the constructor comment's "mirrors BoltNetworkExecutor"
  framing: Bolt only bounds its TLS-detection window, not its own
  subsequent HELLO/LOGON auth phase, so this actually goes further -
  it bounds the entire Redis pre-auth phase through AUTH/HELLO itself.
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 wrapper: unbounded RESP array recursion overflows the stack pre-auth (~47 KB); array size also unvalidated

2 participants