fix: cap client-driven traversal depth (var-length Cypher + MCP tools)

Explicit variable-length Cypher bounds ([:CALLS*1..N]) and the client
`depth` on trace_call_path / detect_changes were passed straight into
cbm_store_bfs, so an arbitrarily large value drove an unbounded traversal
(a DoS on cyclic graphs). The MCP_MAX_DEPTH ceiling was defined but never
applied, and CYP_MAX_DEPTH capped only the unbounded var-length forms.

Add env-configurable ceilings cbm_cypher_max_depth() (CBM_CYPHER_MAX_DEPTH,
default 10) and cbm_mcp_max_depth() (CBM_MCP_MAX_DEPTH, default 15), clamp
both entry points to them, and WARN on clamp -- never a silent truncation.
Remove the now-dead CYP_MAX_DEPTH / MCP_MAX_DEPTH constants.

Reproduce-first: cypher_exec_var_length_explicit_bound_capped (12 -> 10 rows)
and tool_trace_call_path_depth_clamped (depth 1000 -> hop 15).

Closes #887.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-07-05 16:13:00 +02:00
1 parent 16cf03fa74
commit 98e98c3208
6 files changed
+204 -3

No files matched your search

+16 -2
View File
@@ -8,6 +8,8 @@
#include "cypher/cypher.h"
#include "store/store.h"
#include "foundation/platform.h"
#include "foundation/limits.h"
#include "foundation/log.h"
enum {
CYP_BUF_16 = 16,
@@ -22,7 +24,6 @@ enum {
CYP_MAX_VARS = 16, /* max Cypher variables in a query */
CYP_MAX_EDGE_VARS = 8, /* max edge variables */
CYP_GROWTH_10 = 10, /* binding growth factor */
CYP_MAX_DEPTH = 10, /* max variable-length path depth */
CYP_CHAR_IDX1 = 1, /* second character index (e.g. op[1]) */
CYP_EBUF_MASK = 7,
CYP_NODE_COLS = 4, /* columns per node var: name, qn, label, file */
@@ -2886,7 +2887,20 @@ static void expand_var_length(cbm_store_t *store, cbm_rel_pattern_t *rel,
cbm_node_pattern_t *target_node, binding_t *b, cbm_node_t *src,
const char *to_var, binding_t *new_bindings, int *new_count,
int max_new, int *match_count) {
int max_depth = rel->max_hops > 0 ? rel->max_hops : CYP_MAX_DEPTH;
/* Clamp BOTH the explicit (`*1..N`) and unbounded (`*`, `*..m`) forms to the
* engine ceiling: an explicit N above the cap was previously honoured
* verbatim, driving cbm_store_bfs to an unbounded hop count (#887). WARN on
* clamp — never a silent truncation. */
int depth_cap = cbm_cypher_max_depth();
int max_depth = rel->max_hops > 0 ? rel->max_hops : depth_cap;
if (max_depth > depth_cap) {
char req_buf[16];
char cap_buf[16];
snprintf(req_buf, sizeof(req_buf), "%d", max_depth);
snprintf(cap_buf, sizeof(cap_buf), "%d", depth_cap);
cbm_log_warn("cypher.depth_capped", "requested", req_buf, "cap", cap_buf);
max_depth = depth_cap;
}
cbm_traverse_result_t tr = {0};
const char *dir = rel->direction ? rel->direction : "outbound";
cbm_store_bfs(store, src->id, dir, rel->types, rel->type_count, max_depth, CBM_PERCENT, &tr);
+30
View File
@@ -4,6 +4,7 @@
#include "foundation/limits.h"
#include <errno.h>
#include <limits.h>
#include <stdlib.h>
long cbm_max_file_bytes(void) {
@@ -24,3 +25,32 @@ long cbm_max_file_bytes(void) {
}
return default_cap;
}
/* Shared env-int parser: a positive integer in [1, INT_MAX], else the fallback.
* Read fresh each call (see cbm_max_file_bytes rationale — cheap, test-friendly,
* no stale memoized copy across runs). */
static int env_positive_int(const char *name, int fallback) {
const char *raw = getenv(name);
if (raw && raw[0]) {
errno = 0;
char *end = NULL;
long v = strtol(raw, &end, 10);
if (errno == 0 && end != raw && *end == '\0' && v > 0 && v <= INT_MAX) {
return (int)v;
}
/* Unparseable / non-positive / out-of-range → safe default. */
}
return fallback;
}
int cbm_cypher_max_depth(void) {
/* 10 — generous for a code call/def graph; an explicit `*1..N` above this is
* WARN-capped, never an unbounded (cyclic-graph DoS) traversal. */
return env_positive_int("CBM_CYPHER_MAX_DEPTH", 10);
}
int cbm_mcp_max_depth(void) {
/* 15 — ceiling for client-driven MCP graph traversals (trace_call_path,
* detect_changes); the caller's `depth` is WARN-clamped to this. */
return env_positive_int("CBM_MCP_MAX_DEPTH", 15);
}
+13
View File
@@ -30,4 +30,17 @@ typedef enum {
* leaking across runs. */
long cbm_max_file_bytes(void);
/* Maximum variable-length path depth for the Cypher engine (the `*min..max`
* hop ceiling). BOTH the explicit (`*1..N`) and unbounded (`*`, `*..m`) forms
* are clamped to this, so `[:CALLS*1..1000000]` degrades to a WARN-and-cap
* rather than an unbounded (cyclic-graph DoS) traversal. Override with
* CBM_CYPHER_MAX_DEPTH (a positive integer). Default 10. */
int cbm_cypher_max_depth(void);
/* Maximum traversal depth for client-driven MCP graph tools (trace_call_path,
* detect_changes): the client `depth` argument is WARN-clamped to this so an
* arbitrarily large value cannot drive an unbounded BFS over the shared store.
* Override with CBM_MCP_MAX_DEPTH (a positive integer). Default 15. */
int cbm_mcp_max_depth(void);
#endif /* CBM_LIMITS_H */
+19 -1
View File
@@ -14,7 +14,6 @@ enum {
MCP_TIMEOUT_MS = 1000,
MCP_HALF_SEC_US = 500000,
MCP_MAX_ROWS = 100,
MCP_MAX_DEPTH = 15,
MCP_COL_2 = 2,
MCP_COL_3 = 3,
MCP_COL_4 = 4,
@@ -54,6 +53,7 @@ enum {
#include "foundation/compat_fs.h"
#include "foundation/compat_thread.h"
#include "foundation/log.h"
#include "foundation/limits.h"
#include "mcp/index_supervisor.h"
#include "foundation/str_util.h"
#include "foundation/dump_verify.h"
@@ -2933,6 +2933,22 @@ static void bfs_union_same_name(cbm_store_t *store, const cbm_node_t *nodes, int
}
}
/* Clamp a client-supplied traversal depth to the MCP ceiling (cbm_mcp_max_depth),
* WARN-logging when it does so — never a silent truncation (#887). An unclamped
* `depth` would drive the shared cbm_store_bfs to an arbitrary hop count. */
static int clamp_mcp_depth(int depth, const char *tool) {
int cap = cbm_mcp_max_depth();
if (depth > cap) {
char req_buf[16];
char cap_buf[16];
snprintf(req_buf, sizeof(req_buf), "%d", depth);
snprintf(cap_buf, sizeof(cap_buf), "%d", cap);
cbm_log_warn("mcp.depth_capped", "tool", tool, "requested", req_buf, "cap", cap_buf);
return cap;
}
return depth;
}
static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
char *func_name = cbm_mcp_get_string_arg(args, "function_name");
char *project = get_project_arg(args);
@@ -2941,6 +2957,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
char *mode = cbm_mcp_get_string_arg(args, "mode");
char *param_name = cbm_mcp_get_string_arg(args, "parameter_name");
int depth = cbm_mcp_get_int_arg(args, "depth", MCP_DEFAULT_DEPTH);
depth = clamp_mcp_depth(depth, "trace_call_path");
bool risk_labels = cbm_mcp_get_bool_arg(args, "risk_labels");
bool include_tests = cbm_mcp_get_bool_arg(args, "include_tests");
@@ -5201,6 +5218,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
char *since = cbm_mcp_get_string_arg(args, "since");
char *scope = cbm_mcp_get_string_arg(args, "scope");
int depth = cbm_mcp_get_int_arg(args, "depth", MCP_DEFAULT_BFS_DEPTH);
depth = clamp_mcp_depth(depth, "detect_changes");
/* scope: "files" = just changed files, "symbols" = files + symbols (default) */
bool want_symbols = !scope || strcmp(scope, "symbols") == 0 || strcmp(scope, "impact") == 0;
+69
View File
@@ -7,6 +7,7 @@
#include "test_framework.h"
#include <cypher/cypher.h>
#include <store/store.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
@@ -834,6 +835,73 @@ TEST(cypher_exec_variable_length) {
PASS();
}
/* Reproduce-first (#887): an EXPLICIT variable-length upper bound must still be
* capped at the engine ceiling (cbm_cypher_max_depth(), default 10). On
* origin/main, expand_var_length honoured an explicit `*1..N` verbatim (only the
* unbounded `*` / `*..m` forms were capped), so `[:CALLS*1..N]` passed N straight
* to cbm_store_bfs — an unbounded traversal (a DoS on cyclic graphs). RED before
* the clamp: a *1..12 walk over a 13-node chain
* returns all 12 hops (N01..N12). GREEN after: it stops at the depth-10 ceiling
* (N01..N10); N11/N12 are never emitted. max_rows=64 keeps the binding-expansion
* cap (bind_cap*10) well above the hop count, so DEPTH — not the binding cap — is
* the bound under test. */
TEST(cypher_exec_var_length_explicit_bound_capped) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Linear chain N00 -CALLS-> N01 -> ... -> N12 (13 nodes, one node per hop). */
int64_t ids[13];
for (int i = 0; i < 13; i++) {
char name[8];
char qn[24];
snprintf(name, sizeof(name), "N%02d", i);
snprintf(qn, sizeof(qn), "test.N%02d", i);
cbm_node_t n = {.project = "test",
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "chain.go"};
ids[i] = cbm_store_upsert_node(s, &n);
}
for (int i = 0; i < 12; i++) {
cbm_edge_t e = {
.project = "test", .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s,
"MATCH (a:Function {name: \"N00\"})-[:CALLS*1..12]->"
"(x:Function) RETURN x.name",
"test", 64, &r);
ASSERT_EQ(rc, 0);
/* Capped at 10 hops → exactly N01..N10; N11/N12 are beyond the ceiling. */
ASSERT_EQ(r.row_count, 10);
bool saw_n10 = false;
bool saw_n11 = false;
bool saw_n12 = false;
for (int i = 0; i < r.row_count; i++) {
const char *v = r.rows[i][0];
if (v && strcmp(v, "N10") == 0) {
saw_n10 = true;
}
if (v && strcmp(v, "N11") == 0) {
saw_n11 = true;
}
if (v && strcmp(v, "N12") == 0) {
saw_n12 = true;
}
}
ASSERT_TRUE(saw_n10); /* within the ceiling — proves the traversal really ran */
ASSERT_FALSE(saw_n11); /* clamped away */
ASSERT_FALSE(saw_n12);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(cypher_exec_defines_edge) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
@@ -2581,6 +2649,7 @@ SUITE(cypher) {
RUN_TEST(cypher_exec_limit);
RUN_TEST(cypher_exec_order_by);
RUN_TEST(cypher_exec_variable_length);
RUN_TEST(cypher_exec_var_length_explicit_bound_capped);
RUN_TEST(cypher_exec_defines_edge);
RUN_TEST(cypher_exec_no_results);
RUN_TEST(cypher_exec_where_numeric);
+57
View File
@@ -1001,6 +1001,62 @@ TEST(tool_trace_call_path_prefers_definition) {
PASS();
}
/* Reproduce-first (#887): the client-supplied `depth` on trace_call_path must be
* clamped to the MCP ceiling (cbm_mcp_max_depth(), default 15). On origin/main
* an MCP_MAX_DEPTH=15 constant was defined but never applied — `depth` flowed
* straight into bfs_union_same_name, so an unbounded value drives the shared
* cbm_store_bfs to arbitrary depth. Over an 18-node call chain, depth=1000
* reaches n16/n17 (RED); with the clamp the walk stops at hop 15, so n15 is
* reached but n16 is not (GREEN). Quoted tokens ("n15"/"n16") match only the
* node-name field, never the qualified_name (preceded by '.'), so the boundary
* check is exact. */
TEST(tool_trace_call_path_depth_clamped) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "depth-proj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/depth");
/* Linear call chain n00 -CALLS-> n01 -> ... -> n17 (18 nodes). */
int64_t ids[18];
for (int i = 0; i < 18; i++) {
char name[8];
char qn[32];
snprintf(name, sizeof(name), "n%02d", i);
snprintf(qn, sizeof(qn), "depth-proj.n%02d", i);
cbm_node_t n = {.project = proj,
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "chain.c",
.start_line = 1,
.end_line = 2};
ids[i] = cbm_store_upsert_node(st, &n);
}
for (int i = 0; i < 17; i++) {
cbm_edge_t e = {
.project = proj, .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"};
cbm_store_insert_edge(st, &e);
}
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"n00\","
"\"project\":\"depth-proj\",\"direction\":\"outbound\",\"depth\":1000}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
/* Reached within the ceiling (proves the traversal ran) but clamped at 15. */
ASSERT_NOT_NULL(strstr(inner, "\"n15\""));
ASSERT_NULL(strstr(inner, "\"n16\""));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Reproduce-first (#650, distilled): two GENUINELY-DIFFERENT same-named functions
* whose bodies differ in length score differently, so the old exact-tie check did
* not flag them ambiguous — and bfs_union_same_name (#546) then merged the caller
@@ -4711,6 +4767,7 @@ SUITE(mcp) {
RUN_TEST(tool_trace_missing_function_name);
RUN_TEST(tool_trace_call_path_ambiguous);
RUN_TEST(tool_trace_call_path_prefers_definition);
RUN_TEST(tool_trace_call_path_depth_clamped);
RUN_TEST(tool_trace_call_path_distinct_defs_not_over_unioned);
RUN_TEST(tool_trace_call_path_dts_stub_unions_with_impl);
RUN_TEST(tool_delete_project_not_found);