diff --git a/src/gateway/http.c b/src/gateway/http.c index 7bcd4bf..7e15571 100644 --- a/src/gateway/http.c +++ b/src/gateway/http.c @@ -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; @@ -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; diff --git a/src/gateway/http.h b/src/gateway/http.h index 24b043f..60da5af 100644 --- a/src/gateway/http.h +++ b/src/gateway/http.h @@ -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. diff --git a/src/sandbox/allowlist.c b/src/sandbox/allowlist.c index c3033ce..f7e128e 100644 --- a/src/sandbox/allowlist.c +++ b/src/sandbox/allowlist.c @@ -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) */ /* ------------------------------------------------------------------ */ @@ -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]; diff --git a/src/sandbox/allowlist.h b/src/sandbox/allowlist.h index 76a4e4f..1bad327 100644 --- a/src/sandbox/allowlist.h +++ b/src/sandbox/allowlist.h @@ -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 diff --git a/tests/test_allowlist.c b/tests/test_allowlist.c index 5d2ac74..cd05629 100644 --- a/tests/test_allowlist.c +++ b/tests/test_allowlist.c @@ -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) */ /* ------------------------------------------------------------------ */ @@ -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;