Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/gateway/http.c
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ int http_start(const config_t *cfg, struct auth_ctx *auth_ctx, const char *confi
if (!cfg || !auth_ctx || g_ctx) return -1;
const char *host = config_gateway_host(cfg);
int port = config_gateway_port(cfg);
if (strcmp(host, "0.0.0.0") == 0 && !config_gateway_allow_bind_all(cfg))
int bind_all = (strcmp(host, "0.0.0.0") == 0 || strcmp(host, "*") == 0);
if (bind_all && !config_gateway_allow_bind_all(cfg))
return -1;
http_server_ctx_t *ctx = calloc(1, sizeof(*ctx));
if (!ctx) return -1;
Expand All @@ -121,6 +122,12 @@ int http_start(const config_t *cfg, struct auth_ctx *auth_ctx, const char *confi
struct lws_context_creation_info info;
memset(&info, 0, sizeof(info));
info.port = port;
/*
* LWS: iface NULL binds INADDR_ANY (all interfaces). Config host defaults
* to 127.0.0.1 — pass it through so operators are not silently exposed.
* allow_bind_all + host 0.0.0.0/★ keeps iface NULL for intentional LAN bind.
*/
info.iface = bind_all ? NULL : host;
info.protocols = protocols;
#if defined(LWS_SERVER_OPTION_HTTP_HEADERS_SECURITY_BEST_PRACTICES_ENFORCE)
info.options = LWS_SERVER_OPTION_HTTP_HEADERS_SECURITY_BEST_PRACTICES_ENFORCE;
Expand Down
3 changes: 2 additions & 1 deletion src/gateway/http.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ struct auth_ctx;

/**
* Start HTTP+WebSocket server on config host:port.
* Rejects bind to 0.0.0.0 if allow_bind_all is false.
* Binds the listen socket to config_gateway_host (default 127.0.0.1 via
* info.iface). Rejects host 0.0.0.0/"*" unless allow_bind_all is true.
*
* @param cfg Configuration (host, port, allow_bind_all).
* @param auth_ctx Auth context for token validation.
Expand Down
134 changes: 131 additions & 3 deletions src/sandbox/allowlist.c
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,129 @@ static void set_reason(char *buf, size_t cap, const char *prefix, const char *de
buf[cap - 1] = '\0';
}

/** Return 1 if @p s begins with prefix after any leading whitespace. */
/** Return 1 if @p s begins with a path-like character. */
static int has_path_chars(const char *tok)
{
if (!tok) return 0;
return tok[0] == '/' || tok[0] == '~' || tok[0] == '.';
}

/**
* Strip one layer of matching surrounding quotes from @p tok in place.
* Returns the (possibly advanced) start of the unquoted token.
*/
static char *strip_surrounding_quotes(char *tok)
{
size_t n;
if (!tok || !tok[0]) return tok;
n = strlen(tok);
if (n >= 2 && ((tok[0] == '\'' && tok[n - 1] == '\'') ||
(tok[0] == '"' && tok[n - 1] == '"'))) {
tok[n - 1] = '\0';
return tok + 1;
}
return tok;
}

/**
* Return 1 if @p c is allowed inside an absolute path fragment we extract
* from command text (conservative; stops before shell metacharacters).
*/
static int is_path_body_char(unsigned char c)
{
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '/' || c == '.' || c == '_' ||
c == '-' || c == '+' || c == '%' || c == '@';
}

/**
* Return 1 when @p p in @p text starts a filesystem absolute path (`/` or `~`),
* not a slash inside `src/foo`, `3/4`, or a URL scheme `://`.
*/
static int is_fs_absolute_path_start(const char *text, const char *p)
{
unsigned char prev;
if (!text || !p || (*p != '/' && *p != '~'))
return 0;
if (p == text)
return 1;
prev = (unsigned char)p[-1];
/* URL scheme slashes in http:// and file:// — do not treat them as FS paths. */
if (*p == '/' && prev == ':')
return 0;
if (*p == '/' && p >= text + 2 && p[-1] == '/' && p[-2] == ':')
return 0;
/* Relative "src/foo" or "3/4": slash continues an existing token. */
if (is_path_body_char(prev) && prev != '/')
return 0;
return 1;
}

/**
* Copy a `~` fragment into @p dest, expanding `$HOME` the same way token checks do.
* @return 0 on success, -1 if the expanded path does not fit.
*/
static int expand_tilde_fragment(const char *fragment, char *dest, size_t dest_cap)
{
const char *home;
int n;
if (!fragment || !dest || dest_cap == 0)
return -1;
if (fragment[0] != '~') {
if (strlen(fragment) >= dest_cap)
return -1;
memcpy(dest, fragment, strlen(fragment) + 1);
return 0;
}
home = getenv("HOME");
if (!home)
home = "";
n = snprintf(dest, dest_cap, "%s%s", home, fragment + 1);
if (n < 0 || (size_t)n >= dest_cap)
return -1;
return 0;
}

/**
* Scan @p text for absolute (~ or /) path fragments and reject any that escape
* @p workspace_root. Catches quoted / embedded paths the whitespace tokenizer misses
* (e.g. python3 -c "open('/etc/passwd')").
* @return 1 if blocked, 0 if all fragments are under the workspace.
*/
static int block_if_embedded_paths_escape(const char *text, const char *workspace_root,
char *reason_buf, size_t reason_cap)
{
const char *p;
if (!text || !workspace_root) return 0;
for (p = text; *p; p++) {
char fragment[PATH_MAX];
char expanded[PATH_MAX];
size_t n = 0;
const char *start;
if (!is_fs_absolute_path_start(text, p))
continue;
start = p;
fragment[n++] = *p++;
while (*p && is_path_body_char((unsigned char)*p) && n + 1 < sizeof(fragment))
fragment[n++] = *p++;
fragment[n] = '\0';
if (expand_tilde_fragment(fragment, expanded, sizeof(expanded)) != 0) {
set_reason(reason_buf, reason_cap,
"command blocked: path escapes workspace: ", fragment);
return 1;
}
if (!allowlist_path_is_under_workspace(expanded, workspace_root)) {
set_reason(reason_buf, reason_cap,
"command blocked: path escapes workspace: ", expanded);
fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", expanded);
return 1;
}
if (p > start)
p--;
}
return 0;
}

/* ------------------------------------------------------------------ */
/* Public: path-under-workspace check (5.4) */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -161,11 +277,23 @@ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg
ws_resolved[n] = '\0';
}
workspace_root = ws_resolved;
/* Tokenize the command and check each path-like token. */
/*
* Scan the full command for embedded absolute paths first. Whitespace
* tokenization alone misses quoted paths (cat '/etc/passwd') and paths
* inside -c / eval strings. Sandbox namespaces do not chroot, so this
* scan is the primary workspace FS gate when workspace_only is set.
*/
if (block_if_embedded_paths_escape(cmd, workspace_root, reason_buf, reason_cap))
return 1;
/* Tokenize the command and check each path-like token (incl. relative ./). */
cmd_copy = strdup(cmd);
if (!cmd_copy) return 0; /* fail-open on OOM */
if (!cmd_copy) {
set_reason(reason_buf, reason_cap, "command blocked: out of memory", "");
return 1; /* fail-closed on OOM */
}
tok = strtok_r(cmd_copy, " \t\n;|&><", &saveptr);
while (tok) {
tok = strip_surrounding_quotes(tok);
if (has_path_chars(tok)) {
/* Expand a leading tilde naively */
char expanded[PATH_MAX];
Expand Down
5 changes: 3 additions & 2 deletions src/sandbox/allowlist.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
* when they escape the declared workspace root.
*
* Both checks are intentionally conservative and may produce false positives.
* They are a best-effort defence-in-depth layer; real isolation is provided by
* sandbox_exec() via kernel namespaces.
* They are a best-effort defence-in-depth layer. sandbox_exec() isolates
* mount/network/PID namespaces but does not chroot/pivot_root; workspace_only
* path scanning is therefore the primary host-filesystem gate for the shell tool.
*/
#ifndef SHELLCLAW_ALLOWLIST_H
#define SHELLCLAW_ALLOWLIST_H
Expand Down
55 changes: 55 additions & 0 deletions tests/test_allowlist.c
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,57 @@ static int test_workspace_only_allows_inside_path(void)
return 0;
}

static int test_workspace_only_blocks_quoted_path(void)
{
allowlist_config_t cfg;
char reason[256];
cfg.workspace_path = "/tmp";
cfg.workspace_only = 1;
reason[0] = '\0';
ASSERT(allowlist_check_shell_command("cat '/etc/passwd'", &cfg, reason, sizeof(reason)) == 1);
ASSERT(allowlist_check_shell_command("cat \"/etc/passwd\"", &cfg, reason, sizeof(reason)) == 1);
return 0;
}

static int test_workspace_only_blocks_embedded_path_in_python(void)
{
allowlist_config_t cfg;
char reason[256];
cfg.workspace_path = "/tmp";
cfg.workspace_only = 1;
reason[0] = '\0';
ASSERT(allowlist_check_shell_command(
"python3 -c \"open('/etc/passwd').read()\"", &cfg, reason, sizeof(reason)) == 1);
ASSERT(strstr(reason, "passwd") != NULL || strstr(reason, "workspace") != NULL);
return 0;
}

static int test_workspace_only_allows_relative_and_url_slashes(void)
{
allowlist_config_t cfg;
char reason[256];
cfg.workspace_path = "/tmp";
cfg.workspace_only = 1;
reason[0] = '\0';
/* Slash inside a relative token or URL must not be treated as /foo. */
ASSERT(allowlist_check_shell_command("echo 3/4", &cfg, reason, sizeof(reason)) == 0);
ASSERT(allowlist_check_shell_command("ls src/foo", &cfg, reason, sizeof(reason)) == 0);
ASSERT(allowlist_check_shell_command(
"curl https://example.com/api", &cfg, reason, sizeof(reason)) == 0);
return 0;
}

static int test_workspace_only_blocks_file_url(void)
{
allowlist_config_t cfg;
char reason[256];
cfg.workspace_path = "/tmp";
cfg.workspace_only = 1;
reason[0] = '\0';
ASSERT(allowlist_check_shell_command("cat file:///etc/passwd", &cfg, reason, sizeof(reason)) == 1);
return 0;
}

/* ------------------------------------------------------------------ */
/* Symlink escape test (5.4) */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -205,6 +256,10 @@ int main(void)
RUN(test_path_prefix_no_slash());
RUN(test_workspace_only_blocks_outside_path());
RUN(test_workspace_only_allows_inside_path());
RUN(test_workspace_only_blocks_quoted_path());
RUN(test_workspace_only_blocks_embedded_path_in_python());
RUN(test_workspace_only_allows_relative_and_url_slashes());
RUN(test_workspace_only_blocks_file_url());
RUN(test_symlink_escape());
printf("test_allowlist: all tests passed\n");
return 0;
Expand Down
Loading