A truncated FT.CREATE kills the whole server process
One malformed command from any client aborts moon. No auth, no privileged
context, no large payload — a single short line.
$ redis-cli -p 6487 FT.CREATE tidx ON HASH PREFIX 1 d: SCHEMA v VECTOR HNSW
Error: Server closed the connection
$ redis-cli -p 6487 PING
Could not connect to Redis at 127.0.0.1:6487: Connection refused
Server log:
thread 'shard-0' panicked at src/command/vector_search/ft_create.rs:550:39:
index out of bounds: the len is 10 but the index is 10
FATAL: thread 'shard-0' panicked; aborting the whole process rather than
serving with a dead shard or a dead cluster control plane
The panic is a shard-thread panic, and moon's policy deliberately escalates
that to a process abort — so the blast radius is the entire server, every
database, every other client, not just the offending connection.
Cause
parse_vector_field_params (src/command/vector_search/ft_create.rs:542)
bounds-checks the algorithm keyword, then advances past it and reads the
parameter count with no second check:
if *pos >= args.len() || !matches_keyword(&args[*pos], b"HNSW") {
return Err(Frame::Error(Bytes::from_static(b"ERR expected HNSW algorithm")));
}
*pos += 1;
let num_params = match parse_u32(&args[*pos]) { // <-- line 550, no bounds check
When HNSW is the last argument, *pos becomes args.len() and the index
panics.
Scope: exactly one reachable site, not a family
My first assumption was that every *pos += 1; args[*pos] pair in this
function was a separate crash — a dozen of them. Measured, and that is
wrong. The parameter loop guards both ends:
while *pos + 1 < param_end && *pos + 1 < args.len() {
so after the key is consumed, the value read is in bounds. Probed one server
per case, each freshly spawned and liveness-checked by listener PID:
| Command (truncated at the end) |
Result |
... VECTOR HNSW |
DEAD — ft_create.rs:550:39 |
... VECTOR HNSW 6 TYPE |
alive |
... VECTOR HNSW 6 TYPE FLOAT32 DIM |
alive |
... VECTOR HNSW 6 TYPE FLOAT32 DIM 4 DISTANCE_METRIC |
alive |
... VECTOR HNSW 6 TYPE FLOAT32 DIM 4 M |
alive |
| well-formed control |
alive |
Line 550 is the one unguarded read.
Present on main
src/command/vector_search/ is byte-identical between main and the branch
this was found on (git diff main --stat -- src/command/vector_search/ is
empty), so this is not branch-local. The ERR expected HNSW algorithm string
above it has been in the file since #27.
No fuzz coverage for this parser
There are 19 targets in fuzz/fuzz_targets/ and none of them reach
FT.CREATE argument parsing. CLAUDE.md requires a fuzz target for any new
parser or decoder; the vector schema parser has never had one, which is why a
one-line truncation survived this long.
Suggested fix
The bounds check, returning the error the very next arm already uses for an
unparseable count so the two truncation shapes agree:
*pos += 1;
if *pos >= args.len() {
return Err(Frame::Error(Bytes::from_static(b"ERR invalid param count")));
}
Plus a fuzz target over FT.CREATE argument vectors, listed in both
matrices in .github/workflows/fuzz.yml (an unlisted target never runs —
moon#576).
Note there is no redis oracle to match here: the redis-server 8.6.1 on this
host has no query engine, so FT.CREATE is unknown command there. The error
string is moon's own to choose.
How this surfaced
Reading ft_create.rs while scoping #679(c), which reports that the harness's
FT.CREATE ... VECTOR FLAT row has never passed.
A truncated
FT.CREATEkills the whole server processOne malformed command from any client aborts moon. No auth, no privileged
context, no large payload — a single short line.
Server log:
The panic is a shard-thread panic, and moon's policy deliberately escalates
that to a process abort — so the blast radius is the entire server, every
database, every other client, not just the offending connection.
Cause
parse_vector_field_params(src/command/vector_search/ft_create.rs:542)bounds-checks the algorithm keyword, then advances past it and reads the
parameter count with no second check:
When
HNSWis the last argument,*posbecomesargs.len()and the indexpanics.
Scope: exactly one reachable site, not a family
My first assumption was that every
*pos += 1; args[*pos]pair in thisfunction was a separate crash — a dozen of them. Measured, and that is
wrong. The parameter loop guards both ends:
so after the key is consumed, the value read is in bounds. Probed one server
per case, each freshly spawned and liveness-checked by listener PID:
... VECTOR HNSWft_create.rs:550:39... VECTOR HNSW 6 TYPE... VECTOR HNSW 6 TYPE FLOAT32 DIM... VECTOR HNSW 6 TYPE FLOAT32 DIM 4 DISTANCE_METRIC... VECTOR HNSW 6 TYPE FLOAT32 DIM 4 MLine 550 is the one unguarded read.
Present on
mainsrc/command/vector_search/is byte-identical betweenmainand the branchthis was found on (
git diff main --stat -- src/command/vector_search/isempty), so this is not branch-local. The
ERR expected HNSW algorithmstringabove it has been in the file since #27.
No fuzz coverage for this parser
There are 19 targets in
fuzz/fuzz_targets/and none of them reachFT.CREATEargument parsing. CLAUDE.md requires a fuzz target for any newparser or decoder; the vector schema parser has never had one, which is why a
one-line truncation survived this long.
Suggested fix
The bounds check, returning the error the very next arm already uses for an
unparseable count so the two truncation shapes agree:
Plus a fuzz target over
FT.CREATEargument vectors, listed in bothmatrices in
.github/workflows/fuzz.yml(an unlisted target never runs —moon#576).
Note there is no redis oracle to match here: the
redis-server8.6.1 on thishost has no query engine, so
FT.CREATEisunknown commandthere. The errorstring is moon's own to choose.
How this surfaced
Reading
ft_create.rswhile scoping #679(c), which reports that the harness'sFT.CREATE ... VECTOR FLATrow has never passed.