diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index f06873ad..97616988 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -742,11 +742,19 @@ static void expr_free(cbm_expr_t *e) { } free(cur->cond.in_values); } - if (cur->right && top < EXPR_FREE_STACK) { - stack[top++] = cur->right; + if (cur->right) { + if (top < EXPR_FREE_STACK) { + stack[top++] = cur->right; + } else { + expr_free(cur->right); /* recurse when stack overflows */ + } } - if (cur->left && top < EXPR_FREE_STACK) { - stack[top++] = cur->left; + if (cur->left) { + if (top < EXPR_FREE_STACK) { + stack[top++] = cur->left; + } else { + expr_free(cur->left); /* recurse when stack overflows */ + } } free(cur); } @@ -3577,4 +3585,4 @@ void cbm_cypher_result_free(cbm_cypher_result_t *r) { free(r->rows); free(r->error); memset(r, 0, sizeof(*r)); -} +} \ No newline at end of file diff --git a/src/foundation/str_util.c b/src/foundation/str_util.c index 1c23c711..f20079f5 100644 --- a/src/foundation/str_util.c +++ b/src/foundation/str_util.c @@ -270,6 +270,24 @@ bool cbm_validate_shell_arg(const char *s) { return true; } +bool cbm_validate_project_name(const char *name) { + if (!name || !*name) return false; + /* Reject directory traversal */ + if (strcmp(name, "..") == 0 || strstr(name, "..") != NULL) return false; + /* Reject path separators */ + if (strchr(name, '/') || strchr(name, '\\')) return false; + /* Reject leading dot (hidden files / relative refs) */ + if (name[0] == '.') return false; + /* Allow only alphanumeric, dash, underscore, dot */ + for (const char *p = name; *p; p++) { + if (!(((*p >= 'a') && (*p <= 'z')) || ((*p >= 'A') && (*p <= 'Z')) || + ((*p >= '0') && (*p <= '9')) || *p == '-' || *p == '_' || *p == '.')) { + return false; + } + } + return true; +} + int cbm_json_escape(char *buf, int bufsize, const char *src) { if (!buf || bufsize <= 0) { return 0; diff --git a/src/foundation/str_util.h b/src/foundation/str_util.h index cab2faa5..da02c760 100644 --- a/src/foundation/str_util.h +++ b/src/foundation/str_util.h @@ -57,6 +57,12 @@ char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count); * Returns true if safe, false if the string contains shell metacharacters. */ bool cbm_validate_shell_arg(const char *s); +/* Validate a project name is safe for file path construction. + * Allows: alphanumeric, dash, underscore, dot (but not leading dot or dot-dot). + * Rejects: path separators (/ \), directory traversal (..), and control chars. + * Returns true if safe, false if the name could escape the cache directory. */ +bool cbm_validate_project_name(const char *name); + /* Safe snprintf append: clamps offset to prevent buffer overflow on truncation. * When snprintf truncates, it returns what it WOULD have written, which can make * offset > bufsize. Next call: bufsize - offset wraps unsigned → huge → overflow. diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 43444648..716e6238 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -201,6 +201,9 @@ char *cbm_jsonrpc_format_response(const cbm_jsonrpc_response_t *resp) { yyjson_mut_obj_add_val(doc, root, "result", res_val); yyjson_doc_free(res_doc); } + } else { + /* JSON-RPC 2.0 spec: response MUST contain "result" or "error" */ + yyjson_mut_obj_add_null(doc, root, "result"); } char *out = yy_doc_to_str(doc); @@ -720,6 +723,10 @@ static const char *cache_dir(char *buf, size_t bufsz) { /* Returns full .db path for a project: /.db */ static const char *project_db_path(const char *project, char *buf, size_t bufsz) { + if (!cbm_validate_project_name(project)) { + buf[0] = '\0'; + return buf; + } char dir[CBM_SZ_1K]; cache_dir(dir, sizeof(dir)); snprintf(buf, bufsz, "%s/%s.db", dir, project); @@ -813,12 +820,16 @@ static int collect_db_project_names(const char *dir_path, char *out, size_t out_ if (!is_project_db_file(n, len)) { continue; } + if ((size_t)offset >= out_sz) break; /* bounds check before write */ if (count > 0 && offset < (int)out_sz - MCP_SEPARATOR) { out[offset++] = ','; } int wrote = snprintf(out + offset, out_sz - (size_t)offset, "\"%.*s\"", (int)(len - 3), n); if (wrote > 0) { offset += wrote; + if ((size_t)offset >= out_sz) { + offset = (int)out_sz - 1; /* clamp on truncation */ + } } count++; } diff --git a/src/store/store.c b/src/store/store.c index 30fddad8..03a1fe91 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -63,6 +63,7 @@ enum { #include "foundation/compat.h" #include "foundation/log.h" #include "foundation/compat_regex.h" +#include "foundation/str_util.h" #define XXH_INLINE_ALL #include "xxhash/xxhash.h" @@ -699,6 +700,9 @@ cbm_store_t *cbm_store_open(const char *project) { if (!project) { return NULL; } + if (!cbm_validate_project_name(project)) { + return NULL; + } const char *cdir = cbm_resolve_cache_dir(); if (!cdir) { cdir = cbm_tmpdir(); @@ -830,6 +834,9 @@ int cbm_store_create_indexes(cbm_store_t *s) { /* ── Checkpoint ─────────────────────────────────────────────────── */ int cbm_store_checkpoint(cbm_store_t *s) { + if (!s) { + return CBM_STORE_ERR; + } /* PASSIVE never blocks readers and never ftruncate()s either file. * SQLite recommends PASSIVE for shared databases — TRUNCATE shrinks * the WAL via ftruncate(fd, 0) on success, which on macOS can raise @@ -1749,6 +1756,11 @@ int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const /* ── NodeDegree ────────────────────────────────────────────────── */ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *out_deg) { + if (!s) { + if (in_deg) *in_deg = 0; + if (out_deg) *out_deg = 0; + return; + } *in_deg = 0; *out_deg = 0; @@ -1977,7 +1989,7 @@ int cbm_store_find_edges_by_url_path(cbm_store_t *s, const char *project, const /* Search properties JSON for url_path containing keyword */ char like_pattern[CBM_SZ_512]; - snprintf(like_pattern, sizeof(like_pattern), "%%\"url_path\":\"%%%%%s%%%%\"%%", keyword); + snprintf(like_pattern, sizeof(like_pattern), "%%\"url_path\":\"%%%s%%\"%%", keyword); const char *sql = "SELECT id, project, source_id, target_id, type, properties FROM edges " "WHERE project = ?1 AND properties LIKE ?2"; @@ -2013,6 +2025,9 @@ int cbm_store_find_edges_by_url_path(cbm_store_t *s, const char *project, const /* ── RestoreFrom ───────────────────────────────────────────────── */ int cbm_store_restore_from(cbm_store_t *dst, cbm_store_t *src) { + if (!dst || !src) { + return CBM_STORE_ERR; + } sqlite3_backup *bk = sqlite3_backup_init(dst->db, "main", src->db, "main"); if (!bk) { store_set_error_sqlite(dst, "backup init"); @@ -2286,7 +2301,9 @@ static void where_add_like_hints(const char *column, const char *pattern, char * char *lp = make_like_hint(hints[i]); free(hints[i]); if (!lp) continue; + int pool_was_full = (pool->count >= ST_LIKE_POOL_MAX); like_pool_add(pool, lp); + if (pool_was_full) continue; /* lp was freed — skip bind */ snprintf(bind_buf, sizeof(bind_buf), "%s LIKE ?%d", column, *bind_idx + SKIP_ONE); *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); where_bind_text(binds, bind_idx, lp); @@ -2323,10 +2340,13 @@ static int search_where_basic(const cbm_search_params_t *params, char *where, in } if (params->file_pattern) { char *lp = cbm_glob_to_like(params->file_pattern); + int pool_was_full = (pool->count >= ST_LIKE_POOL_MAX); like_pool_add(pool, lp); - snprintf(bind_buf, sizeof(bind_buf), "n.file_path LIKE ?%d", *bind_idx + SKIP_ONE); - *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); - where_bind_text(binds, bind_idx, lp); + if (!pool_was_full && lp) { + snprintf(bind_buf, sizeof(bind_buf), "n.file_path LIKE ?%d", *bind_idx + SKIP_ONE); + *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); + where_bind_text(binds, bind_idx, lp); + } } return *nparams; } diff --git a/src/ui/http_server.c b/src/ui/http_server.c index cef4ff70..8b4869e6 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -20,6 +20,7 @@ #include "foundation/log.h" #include "foundation/platform.h" #include "foundation/compat.h" +#include "foundation/str_util.h" #include "foundation/compat_thread.h" #include @@ -126,6 +127,10 @@ static bool get_query_param(struct mg_str query, const char *name, char *buf, in /* Build DB path for a project: /.db */ static void db_path_for_project(const char *project, char *buf, size_t bufsz) { + if (!cbm_validate_project_name(project)) { + buf[0] = '\0'; + return; + } const char *dir = cbm_resolve_cache_dir(); if (!dir) { dir = cbm_tmpdir(); @@ -444,7 +449,12 @@ static void handle_browse(struct mg_connection *c, struct mg_http_message *hm) { if (count > 0) buf[pos++] = ','; - pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "\"%s\"", ent->d_name); + /* Escape directory name to prevent XSS (e.g., names with quotes/angle brackets) */ + { + char esc[512]; + cbm_json_escape(esc, (int)sizeof(esc), ent->d_name); + pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "\"%s\"", esc); + } if (pos >= (int)sizeof(buf)) { pos = (int)sizeof(buf) - 1; } @@ -455,7 +465,7 @@ static void handle_browse(struct mg_connection *c, struct mg_http_message *hm) { } closedir(dir); - /* Parent path */ + /* Parent path — escape to prevent injection */ char parent[1024]; snprintf(parent, sizeof(parent), "%s", path); char *last_slash = strrchr(parent, '/'); @@ -464,7 +474,11 @@ static void handle_browse(struct mg_connection *c, struct mg_http_message *hm) { else snprintf(parent, sizeof(parent), "/"); - pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "],\"parent\":\"%s\"}", parent); + { + char esc_parent[2048]; + cbm_json_escape(esc_parent, (int)sizeof(esc_parent), parent); + pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "],\"parent\":\"%s\"}", esc_parent); + } mg_http_reply(c, 200, g_cors_json, "%s", buf); }