Skip to content

Commit a3adfbc

Browse files
fix(cypher): bound OPTIONAL fallback in expand_pattern_rels to prevent heap overflow (#627)
Signed-off-by: SEPURI-SAI-KRISHNA <saik20533@gmail.com>
1 parent 7d6cdb2 commit a3adfbc

2 files changed

Lines changed: 66 additions & 2 deletions

File tree

src/cypher/cypher.c

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3221,7 +3221,14 @@ static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_
32213221

32223222
bool is_variable_length = (rel->min_hops != SKIP_ONE || rel->max_hops != SKIP_ONE);
32233223

3224-
size_t alloc_n = (size_t)*bind_cap * (size_t)CYP_GROWTH_10 + SKIP_ONE;
3224+
/* Room for up to max_new (bind_cap*10) expanded rows PLUS one OPTIONAL
3225+
* fallback row per source binding (the match_count==0 case below). The
3226+
* two are mutually exclusive per source, but a saturating hub can drive
3227+
* the expanded count to max_new while later sources still take the
3228+
* OPTIONAL path — so the buffer must hold both. The old `bind_cap*10 + 1`
3229+
* left room for a single OPTIONAL row after a saturated expansion and
3230+
* overflowed once a second one followed (heap OOB write). */
3231+
size_t alloc_n = (size_t)*bind_cap * (size_t)CYP_GROWTH_10 + (size_t)*bind_count + SKIP_ONE;
32253232
binding_t *new_bindings = malloc(alloc_n * sizeof(binding_t));
32263233
if (!new_bindings) {
32273234
return; /* OOM: leave existing bindings untouched rather than corrupt */
@@ -3250,7 +3257,7 @@ static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_
32503257
}
32513258

32523259
/* OPTIONAL MATCH: keep binding with empty target if no matches */
3253-
if (is_optional && match_count == 0) {
3260+
if (is_optional && match_count == 0 && (size_t)new_count < alloc_n) {
32543261
binding_t nb = {0};
32553262
binding_copy(&nb, b);
32563263
/* Don't set to_var — it remains unbound; projection returns "" */

tests/test_cypher.c

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,62 @@ TEST(cypher_exec_match_all_functions) {
489489
PASS();
490490
}
491491

492+
/* Regression: expand_pattern_rels sized its output buffer as bind_cap*10 + 1 —
493+
* room for the bounded expansion (max_new = bind_cap*10) plus a SINGLE OPTIONAL
494+
* fallback row. When one source saturates the expansion to max_new and two or
495+
* more later sources take the OPTIONAL (no-match) path, the second fallback
496+
* write ran past the allocation (ASan: heap-buffer-overflow). Query text is
497+
* agent-controlled via the MCP query tool. */
498+
TEST(cypher_exec_optional_rel_saturated_no_overflow) {
499+
cbm_store_t *s = cbm_store_open_memory();
500+
cbm_store_upsert_project(s, "test", "/tmp/test");
501+
502+
/* 1 hub + 20 leaf Function nodes → bind_cap = 21, max_new = 210,
503+
* old alloc = 211 slots. The hub is inserted first so it is expanded before
504+
* the leaves; it saturates the expansion, then each leaf adds an OPTIONAL
505+
* fallback row, pushing new_count well past the allocation. */
506+
cbm_node_t hub = {
507+
.project = "test", .label = "Function", .name = "hub", .qualified_name = "test.hub"};
508+
int64_t hub_id = cbm_store_upsert_node(s, &hub);
509+
for (int i = 0; i < 20; i++) {
510+
char nm[32];
511+
char qn[48];
512+
snprintf(nm, sizeof(nm), "leaf%02d", i);
513+
snprintf(qn, sizeof(qn), "test.leaf%02d", i);
514+
cbm_node_t leaf = {
515+
.project = "test", .label = "Function", .name = nm, .qualified_name = qn};
516+
cbm_store_upsert_node(s, &leaf);
517+
}
518+
519+
/* Give the hub 300 CALLS edges (> max_new = 210) so its expansion saturates
520+
* the buffer; targets are non-Function so they don't inflate bind_cap. */
521+
for (int i = 0; i < 300; i++) {
522+
char nm[32];
523+
char qn[48];
524+
snprintf(nm, sizeof(nm), "callee%d", i);
525+
snprintf(qn, sizeof(qn), "test.callee%d", i);
526+
cbm_node_t callee = {.project = "test", .label = "Var", .name = nm, .qualified_name = qn};
527+
int64_t cid = cbm_store_upsert_node(s, &callee);
528+
cbm_edge_t e = {.project = "test", .source_id = hub_id, .target_id = cid, .type = "CALLS"};
529+
cbm_store_insert_edge(s, &e);
530+
}
531+
532+
/* max_rows must be below the Function count (21) so bind_cap tracks
533+
* scan_count (21) rather than the 100000 result ceiling — the same regime a
534+
* large repo (> ceiling functions) or an agent-supplied small limit hits.
535+
* bind_cap = 21 → max_new = 210, old alloc = 211 slots. */
536+
cbm_cypher_result_t r = {0};
537+
int rc = cbm_cypher_execute(
538+
s, "MATCH (a:Function) OPTIONAL MATCH (a)-[:CALLS]->(b) RETURN a.name", "test", 5, &r);
539+
ASSERT_EQ(rc, 0);
540+
/* hub expands (capped at max_new), each leaf keeps one unbound row. */
541+
ASSERT_GT(r.row_count, 0);
542+
543+
cbm_cypher_result_free(&r);
544+
cbm_store_close(s);
545+
PASS();
546+
}
547+
492548
TEST(cypher_exec_where_eq) {
493549
cbm_store_t *s = setup_cypher_store();
494550
cbm_cypher_result_t r = {0};
@@ -3080,6 +3136,7 @@ SUITE(cypher) {
30803136
RUN_TEST(cypher_exec_deadline_aborts_runaway_query_issue601);
30813137
RUN_TEST(cypher_exec_deadline_allows_normal_query_issue601);
30823138
RUN_TEST(cypher_exec_match_all_functions);
3139+
RUN_TEST(cypher_exec_optional_rel_saturated_no_overflow);
30833140
RUN_TEST(cypher_issue240_labels_function);
30843141
RUN_TEST(cypher_issue237_distinct_order_limit);
30853142
RUN_TEST(cypher_issue873_distinct_order_limit_dedupes_before_limit);

0 commit comments

Comments
 (0)