Validate numkeys in SINTERCARD, ZINTERSTORE and LMPOP/BLMPOP - #2031
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens RESP object command handlers against invalid numkeys inputs that previously caused unhandled exceptions and connection teardown (no error reply written), bringing behavior in line with Redis by returning explicit error replies while keeping the session alive.
Changes:
- Add
numkeys < 1validation toSINTERCARD,ZINTERSTORE, andLMPOP/BLMPOPhandlers to prevent negative/zero values from reaching slicing/allocation paths. - Add an argument-count bound check for
ZINTERSTOREto prevent slicing beyond provided keys. - Add LightClient-based regression tests that assert (a) correct error replies and (b) the connection remains usable (reply stream stays aligned via a pipelined
PING).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/standalone/Garnet.test.collections/RespSortedSetTests.cs | Adds LightClient test coverage for invalid ZINTERSTORE numkeys scenarios and ensures the session stays open. |
| test/standalone/Garnet.test.collections/RespSetTest.cs | Adds LightClient assertions for SINTERCARD invalid numkeys cases that previously dropped the connection or returned non-error replies. |
| test/standalone/Garnet.test.collections/RespListTests.cs | Adds LightClient assertions for LMPOP/BLMPOP invalid numkeys cases and validates session survivability. |
| libs/server/Resp/Objects/SortedSetCommands.cs | Adds ZINTERSTORE numkeys lower-bound and argument-count validation to avoid handler exceptions and improve error behavior. |
| libs/server/Resp/Objects/SetCommands.cs | Adds SINTERCARD numkeys < 1 guard to avoid out-of-range slicing and connection teardown. |
| libs/server/Resp/Objects/ListCommands.cs | Adds numkeys < 1 validation in LMPOP and BLMPOP handlers to avoid negative-length allocations and align error replies with Redis. |
7df97ca to
e3784f0
Compare
|
The single red check here is The same job fails on
Both failures in this run are transport-level, not assertion diffs. Both failing tests pass in sibling fixtures in this same run. The diff can't reach these tests. It only adds lower-bound What I did not prove. I could not reproduce the CI failure locally. This machine has no net8.0 runtime (10.0.x only) and the red job is net8.0/Debug/ubuntu, so nothing I ran locally clears that job; the vectorset suite doesn't run here at all, on I don't have permission to re-run the job. A re-run of just that one job should settle it. I'd rather not push an empty commit, since that re-triggers the whole matrix and the flake looks frequent enough that it could recur anyway. Happy to open a separate issue for the two flaky tests if that's useful. |
SINTERCARD, ZINTERSTORE, LMPOP and BLMPOP read numkeys and used it as a span/array length without a lower-bound check. A negative value threw out of the command handler and dropped the session with no reply: SINTERCARD -1 key1 ArgumentOutOfRangeException ZINTERSTORE dest -1 zset1 ArgumentOutOfRangeException LMPOP -1 a b OverflowException BLMPOP 0 -1 a b OverflowException A zero value did not throw, but answered something other than an error: 'SINTERCARD 0 LIMIT 0' replied :0, 'LMPOP 0 LEFT COUNT 5' replied a null array, and 'ZINTERSTORE dest 0 zset1' replied a syntax error rather than the at-least-one-key error its ZUNIONSTORE sibling gives. ZINTERSTORE also lacked the argument-count guard ZUNIONSTORE has, so 'ZINTERSTORE dest 2 zset1' sliced past the end of the parse state. Each handler now rejects numkeys < 1 with the message already used in that handler: 'numkeys should be greater than 0' for SINTERCARD, which is what the existing numkeys check five lines below emits and what Redis's sinterCardCommand emits; GenericErrAtLeastOneKey for ZINTERSTORE, as in ZINTERCARD and ZUNIONSTORE; and the existing folded TryGetInt condition for LMPOP and BLMPOP, leaving their current wording untouched. The new ZINTERSTORE argument-count guard is written as 'parseState.Count - 2 < nKeys' rather than 'parseState.Count < nKeys + 2'. The forms are equivalent wherever the addition does not overflow, but nKeys >= 2147483646 wraps it negative and slips the guard, leaving 'ZINTERSTORE dest 2147483647 zset1' still able to reach the slice. parseState.Count is at least 3 at that point, so subtracting cannot underflow.
e3784f0 to
376d948
Compare
Symptom
Against a server built from
main(8b329e3), three commands close the client connection instead of replying:Each throws out of its command handler, so the session is torn down and nothing at all is written to the socket. A pipelining client does not see an error, it sees its remaining replies shift by one. Expected in all three cases is an error reply on a connection that stays open.
Three more inputs do not throw but answer something other than an error:
mainSINTERCARD 0 LIMIT 0:0-ERR numkeys should be greater than 0LMPOP 0 LEFT COUNT 5*-1-ERR numkeys should be greater than 0ZINTERSTORE dest 0 zset1-ERR syntax error-ERR at least 1 input key is needed for 'ZINTERSTORE' commandAnd
ZINTERSTORE dest 2 zset1— anumkeyslarger than the number of keys actually sent — also drops the connection, whereZUNIONSTORE dest 2 zset1already replies-ERR syntax error.Root cause
Each handler parses
numkeysand uses it directly as a span or array length with no lower bound:libs/server/Resp/Objects/SetCommands.cs:178—parseState.Parameters.Slice(1, nKeys)libs/server/Resp/Objects/SortedSetCommands.cs:1248—parseState.Parameters.Slice(2, nKeys)libs/server/Resp/Objects/ListCommands.cs:208—new PinnedSpanByte[numKeys]libs/server/Resp/Objects/ListCommands.cs:878—new byte[numKeys][]The arity checks above those lines compare against
numkeysitself and pass vacuously for negative values: SINTERCARD's(parseState.Count - 1) < nKeysis false for any negativenKeys, and LMPOP'sparseState.Count != numKeys + 2 && parseState.Count != numKeys + 4happens to match on the shapes above (LMPOP -1 a bhasCount == 3 == numKeys + 4).Slicethen throwsArgumentOutOfRangeException,new T[negative]throwsOverflowException.ZINTERSTORE additionally has no check that the argument count covers
numkeys, which is what letsZINTERSTORE dest 2 zset1slice past the end of the parse state.Fix
Add the missing
numkeys < 1guard to each handler, reusing the error string that handler already emits:ERR numkeys should be greater than 0, the same string as the(parseState.Count - 1) < nKeyscheck five lines below it, and the same string Redis'ssinterCardCommandemits.GenericErrAtLeastOneKey, plus the argument-count check itsSortedSetUnionStoresibling carries atlibs/server/Resp/Objects/SortedSetCommands.cs:1464-1471. The count check is written asparseState.Count - 2 < nKeysrather thanparseState.Count < nKeys + 2: the two are equivalent for every value that does not overflow, but the addition form wraps negative fornumkeys>= 2147483646 and lets it through.parseState.Countis at least 3 here, so the subtraction cannot underflow.TryGetIntcondition, the same shape used for theCOUNTguard in these two handlers, so each keeps the wording it already uses (LMPOPERR numkeys should be greater than 0, BMPOPERR Parameter `numkeys` should be greater than 0). No existing message is reworded.Happy paths are unchanged:
SINTERCARD 1 s,SINTERCARD 1 s LIMIT 2,ZINTERSTORE d 1 zandLMPOP 1 L LEFTbehave as before, and every existingCanDoZInterStorecase — includingZINTERSTORE dest 3 zset1 zset2 nx— still passes both new ZINTERSTORE guards.Tests
Eleven new assertions, all of which fail on
maintoday. None is a green-state regression guard.CanDoSinterCardThowsErrors(RespSetTest.cs)SINTERCARD -1 key1— connection dropped onmainSINTERCARD 0 LIMIT 0— replies:0onmainCanDoRejectBadLMPOPCommand(RespListTests.cs)LMPOP -1 a b— connection dropped onmainLMPOP 0 LEFT COUNT 5— replies*-1onmainBLMPOP 0 -1 a b— connection dropped onmainCanDoZInterStoreWithBadNumKeysLC(RespSortedSetTests.cs, new)ZINTERSTORE dest -1 zset1— connection dropped onmainZINTERSTORE dest 0 zset1— replies-ERR syntax erroronmainZINTERSTORE dest 2 zset1— connection dropped onmainZINTERSTORE dest 3 zset1 zset2— connection dropped onmainZINTERSTORE dest 2147483647 zset1— connection dropped onmainZINTERSTORE dest 2147483646 zset1— connection dropped onmainThe last two are the overflow cases, added after review feedback on this PR: with the addition form of
the count check they slipped past the guard and still reached the slice. 2147483645 is the largest
numkeysthat does not overflow and is handled correctly either way.These use
LightClientRequestrather than StackExchange.Redis on purpose: the pre-fix failure is an absent reply on a dropped socket, which a StackExchange.Redis assertion cannot tell apart from a normal error. Each command is pipelined with a followingPINGso the assertion also proves the session survives and the reply stream stays aligned.Out of scope
The additive form of this argument-count check predates this PR and appears in other handlers in the
same family on
main. This PR only changes the check it introduces; auditing the rest belongs in aseparate change, which I am happy to prepare. SINTERCARD and LMPOP/BLMPOP are unaffected either way —
their bound checks do not add to
numkeys.