diff --git a/README.md b/README.md index 678db3b9b..cf8a45b62 100644 --- a/README.md +++ b/README.md @@ -476,6 +476,7 @@ codebase-memory-mcp config reset auto_index # reset to default | Variable | Default | Description | |----------|---------|-------------| +| `CBM_ALLOWED_ROOT` | *(unset)* | Restrict `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused; unset imposes no restriction. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. | | `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the database storage directory. All project indexes and config are stored here. | | `CBM_DIAGNOSTICS` | `false` | Set to `1` or `true` to enable periodic diagnostics output to `/tmp/cbm-diagnostics-.json`. | | `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index bccc6b27c..c5cb96eb8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -110,6 +110,7 @@ These environment variables affect runtime behavior: | Variable | Default | Description | |---|---|---| +| `CBM_ALLOWED_ROOT` | *(unset)* | Restrict `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused; unset imposes no restriction. Useful when the server may be driven by an untrusted caller (agentic or multi-tenant deployments). | | `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the cache directory used for indexes, `_config.db`, and UI `config.json`. | | `CBM_DIAGNOSTICS` | `false` | Enable periodic diagnostics output to `/tmp/cbm-diagnostics-.json`. | | `CBM_DOWNLOAD_URL` | GitHub releases | Override the update download URL. | diff --git a/internal/cbm/zstd_store.c b/internal/cbm/zstd_store.c index 732f64daa..509537419 100644 --- a/internal/cbm/zstd_store.c +++ b/internal/cbm/zstd_store.c @@ -5,6 +5,7 @@ #include "zstd_store.h" #include +#include int cbm_zstd_compress(const char *src, int srcLen, char *dst, int dstCap, int level) { size_t rc = ZSTD_compress(dst, (size_t)dstCap, src, (size_t)srcLen, level); @@ -14,12 +15,20 @@ int cbm_zstd_compress(const char *src, int srcLen, char *dst, int dstCap, int le return (int)rc; } -int cbm_zstd_decompress(const char *src, int srcLen, char *dst, int dstCap) { - size_t rc = ZSTD_decompress(dst, (size_t)dstCap, src, (size_t)srcLen); +int64_t cbm_zstd_decompress(const char *src, size_t srcLen, char *dst, size_t dstCap) { + size_t rc = ZSTD_decompress(dst, dstCap, src, srcLen); if (ZSTD_isError(rc)) { return 0; } - return (int)rc; + return (int64_t)rc; +} + +size_t cbm_zstd_frame_content_size(const char *src, size_t srcLen) { + unsigned long long n = ZSTD_getFrameContentSize(src, srcLen); + if (n == ZSTD_CONTENTSIZE_UNKNOWN || n == ZSTD_CONTENTSIZE_ERROR) { + return 0; + } + return (size_t)n; } size_t cbm_zstd_compress_bound(int inputSize) { diff --git a/internal/cbm/zstd_store.h b/internal/cbm/zstd_store.h index b854f736e..ef427df04 100644 --- a/internal/cbm/zstd_store.h +++ b/internal/cbm/zstd_store.h @@ -2,14 +2,22 @@ #define CBM_ZSTD_STORE_H #include +#include // Zstd compression at specified level (1=fast .. 22=best). // Returns compressed size on success, 0 on error. int cbm_zstd_compress(const char *src, int srcLen, char *dst, int dstCap, int level); -// Zstd decompression. -// Returns decompressed size on success, 0 on error. -int cbm_zstd_decompress(const char *src, int srcLen, char *dst, int dstCap); +// Zstd decompression. srcLen/dstCap are size_t so a >2 GiB destination capacity +// is never truncated (a large DB artifact, or a crafted one, would otherwise +// wrap through int and desync the real buffer size from the decoder capacity). +// Returns decompressed size on success (>0), 0 on error. +int64_t cbm_zstd_decompress(const char *src, size_t srcLen, char *dst, size_t dstCap); + +// Declared decompressed content size of a zstd frame (from its header), or 0 +// when unknown/unreadable. Lets the caller size the destination from the frame +// itself instead of trusting a separate, attacker-controllable size field. +size_t cbm_zstd_frame_content_size(const char *src, size_t srcLen); // Maximum compressed size bound for given input size. size_t cbm_zstd_compress_bound(int inputSize); diff --git a/src/cli/cli.c b/src/cli/cli.c index 142d8a880..884849e0b 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -2968,15 +2968,16 @@ static bool prompt_yn(const char *question) { /* Minimum line length in checksums.txt: CBM_SZ_64 hex + 2 spaces + 1 char filename */ #define CHECKSUM_LINE_MIN (SHA256_HEX_LEN + 2) -/* Compute SHA-CBM_SZ_256 of a file using platform tools (sha256sum/shasum). - * Writes CBM_SZ_64-char hex digest + NUL to out. Returns 0 on success. */ -static int sha256_file(const char *path, char *out, size_t out_size) { +/* Compute the SHA-256 of a file using platform tools (sha256sum/shasum). + * Writes a 64-char hex digest + NUL to out. Returns 0 on success. Not static: + * exercised directly by the self-update checksum regression test. */ +int cbm_cli_sha256_file(const char *path, char *out, size_t out_size) { if (out_size < SHA256_BUF_SIZE) { return CLI_ERR; } char cmd[CLI_BUF_1K]; #ifdef __APPLE__ - snprintf(cmd, sizeof(cmd), "shasum -a CBM_SZ_256 '%s' 2>/dev/null", path); + snprintf(cmd, sizeof(cmd), "shasum -a 256 '%s' 2>/dev/null", path); #else snprintf(cmd, sizeof(cmd), "sha256sum '%s' 2>/dev/null", path); #endif @@ -3107,8 +3108,8 @@ static int verify_download_checksum(const char *archive_path, const char *archiv } char actual[SHA256_BUF_SIZE] = {0}; - if (sha256_file(archive_path, actual, sizeof(actual)) != 0) { - (void)fprintf(stderr, "warning: sha256sum/shasum not available — skipping verification\n"); + if (cbm_cli_sha256_file(archive_path, actual, sizeof(actual)) != 0) { + (void)fprintf(stderr, "error: could not compute checksum (sha256 tool unavailable)\n"); return CLI_ERR; } @@ -4250,8 +4251,12 @@ static int download_verify_install(const char *url, const char *ext, const char const char *portable = (strcmp(os, "linux") == 0) ? "-portable" : ""; snprintf(archive_name, sizeof(archive_name), "codebase-memory-mcp-%s%s-%s%s.%s", want_ui ? "ui-" : "", os, arch, portable, ext); + /* Fail closed: install only a positively-verified download. A mismatch, + * a missing checksum entry, or an unavailable hash tool (crc != 0) all + * abort rather than install an unverified binary. */ int crc = verify_download_checksum(tmp_archive, archive_name); - if (crc == CLI_TRUE) { + if (crc != 0) { + (void)fprintf(stderr, "error: refusing to install an unverified download\n"); cbm_unlink(tmp_archive); return CLI_TRUE; } diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 4f554ea4b..8f321f588 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -1652,6 +1652,16 @@ static int parse_return_or_with(parser_t *p, cbm_return_clause_t **out, bool is_ } while (check(p, TOK_COMMA)); + /* Projection is materialized per row into fixed-width stack arrays sized at + * CBM_SZ_32 columns (execute_return_simple and its siblings). Bound the + * parsed item count to that width so an over-wide RETURN is rejected here + * instead of writing past those arrays downstream. */ + if (r->count > CBM_SZ_32) { + free(r->items); + free(r); + return CBM_NOT_FOUND; + } + tail: /* Optional ORDER BY */ if (match(p, TOK_ORDER)) { diff --git a/src/discover/discover.c b/src/discover/discover.c index 479dd695b..d38a74f37 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -606,9 +606,20 @@ static int wide_stat(const char *path, struct stat *st) { #endif } -/* Stat a path, skipping symlinks. Returns 0 on success, -1 to skip. */ +/* Stat a path, skipping symlinks (POSIX) and junctions / reparse points + * (Windows). Returns 0 on success, -1 to skip. Skipping reparse points keeps + * discovery from walking through a junction that points outside the project + * root, mirroring the POSIX S_ISLNK skip. */ static int safe_stat(const char *abs_path, struct stat *st) { #ifdef _WIN32 + wchar_t *wpath = cbm_utf8_to_wide(abs_path); + if (wpath) { + DWORD attr = GetFileAttributesW(wpath); + free(wpath); + if (attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_REPARSE_POINT)) { + return CBM_NOT_FOUND; + } + } return wide_stat(abs_path, st); #else if (lstat(abs_path, st) != 0) { diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 9330f31bd..6702fd1c9 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -3842,6 +3842,8 @@ char *cbm_mcp_index_run_supervised_path(const char *root_path) { return index_run_supervised_path(NULL, root_path); } +bool cbm_path_within_root(const char *root_path, const char *abs_path); /* defined below */ + static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { /* Supervisor gate: run the index in a crash/hang-isolating worker subprocess * unless this process IS the worker or the kill switch (CBM_INDEX_SUPERVISOR=0) @@ -3866,6 +3868,20 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { repo_path = canonicalize_repo_path_if_exists(repo_path); + /* Optional workspace boundary: when CBM_ALLOWED_ROOT is set (agentic / + * multi-tenant deployments where repo_path may be influenced by an + * untrusted caller), refuse to index a path that resolves outside it. + * Unset by default, so the standard "index the path I gave you" behaviour + * is unchanged. */ + const char *allowed_root = getenv("CBM_ALLOWED_ROOT"); + if (allowed_root && allowed_root[0] && repo_path && + !cbm_path_within_root(allowed_root, repo_path)) { + free(mode_str); + free(name_override); + free(repo_path); + return cbm_mcp_text_result("repo_path is outside the allowed root", true); + } + if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) { free(mode_str); free(name_override); @@ -4080,19 +4096,19 @@ static yyjson_doc *enrich_node_properties(yyjson_mut_doc *doc, yyjson_mut_val *o /* Resolve an absolute path from root_path + file_path, verify containment, * and read source lines. Sets *out_abs_path (caller frees). Returns source * string (caller frees) or NULL if path is invalid/unreadable. */ -static char *resolve_snippet_source(const char *root_path, const char *file_path, int start, - int end, char **out_abs_path) { - *out_abs_path = NULL; - if (!root_path || !file_path) { - return NULL; +/* True only when abs_path, after realpath/_fullpath resolution (which collapses + * `..` and resolves symlinks/junctions), stays within root_path. This is the + * single containment guard every MCP file-read sink must pass before reading a + * file into a tool response: both the snippet path (resolve_snippet_source) and + * the search path (attach_result_source) route through it, so a result whose + * indexed path escapes the project root — via a `..` segment, or a symlink / + * Windows junction picked up during discovery — is never read back out. */ +bool cbm_path_within_root(const char *root_path, const char *abs_path) { + if (!root_path || !abs_path) { + return false; } - size_t apsz = strlen(root_path) + strlen(file_path) + MCP_SEPARATOR; - char *abs_path = malloc(apsz); - snprintf(abs_path, apsz, "%s/%s", root_path, file_path); - char real_root[CBM_SZ_4K]; char real_file[CBM_SZ_4K]; - bool path_ok = false; #ifdef _WIN32 if (_fullpath(real_root, root_path, sizeof(real_root)) && _fullpath(real_file, abs_path, sizeof(real_file))) { @@ -4104,11 +4120,24 @@ static char *resolve_snippet_source(const char *root_path, const char *file_path size_t root_len = strlen(real_root); if (strncmp(real_file, real_root, root_len) == 0 && (real_file[root_len] == '/' || real_file[root_len] == '\0')) { - path_ok = true; + return true; } } + return false; +} + +static char *resolve_snippet_source(const char *root_path, const char *file_path, int start, + int end, char **out_abs_path) { + *out_abs_path = NULL; + if (!root_path || !file_path) { + return NULL; + } + size_t apsz = strlen(root_path) + strlen(file_path) + MCP_SEPARATOR; + char *abs_path = malloc(apsz); + snprintf(abs_path, apsz, "%s/%s", root_path, file_path); + *out_abs_path = abs_path; - if (path_ok) { + if (cbm_path_within_root(root_path, abs_path)) { return read_file_lines(abs_path, start, end); } return NULL; @@ -4570,6 +4599,14 @@ static void attach_result_source(yyjson_mut_doc *doc, yyjson_mut_val *item, sear char abs_path[CBM_SZ_1K]; snprintf(abs_path, sizeof(abs_path), "%s/%s", root_path, r->file); + /* Containment: a search result whose indexed path resolves outside the + * project root (a `..` segment, or a symlink/junction that discovery + * followed) must not be read back into the response. Same guard the + * snippet path already uses. */ + if (!cbm_path_within_root(root_path, abs_path)) { + return; + } + if (mode == MODE_FULL) { char *source = read_file_lines(abs_path, r->start_line, r->end_line); if (source) { @@ -4938,6 +4975,14 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co int written = 0; if (fl) { for (int fi = 0; fi < indexed_count; fi++) { + /* A source path never legitimately contains a newline or carriage + * return. Those bytes are exactly the record separator on the + * Windows filelist (and would split naive line readers elsewhere), + * so a crafted indexed path with an embedded newline could inject + * an extra entry into the scan set. Skip such paths entirely. */ + if (strpbrk(indexed_files[fi], "\r\n") != NULL) { + continue; + } if (has_path_filter && path_regex) { #ifdef _WIN32 cbm_normalize_path_sep(indexed_files[fi]); @@ -5352,8 +5397,12 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { base_branch = heap_strdup("main"); } - /* Reject shell metacharacters in user-supplied branch name */ - if (!cbm_validate_shell_arg(base_branch)) { + /* Reject shell metacharacters, and a leading '-', in the user-supplied + * branch name. base_branch is spliced into `git diff --name-only + * ""...HEAD`; a value starting with '-' would be read by git as an + * option rather than a ref (e.g. `--output=` writes the diff to an + * arbitrary file). A real git ref never begins with '-'. */ + if (!cbm_validate_shell_arg(base_branch) || base_branch[0] == '-') { free(project); free(base_branch); free(scope); diff --git a/src/pipeline/artifact.c b/src/pipeline/artifact.c index c7a8f451a..9e27e944f 100644 --- a/src/pipeline/artifact.c +++ b/src/pipeline/artifact.c @@ -15,6 +15,13 @@ enum { }; #define ART_BYTES_PER_MB ((size_t)1024 * 1024) +/* Generous ceiling on an imported artifact's decompressed size. Real indexes + * (a full Linux-kernel DB is ~14 GB) fit comfortably; a frame that declares + * more than this is rejected before any allocation so a crafted content size + * can neither trigger a runaway allocation nor be used to desync the decoder + * capacity from the destination buffer. */ +#define ART_MAX_DECOMPRESSED_BYTES ((size_t)64 * 1024 * ART_BYTES_PER_MB) + #include "pipeline/artifact.h" #include "store/store.h" #include "foundation/platform.h" @@ -614,16 +621,29 @@ int cbm_artifact_import(const char *repo_path, const char *cache_db_path) { } /* Decompress */ - char *decompressed = malloc(original_size); + /* Size the destination from the zstd frame's own content-size header, not + * from the separately-stored (attacker-controllable) original_size field. + * The allocation and the decoder capacity are then the SAME size_t value, + * so a crafted size can never make the capacity exceed the real buffer + * (the int-truncation that used to do exactly that is gone with the size_t + * signature). Require the metadata field to agree, and cap the total. */ + size_t frame_size = cbm_zstd_frame_content_size(compressed, clen); + if (frame_size == 0 || frame_size > ART_MAX_DECOMPRESSED_BYTES || frame_size != original_size) { + free(compressed); + cbm_log_error("artifact.import", "err", "bad_decompressed_size"); + return CBM_NOT_FOUND; + } + + char *decompressed = malloc(frame_size); if (!decompressed) { free(compressed); return CBM_NOT_FOUND; } - int dlen = cbm_zstd_decompress(compressed, (int)clen, decompressed, (int)original_size); + int64_t dlen = cbm_zstd_decompress(compressed, clen, decompressed, frame_size); free(compressed); - if (dlen <= 0) { + if (dlen <= 0 || (size_t)dlen != frame_size) { free(decompressed); cbm_log_error("artifact.import", "err", "zstd_decompress"); return CBM_NOT_FOUND; diff --git a/src/ui/http_server.c b/src/ui/http_server.c index f23e36f4a..438f55e0c 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -42,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -147,16 +148,29 @@ static index_job_t g_index_jobs[MAX_INDEX_JOBS]; /* ── Serve embedded asset ─────────────────────────────────────── */ +/* Content-Security-Policy for the served UI. No external host appears in any + * directive, so the browser cannot load or connect to anything off-origin — + * this ENFORCES the airgap (the code makes no external calls; this stops a + * future dependency or injected content from doing so). connect-src 'self' + * confines fetch/XHR/WebSocket to the local server. The 'self'/data:/blob:/ + * 'unsafe-inline'-style/'wasm-unsafe-eval' allowances cover the bundled app's + * own needs (React inline styles, three.js textures/workers/WASM). */ +#define CBM_UI_CSP \ + "Content-Security-Policy: default-src 'self'; connect-src 'self'; " \ + "img-src 'self' data: blob:; script-src 'self' 'wasm-unsafe-eval'; " \ + "style-src 'self' 'unsafe-inline'; font-src 'self' data:; " \ + "worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'\r\n" + static bool serve_embedded(cbm_http_conn_t *c, const char *path) { const cbm_embedded_file_t *f = cbm_embedded_lookup(path); if (!f) return false; /* Build headers with correct Content-Type for this asset */ - char hdrs[512]; + char hdrs[1024]; snprintf(hdrs, sizeof(hdrs), "%sContent-Type: %s\r\n" - "Cache-Control: public, max-age=31536000, immutable\r\n", + "Cache-Control: public, max-age=31536000, immutable\r\n" CBM_UI_CSP, g_cors, f->content_type); cbm_http_reply_buf(c, 200, hdrs, f->data, (size_t)f->size); @@ -389,6 +403,35 @@ void cbm_ui_log_append(const char *line) { cbm_mutex_unlock(&g_log_mutex); } +/* Append a printf-formatted fragment at *pos within a bufsz buffer, never + * advancing *pos past bufsz. snprintf returns the length it WOULD have written, + * so `pos += snprintf(...)` runs pos past the end on truncation and the next + * call computes a wrapped (huge) remaining size and writes out of bounds. This + * clamps: on truncation *pos is pinned at bufsz and further appends are no-ops. */ +static void http_appendf(char *buf, size_t bufsz, int *pos, const char *fmt, ...) + __attribute__((format(printf, 4, 5))); +static void http_appendf(char *buf, size_t bufsz, int *pos, const char *fmt, ...) { + if (*pos < 0) { + return; + } + if ((size_t)*pos >= bufsz) { + *pos = (int)bufsz; + return; + } + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf + *pos, bufsz - (size_t)*pos, fmt, ap); + va_end(ap); + if (n < 0) { + return; + } + if ((size_t)n >= bufsz - (size_t)*pos) { + *pos = (int)bufsz; + } else { + *pos += n; + } +} + /* GET /api/logs?lines=N — returns last N log lines */ static void handle_logs(cbm_http_conn_t *c, const cbm_http_req_t *req) { char lines_str[16] = {0}; @@ -414,7 +457,7 @@ static void handle_logs(cbm_http_conn_t *c, const cbm_http_req_t *req) { } int pos = 0; - pos += snprintf(buf + pos, buf_size - (size_t)pos, "{\"lines\":["); + http_appendf(buf, buf_size, &pos, "{\"lines\":["); for (int i = 0; i < count; i++) { int idx = (start + i) % LOG_RING_SIZE; if (i > 0) @@ -439,7 +482,7 @@ static void handle_logs(cbm_http_conn_t *c, const cbm_http_req_t *req) { buf[pos++] = '"'; } cbm_mutex_unlock(&g_log_mutex); - pos += snprintf(buf + pos, buf_size - (size_t)pos, "],\"total\":%d}", total); + http_appendf(buf, buf_size, &pos, "],\"total\":%d}", total); cbm_http_replyf(c, 200, g_cors_json, "%s", buf); free(buf); @@ -474,10 +517,10 @@ static void handle_processes(cbm_http_conn_t *c) { user_s = (double)u.QuadPart / 1e7; sys_s = (double)k.QuadPart / 1e7; } - pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, - "{\"self_pid\":%d,\"self_rss_mb\":%.1f," - "\"self_user_cpu_s\":%.1f,\"self_sys_cpu_s\":%.1f,\"processes\":[]}", - (int)_getpid(), (double)rss_bytes / (1024.0 * 1024.0), user_s, sys_s); + http_appendf(buf, sizeof(buf), &pos, + "{\"self_pid\":%d,\"self_rss_mb\":%.1f," + "\"self_user_cpu_s\":%.1f,\"self_sys_cpu_s\":%.1f,\"processes\":[]}", + (int)_getpid(), (double)rss_bytes / (1024.0 * 1024.0), user_s, sys_s); #else struct rusage ru; getrusage(RUSAGE_SELF, &ru); @@ -485,12 +528,12 @@ static void handle_processes(cbm_http_conn_t *c) { #ifdef __APPLE__ rss_kb /= 1024; #endif - pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, - "{\"self_pid\":%d,\"self_rss_mb\":%.1f," - "\"self_user_cpu_s\":%.1f,\"self_sys_cpu_s\":%.1f,\"processes\":[", - (int)getpid(), (double)rss_kb / 1024.0, - (double)ru.ru_utime.tv_sec + (double)ru.ru_utime.tv_usec / 1e6, - (double)ru.ru_stime.tv_sec + (double)ru.ru_stime.tv_usec / 1e6); + http_appendf(buf, sizeof(buf), &pos, + "{\"self_pid\":%d,\"self_rss_mb\":%.1f," + "\"self_user_cpu_s\":%.1f,\"self_sys_cpu_s\":%.1f,\"processes\":[", + (int)getpid(), (double)rss_kb / 1024.0, + (double)ru.ru_utime.tv_sec + (double)ru.ru_utime.tv_usec / 1e6, + (double)ru.ru_stime.tv_sec + (double)ru.ru_stime.tv_usec / 1e6); FILE *fp = popen("LC_ALL=C ps -eo pid,pcpu,rss,etime,comm 2>/dev/null" " | grep '[c]odebase-memory-mcp'", @@ -508,11 +551,11 @@ static void handle_processes(cbm_http_conn_t *c) { if (sscanf(line, "%d %f %ld %63s %255s", &pid, &cpu, &rss, elapsed, comm) >= 4) { if (proc_count > 0) buf[pos++] = ','; - pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, - "{\"pid\":%d,\"cpu\":%.1f,\"rss_mb\":%.1f," - "\"elapsed\":\"%s\",\"command\":\"%s\",\"is_self\":%s}", - pid, (double)cpu, (double)rss / 1024.0, elapsed, comm, - pid == (int)getpid() ? "true" : "false"); + http_appendf(buf, sizeof(buf), &pos, + "{\"pid\":%d,\"cpu\":%.1f,\"rss_mb\":%.1f," + "\"elapsed\":\"%s\",\"command\":\"%s\",\"is_self\":%s}", + pid, (double)cpu, (double)rss / 1024.0, elapsed, comm, + pid == (int)getpid() ? "true" : "false"); if (pos >= (int)sizeof(buf)) { pos = (int)sizeof(buf) - 1; } @@ -521,7 +564,7 @@ static void handle_processes(cbm_http_conn_t *c) { } pclose(fp); } - pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "]}"); + http_appendf(buf, sizeof(buf), &pos, "]}"); #endif cbm_http_replyf(c, 200, g_cors_json, "%s", buf); @@ -602,7 +645,7 @@ static void handle_process_kill(cbm_http_conn_t *c, const cbm_http_req_t *req) { #include static void append_roots_json(char *buf, size_t bufsz, int *pos) { - *pos += snprintf(buf + *pos, bufsz - (size_t)*pos, ",\"roots\":["); + http_appendf(buf, bufsz, pos, ",\"roots\":["); #ifdef _WIN32 DWORD drives = GetLogicalDrives(); int count = 0; @@ -613,12 +656,12 @@ static void append_roots_json(char *buf, size_t bufsz, int *pos) { if (count++ > 0) { buf[(*pos)++] = ','; } - *pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "\"%c:/\"", 'A' + i); + http_appendf(buf, bufsz, pos, "\"%c:/\"", 'A' + i); } #else - *pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "\"/\""); + http_appendf(buf, bufsz, pos, "\"/\""); #endif - *pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "]"); + http_appendf(buf, bufsz, pos, "]"); } /* GET /api/browse?path=/some/dir — list subdirectories for file picker */ @@ -653,7 +696,7 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) { /* Build JSON response */ char buf[32768]; int pos = 0; - pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "{\"path\":\"%s\",\"dirs\":[", path); + http_appendf(buf, sizeof(buf), &pos, "{\"path\":\"%s\",\"dirs\":[", path); struct dirent *ent; int count = 0; @@ -674,7 +717,7 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) { { char esc[512]; cbm_json_escape(esc, (int)sizeof(esc), ent->d_name); - pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "\"%s\"", esc); + http_appendf(buf, sizeof(buf), &pos, "\"%s\"", esc); } if (pos >= (int)sizeof(buf)) { pos = (int)sizeof(buf) - 1; @@ -706,9 +749,9 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) { { 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); + http_appendf(buf, sizeof(buf), &pos, "],\"parent\":\"%s\"", esc_parent); append_roots_json(buf, sizeof(buf), &pos); - pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "}"); + http_appendf(buf, sizeof(buf), &pos, "}"); } cbm_http_replyf(c, 200, g_cors_json, "%s", buf); } @@ -760,8 +803,8 @@ static void handle_adr_get(cbm_http_conn_t *c, const cbm_http_req_t *req) { buf[pos++] = ch; } } - pos += snprintf(buf + pos, buf_size - (size_t)pos, "\",\"updated_at\":\"%s\"}", - adr.updated_at ? adr.updated_at : ""); + http_appendf(buf, buf_size, &pos, "\",\"updated_at\":\"%s\"}", + adr.updated_at ? adr.updated_at : ""); cbm_http_replyf(c, 200, g_cors_json, "%s", buf); free(buf); } else { @@ -1178,9 +1221,9 @@ static void handle_index_status(cbm_http_conn_t *c) { if (pos > 1) buf[pos++] = ','; const char *ss = st == 1 ? "indexing" : st == 2 ? "done" : "error"; - pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, - "{\"slot\":%d,\"status\":\"%s\",\"path\":\"%s\",\"error\":\"%s\"}", i, ss, - g_index_jobs[i].root_path, st == 3 ? g_index_jobs[i].error_msg : ""); + http_appendf(buf, sizeof(buf), &pos, + "{\"slot\":%d,\"status\":\"%s\",\"path\":\"%s\",\"error\":\"%s\"}", i, ss, + g_index_jobs[i].root_path, st == 3 ? g_index_jobs[i].error_msg : ""); } buf[pos++] = ']'; buf[pos] = '\0'; @@ -1577,11 +1620,32 @@ static void handle_rpc(cbm_http_conn_t *c, const cbm_http_req_t *req, cbm_mcp_se /* ── Request dispatch ─────────────────────────────────────────── */ +/* True when the Host header names the loopback interface the server binds to + * (with or without a port). Anything else means the request reached us under a + * name that is not loopback — a rebinding DNS host or a proxy pointed at the + * local port — which is the DNS-rebinding / cross-site vector against a + * localhost-only service. */ +static bool host_is_loopback(const char *host) { + return cbm_http_path_match(host, "localhost") || cbm_http_path_match(host, "localhost:*") || + cbm_http_path_match(host, "127.0.0.1") || cbm_http_path_match(host, "127.0.0.1:*") || + cbm_http_path_match(host, "[::1]") || cbm_http_path_match(host, "[::1]:*"); +} + static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, const cbm_http_req_t *req) { /* Build per-request CORS headers (only reflects localhost origins) */ update_cors(req); + /* DNS-rebinding / cross-site guard: the server binds to loopback only, so a + * request carrying any non-loopback Host was routed here under a foreign + * name (a rebinding DNS record, a proxy) and must be refused before it can + * reach a state-changing endpoint. A bare request with no Host header + * (HTTP/1.0 local tooling) is still allowed. */ + if (req->host[0] != '\0' && !host_is_loopback(req->host)) { + cbm_http_replyf(c, 403, g_cors, "%s", "{\"error\":\"forbidden host\"}"); + return; + } + bool is_get = strcmp(req->method, "GET") == 0; bool is_post = strcmp(req->method, "POST") == 0; bool is_delete = strcmp(req->method, "DELETE") == 0; @@ -1680,9 +1744,9 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, if (cbm_http_path_match(req->path, "/")) { const cbm_embedded_file_t *f = cbm_embedded_lookup("/index.html"); if (f) { - char html_hdrs[512]; + char html_hdrs[1024]; snprintf(html_hdrs, sizeof(html_hdrs), - "%sContent-Type: text/html\r\nCache-Control: no-cache\r\n", g_cors); + "%sContent-Type: text/html\r\nCache-Control: no-cache\r\n" CBM_UI_CSP, g_cors); cbm_http_reply_buf(c, 200, html_hdrs, f->data, (size_t)f->size); return; } diff --git a/src/ui/httpd.c b/src/ui/httpd.c index da5f0146f..7e41b46e4 100644 --- a/src/ui/httpd.c +++ b/src/ui/httpd.c @@ -342,6 +342,8 @@ int cbm_http_parse_head(const char *data, size_t len, cbm_http_req_t *req, size_ if (header_name_is(p, nlen, "origin")) { copy_header_value(colon + 1, eol, req->origin, sizeof(req->origin)); + } else if (header_name_is(p, nlen, "host")) { + copy_header_value(colon + 1, eol, req->host, sizeof(req->host)); } else if (header_name_is(p, nlen, "accept-language")) { copy_header_value(colon + 1, eol, req->accept_language, sizeof(req->accept_language)); } else if (header_name_is(p, nlen, "transfer-encoding")) { diff --git a/src/ui/httpd.h b/src/ui/httpd.h index b6fd48110..d20d9a0f8 100644 --- a/src/ui/httpd.h +++ b/src/ui/httpd.h @@ -49,6 +49,7 @@ typedef struct { char path[2048]; char query[2048]; char origin[256]; + char host[256]; char accept_language[256]; char *body; size_t body_len; diff --git a/tests/test_artifact.c b/tests/test_artifact.c index f87be801b..0e39bb17f 100644 --- a/tests/test_artifact.c +++ b/tests/test_artifact.c @@ -94,6 +94,74 @@ static const char *capture_logs_end(void) { /* ── Tests ───────────────────────────────────────────────────────── */ +/* Rewrite the "original_size" number in an artifact.json in place, adding + * `delta` to it. Returns false if the field / a digit run isn't found. */ +static bool bump_artifact_original_size(const char *meta_path, long delta) { + FILE *fp = fopen(meta_path, "rb"); + if (!fp) { + return false; + } + char buf[4096]; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + fclose(fp); + buf[n] = '\0'; + char *key = strstr(buf, "\"original_size\""); + if (!key) { + return false; + } + char *colon = strchr(key, ':'); + if (!colon) { + return false; + } + char *ds = colon + 1; + while (*ds == ' ' || *ds == '\t') { + ds++; + } + char *de = ds; + while (*de >= '0' && *de <= '9') { + de++; + } + if (de == ds) { + return false; + } + long val = strtol(ds, NULL, 10) + delta; + char out[4096]; + int pre = (int)(ds - buf); + snprintf(out, sizeof(out), "%.*s%ld%s", pre, buf, val, de); + fp = fopen(meta_path, "wb"); + if (!fp) { + return false; + } + fwrite(out, 1, strlen(out), fp); + fclose(fp); + return true; +} + +/* The decompressed size is driven by the zstd frame's own content-size header, + * not the separately-stored original_size field (which travels in plaintext + * artifact.json and is trivially editable). A mismatch between the two must be + * rejected — this is the check that keeps the destination allocation and the + * decoder capacity pinned to the same verified size, so a doctored size can + * never make the decoder write past the buffer. */ +TEST(artifact_import_rejects_size_mismatch) { + setup_artifact_test(); + create_test_db(g_db); + ASSERT_EQ(cbm_artifact_export(g_db, g_repo, "test-proj", CBM_ARTIFACT_FAST), 0); + + char meta[1024]; + snprintf(meta, sizeof(meta), "%s/.codebase-memory/artifact.json", g_repo); + ASSERT_TRUE( + bump_artifact_original_size(meta, 4096)); /* claim 4 KiB more than the frame holds */ + + char import_db[1024]; + snprintf(import_db, sizeof(import_db), "%s/imported.db", g_tmpdir); + int rc = cbm_artifact_import(g_repo, import_db); + ASSERT_NEQ(rc, 0); /* must reject the mismatch, not import on the doctored size */ + + cleanup_dir(g_tmpdir); + PASS(); +} + TEST(artifact_export_fast_roundtrip) { setup_artifact_test(); create_test_db(g_db); @@ -374,5 +442,6 @@ SUITE(artifact) { RUN_TEST(artifact_gitattributes_created); RUN_TEST(artifact_export_rename_failure_logs_specific_error); RUN_TEST(pipeline_persistence_export_failure_returns_error); + RUN_TEST(artifact_import_rejects_size_mismatch); RUN_TEST(artifact_null_safety); } diff --git a/tests/test_cli.c b/tests/test_cli.c index 1aa4ea716..6f2b19c4a 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -3010,11 +3010,38 @@ TEST(cli_print_tool_help_issue680) { PASS(); } +/* The self-update path verifies a downloaded archive against a published + * checksum. That check is only meaningful if the digest is actually computed — + * a broken hash command (it once invoked `shasum -a CBM_SZ_256`, an invalid + * algorithm, from a bad macro rename inside the shell string) makes every + * digest fail, and the caller then falls through and installs unverified. + * Guard the digest itself against a known vector. */ +extern int cbm_cli_sha256_file(const char *path, char *out, size_t out_size); + +TEST(cli_sha256_file_matches_known_vector) { + char path[512]; + snprintf(path, sizeof(path), "%s/cbm_sha_XXXXXX", cbm_tmpdir()); + int fd = cbm_mkstemp(path); + ASSERT_TRUE(fd >= 0); + FILE *fp = fdopen(fd, "wb"); + ASSERT_NOT_NULL(fp); + fwrite("abc", 1, 3, fp); /* sha256("abc") is a NIST test vector */ + fclose(fp); + + char digest[128] = {0}; + int rc = cbm_cli_sha256_file(path, digest, sizeof(digest)); + remove(path); + ASSERT_EQ(rc, 0); + ASSERT_STR_EQ(digest, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════ * Suite definition * ═══════════════════════════════════════════════════════════════════ */ SUITE(cli) { + RUN_TEST(cli_sha256_file_matches_known_vector); /* Version (2 tests — selfupdate_test.go) */ RUN_TEST(cli_compare_versions); RUN_TEST(cli_version_get_set); diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 5fe3cd592..9ab7e618e 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -10,6 +10,10 @@ #include #include #include +#ifndef _WIN32 +#include /* fork/waitpid crash-isolation for the projection-width guard */ +#include +#endif /* ══════════════════════════════════════════════════════════════════ * LEXER TESTS @@ -2578,10 +2582,10 @@ TEST(cypher_multi_prop_projection_no_alias) { ASSERT_EQ(rc, 0); ASSERT_EQ(r.row_count, 1); ASSERT_EQ(r.col_count, 6); - ASSERT_STR_EQ(r.rows[0][0], "1"); /* loop_depth — NOT the suffix transitive_loop_depth */ - ASSERT_STR_EQ(r.rows[0][1], "5"); /* transitive_loop_depth */ - ASSERT_STR_EQ(r.rows[0][2], "7"); /* cognitive */ - ASSERT_STR_EQ(r.rows[0][3], "3"); /* complexity */ + ASSERT_STR_EQ(r.rows[0][0], "1"); /* loop_depth — NOT the suffix transitive_loop_depth */ + ASSERT_STR_EQ(r.rows[0][1], "5"); /* transitive_loop_depth */ + ASSERT_STR_EQ(r.rows[0][2], "7"); /* cognitive */ + ASSERT_STR_EQ(r.rows[0][3], "3"); /* complexity */ ASSERT_STR_EQ(r.rows[0][4], "10"); /* start_line (computed) */ ASSERT_STR_EQ(r.rows[0][5], "42"); /* end_line (computed) — distinct from start_line */ cbm_cypher_result_free(&r); @@ -2589,6 +2593,47 @@ TEST(cypher_multi_prop_projection_no_alias) { PASS(); } +/* Result projection writes into fixed-width per-row stack arrays + * (vals[CBM_SZ_32] / func_bufs[CBM_SZ_32][…] in execute_return_simple and its + * siblings), indexed by the parsed RETURN item count. The parser must bound + * that count to the array width; an over-wide RETURN has to be rejected, not + * allowed to run and write past the arrays. Drive a >32-column RETURN in a + * forked child so a stack overwrite (ASan abort, or a raw segfault) shows up + * as a killing signal instead of taking down the whole runner; the bounded + * path returns an ordinary error and the child exits cleanly. */ +TEST(cypher_wide_return_projection_bounded) { +#ifdef _WIN32 + SKIP_PLATFORM("fork crash-isolation is POSIX-only; the parse-time bound is platform-agnostic"); +#else + char query[4096]; + int off = snprintf(query, sizeof(query), "MATCH (f:Function) RETURN "); + for (int i = 0; i < 48; i++) { /* 48 > CBM_SZ_32 (32) fixed columns */ + off += snprintf(query + off, sizeof(query) - (size_t)off, "%sf.p%d", i ? ", " : "", i); + } + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + (void)cbm_cypher_execute(s, query, "test", 0, &r); + cbm_cypher_result_free(&r); + cbm_store_close(s); + _exit(0); /* reached only when execution did NOT overflow */ + } + ASSERT_TRUE(pid > 0); + int status = 0; + (void)waitpid(pid, &status, 0); + if (WIFSIGNALED(status)) { + char msg[96]; + snprintf(msg, sizeof(msg), "wide RETURN killed by signal %d — projection stack overflow", + WTERMSIG(status)); + FAIL(msg); + } + ASSERT_TRUE(WIFEXITED(status)); + PASS(); +#endif +} + /* ══════════════════════════════════════════════════════════════════ */ SUITE(cypher) { @@ -2750,4 +2795,5 @@ SUITE(cypher) { /* Phase 9: UNWIND */ RUN_TEST(cypher_parse_unwind); RUN_TEST(cypher_parse_unwind_var); + RUN_TEST(cypher_wide_return_projection_bounded); } diff --git a/tests/test_httpd.c b/tests/test_httpd.c index d7cb25742..cceea3628 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -44,6 +44,7 @@ typedef SOCKET th_sock_t; #include #include #include /* struct timeval for the SO_RCVTIMEO watchdog (#798 follow-up) */ +#include /* fork/waitpid crash-isolation for the browse overflow guard */ #include typedef int th_sock_t; #define th_sock_close close @@ -1085,9 +1086,92 @@ TEST(git_context_resolve_no_hang_under_live_ui_sockets) { #endif } +/* The server binds to loopback only. A request carrying a non-loopback Host + * header reached it under a foreign name — the DNS-rebinding / cross-site + * vector against a localhost service — and must be refused before routing. A + * loopback Host (or none) proceeds normally. */ +TEST(ui_server_rejects_non_loopback_host) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + int port = cbm_http_server_port(ts.srv); + char resp[4096]; + + int n = th_http(port, "GET / HTTP/1.1\r\nHost: evil.example.com\r\n\r\n", resp, sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 403); + + /* A loopback Host is not rejected (routes on to the normal 404 stub). */ + char req[128]; + snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); + n = th_http(port, req, resp, sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_NEQ(th_status(resp), 403); + + th_server_stop(&ts); + PASS(); +} + +/* The directory browser formats readdir() entries into a fixed 32 KB response + * buffer. The per-entry loop is clamped, but the trailing "parent"/"roots" + * appends were not — once the entries filled the buffer, pos ran past the end + * and the next size argument wrapped, writing out of bounds. Fill the buffer + * with many long-named subdirectories and browse it in a forked child so an + * overflow surfaces as a killing signal (ASan abort) rather than a clean run. */ +TEST(ui_server_browse_wide_dir_no_overflow) { +#ifdef _WIN32 + SKIP_PLATFORM("fork crash-isolation is POSIX-only; the clamp is platform-agnostic"); +#else + char *dir = th_mktempdir("cbm_browse"); + if (!dir) { + FAIL("mktempdir"); + } + char longname[240]; + memset(longname, 'a', sizeof(longname) - 1); + longname[sizeof(longname) - 1] = '\0'; + for (int i = 0; i < 250; i++) { /* 250 * ~220 chars overflows the 32 KB buffer */ + char sub[600]; + snprintf(sub, sizeof(sub), "%s/%s%03d", dir, longname, i); + th_mkdir_p(sub); + } + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + th_server_t ts; + if (th_server_start(&ts) != 0) { + _exit(2); + } + char req[512]; + snprintf(req, sizeof(req), "GET /api/browse?path=%s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + dir); + char *resp = malloc(262144); + int n = resp ? th_http(cbm_http_server_port(ts.srv), req, resp, 262144) : 0; + int ok = (n > 0 && strstr(resp, "HTTP/1.1 200") != NULL); + free(resp); + th_server_stop(&ts); + _exit(ok ? 0 : 3); + } + ASSERT_TRUE(pid > 0); + int status = 0; + (void)waitpid(pid, &status, 0); + char rm[600]; + snprintf(rm, sizeof(rm), "rm -rf '%s'", dir); + (void)system(rm); + if (WIFSIGNALED(status)) { + char m[96]; + snprintf(m, sizeof(m), "browse killed by signal %d — response buffer overflow", + WTERMSIG(status)); + FAIL(m); + } + ASSERT_TRUE(WIFEXITED(status)); + ASSERT_EQ(WEXITSTATUS(status), 0); + PASS(); +#endif +} + /* ── Suite ────────────────────────────────────────────────────── */ SUITE(httpd) { + RUN_TEST(ui_server_browse_wide_dir_no_overflow); /* Parser / helpers */ RUN_TEST(httpd_parse_simple_get); RUN_TEST(httpd_parse_post_with_body_offset); @@ -1113,6 +1197,7 @@ SUITE(httpd) { RUN_TEST(httpd_listen_port_collision_returns_null); /* Full UI server */ + RUN_TEST(ui_server_rejects_non_loopback_host); RUN_TEST(ui_server_unknown_path_404); RUN_TEST(ui_server_root_serves_stub_404); RUN_TEST(ui_server_cors_localhost_reflected); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index a70b1842c..7914a8bb8 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -4874,11 +4874,98 @@ TEST(mcp_auto_watch_false_skips_supervised_autoindex_issue853) { #endif } +/* The containment guard both MCP file-read sinks route through + * (resolve_snippet_source for get_code_snippet, attach_result_source for + * search_code). A result path that resolves outside the indexed project root + * — via a `..` segment or a followed symlink/junction — must be rejected so + * its contents never reach a tool response. */ +extern bool cbm_path_within_root(const char *root_path, const char *abs_path); + +TEST(mcp_path_within_root_rejects_escape) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX realpath repro; the Windows _fullpath branch is the same guard"); +#else + char root[512]; + snprintf(root, sizeof(root), "%s/cbm_pwr_XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(root)) { + FAIL("cbm_mkdtemp failed"); + } + char inside[700]; + snprintf(inside, sizeof(inside), "%s/inside.c", root); + FILE *fp = fopen(inside, "w"); + ASSERT_NOT_NULL(fp); + fputs("int x;\n", fp); + fclose(fp); + + /* The abs_path a sink builds for an in-root result stays contained; a `..` + * escape to an existing outside file (/etc/hosts) resolves out and must be + * rejected. */ + char escape[900]; + snprintf(escape, sizeof(escape), "%s/../../../../etc/hosts", root); + ASSERT_TRUE(cbm_path_within_root(root, inside)); + ASSERT_FALSE(cbm_path_within_root(root, escape)); + ASSERT_FALSE(cbm_path_within_root(root, "/etc/hosts")); + + remove(inside); + cbm_rmdir(root); + PASS(); +#endif +} + +/* base_branch is spliced into a `git diff --name-only ""...HEAD` command; + * a value starting with '-' would be taken by git as an option (e.g. + * --output= writes the diff to an arbitrary file) rather than a ref. It + * must be rejected up front, alongside the shell-metacharacter check. */ +TEST(detect_changes_rejects_option_like_base_branch) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":77,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"detect_changes\"," + "\"arguments\":{\"project\":\"p\",\"base_branch\":\"--output=/tmp/cbm_pwn\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "invalid characters")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Opt-in workspace boundary: when CBM_ALLOWED_ROOT is set, index_repository + * must refuse a repo_path that resolves outside it. Unset (the default) imposes + * no restriction. */ +TEST(index_repository_honors_allowed_root) { + char allowed[512]; + snprintf(allowed, sizeof(allowed), "%s/cbm_allowed_XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(allowed)) { + FAIL("cbm_mkdtemp failed"); + } + cbm_setenv("CBM_ALLOWED_ROOT", allowed, 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + char args[1024]; + snprintf(args, sizeof(args), + "{\"jsonrpc\":\"2.0\",\"id\":88,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{\"repo_path\":\"%s/../..\"}}}", + allowed); /* resolves to a parent, outside the allowed root */ + char *resp = cbm_mcp_server_handle(srv, args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "outside the allowed root")); + free(resp); + + cbm_unsetenv("CBM_ALLOWED_ROOT"); + cbm_mcp_server_free(srv); + cbm_rmdir(allowed); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ SUITE(mcp) { + RUN_TEST(mcp_path_within_root_rejects_escape); + RUN_TEST(detect_changes_rejects_option_like_base_branch); + RUN_TEST(index_repository_honors_allowed_root); /* JSON-RPC parsing */ RUN_TEST(jsonrpc_parse_request); RUN_TEST(jsonrpc_parse_notification);