mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
* fix(sandbox): avoid shell in FsBridge writes Replace sh -c with interpolated path by shell-free 'tee -- <path>' argv form, piping content via stdin. Prevents command injection through filenames containing shell metacharacters inside the sandbox container. Co-authored-by: evgyur <evgyur@gmail.com> * fix(security): fail-closed on pairing DB errors across channels On IsPaired lookup error, deny instead of granting access. Covers the shared CheckDMPolicy/CheckGroupPolicy helpers (Slack/Discord/Feishu/WhatsApp/Zalo) and the four inline Telegram pairing checks. Co-authored-by: Srini <srinis.k@gmail.com> * fix(security): harden provider URL validation against SSRF Enforce scheme check for all provider types; restrict local types (ollama, claude_cli, acp) to an explicit localhost allowlist instead of skipping checks; resolve remote hostnames and reject any IP in a private/reserved range via the shared security.IsBlocked CIDR list (covers loopback, link-local, metadata, multicast, and unspecified 0.0.0.0/::). Closes the wildcard-DNS bypass and the local-type escape hatch. Operator opt-in via GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS. Exports security.IsBlocked as the single source of truth for blocked ranges. Co-authored-by: Linh Vo Van <linh.vo@e-cq.net> * feat(pipeline): add fail-closed tool call authorization gate Gate tool execution against the server-side AllowedTools allowlist built from the RBAC/tenant-aware filtered tool set. Resolve the tool-call prefix before the allowlist lookup so prefixed agents are not wrongly blocked, re-check deny on lazy MCP activation, and expand IsDenied to cover aliased tool names. Co-authored-by: Huy Doan <tui@pm.me> * fix(security): expand file-serve deny-list defense-in-depth Add absolute-path deny prefixes (/home, /Users, /srv, /var/lib, /var/www, /opt) and an explicit fail-closed log when no file-serving boundary is configured. Co-authored-by: Linh Vo Van <linh.vo@e-cq.net> * fix(providers): allow claude cli executable paths Refs: #1185 --------- Co-authored-by: evgyur <evgyur@gmail.com> Co-authored-by: Srini <srinis.k@gmail.com> Co-authored-by: Linh Vo Van <linh.vo@e-cq.net> Co-authored-by: Huy Doan <tui@pm.me>
190 lines
5.5 KiB
Go
190 lines
5.5 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fakeDockerScript is a fake `docker` binary used in WriteFile tests.
|
|
// It logs all argv to $DOCKER_LOG and, when invoked with a `tee` sub-command,
|
|
// captures stdin to $DOCKER_STDIN.
|
|
// `realpath -e -- <path>` echoes the path back (simulates existing path).
|
|
// `mkdir` exits 0 silently.
|
|
const fakeDockerScript = `#!/bin/sh
|
|
{
|
|
echo CALL
|
|
i=0
|
|
for arg in "$@"; do
|
|
echo "ARG[$i]=$arg"
|
|
i=$((i + 1))
|
|
done
|
|
} >> "$DOCKER_LOG"
|
|
|
|
# realpath: echo the last arg back as the resolved path
|
|
for j in $(seq 0 $#); do
|
|
eval "a=\${$j}"
|
|
if [ "$a" = "realpath" ]; then
|
|
# print the last argument (the path)
|
|
eval "echo \"\${$#}\""
|
|
exit 0
|
|
fi
|
|
done
|
|
|
|
# tee: capture stdin
|
|
for arg in "$@"; do
|
|
if [ "$arg" = "tee" ]; then
|
|
if [ -n "$DOCKER_STDIN" ]; then
|
|
cat > "$DOCKER_STDIN"
|
|
else
|
|
cat > /dev/null
|
|
fi
|
|
exit 0
|
|
fi
|
|
done
|
|
|
|
exit 0
|
|
`
|
|
|
|
// installFakeDocker writes the fake docker script to tmp, prepends tmp to PATH,
|
|
// and sets DOCKER_LOG and optionally DOCKER_STDIN env vars for the test.
|
|
func installFakeDocker(t *testing.T, tmp, logPath, stdinPath string) {
|
|
t.Helper()
|
|
dockerPath := filepath.Join(tmp, "docker")
|
|
if err := os.WriteFile(dockerPath, []byte(fakeDockerScript), 0o755); err != nil {
|
|
t.Fatalf("write fake docker: %v", err)
|
|
}
|
|
t.Setenv("PATH", tmp+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
t.Setenv("DOCKER_LOG", logPath)
|
|
if stdinPath != "" {
|
|
t.Setenv("DOCKER_STDIN", stdinPath)
|
|
}
|
|
}
|
|
|
|
// TestFsBridgeWriteFileDoesNotInvokeShell asserts that WriteFile passes the
|
|
// resolved path as a discrete argv entry to `tee -- <path>` and does NOT
|
|
// build a shell command string (no sh / -c / shell metachar expansion).
|
|
func TestFsBridgeWriteFileDoesNotInvokeShell(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
logPath := filepath.Join(tmp, "docker.log")
|
|
stdinPath := filepath.Join(tmp, "stdin.txt")
|
|
|
|
installFakeDocker(t, tmp, logPath, stdinPath)
|
|
|
|
bridge := NewFsBridge("container-id", "/workspace")
|
|
maliciousPath := `nested/evil$(touch /tmp/goclaw-fsbridge-pwned);name.txt`
|
|
content := "safe content"
|
|
|
|
if err := bridge.WriteFile(context.Background(), maliciousPath, content, false); err != nil {
|
|
t.Fatalf("WriteFile returned error: %v", err)
|
|
}
|
|
|
|
logBytes, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
t.Fatalf("read docker log: %v", err)
|
|
}
|
|
log := string(logBytes)
|
|
|
|
// Must NOT have invoked sh or built a shell string.
|
|
if strings.Contains(log, "=sh") || strings.Contains(log, "=-c") || strings.Contains(log, "cat >") {
|
|
t.Fatalf("WriteFile invoked shell command path; log:\n%s", log)
|
|
}
|
|
// Must have called tee.
|
|
if !strings.Contains(log, "=tee") {
|
|
t.Fatalf("expected write command to use tee without shell; log:\n%s", log)
|
|
}
|
|
// Must have included -- to terminate option parsing.
|
|
if !strings.Contains(log, "=--") {
|
|
t.Fatalf("expected tee delimiter (--) before filename; log:\n%s", log)
|
|
}
|
|
// The malicious path must appear verbatim as a single argv entry (not split/executed).
|
|
resolved := "/workspace/" + maliciousPath
|
|
if !strings.Contains(log, "="+resolved) {
|
|
t.Fatalf("expected malicious filename to remain one argv entry; log:\n%s", log)
|
|
}
|
|
|
|
stdinBytes, err := os.ReadFile(stdinPath)
|
|
if err != nil {
|
|
t.Fatalf("read captured stdin: %v", err)
|
|
}
|
|
if string(stdinBytes) != content {
|
|
t.Fatalf("stdin content = %q, want %q", string(stdinBytes), content)
|
|
}
|
|
}
|
|
|
|
// TestFsBridgeWriteFileAppendUsesTeeAppendArg asserts that append mode passes
|
|
// -a before -- in the tee argv, i.e. `tee -a -- <path>`.
|
|
func TestFsBridgeWriteFileAppendUsesTeeAppendArg(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
logPath := filepath.Join(tmp, "docker.log")
|
|
|
|
installFakeDocker(t, tmp, logPath, "")
|
|
|
|
bridge := NewFsBridge("container-id", "/workspace")
|
|
if err := bridge.WriteFile(context.Background(), "append.txt", "more", true); err != nil {
|
|
t.Fatalf("WriteFile append returned error: %v", err)
|
|
}
|
|
|
|
logBytes, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
t.Fatalf("read docker log: %v", err)
|
|
}
|
|
log := string(logBytes)
|
|
|
|
// Must have: tee, then -a, then --.
|
|
if !strings.Contains(log, "=tee") {
|
|
t.Fatalf("expected tee in argv; log:\n%s", log)
|
|
}
|
|
if !strings.Contains(log, "=-a") {
|
|
t.Fatalf("expected -a flag for append mode; log:\n%s", log)
|
|
}
|
|
if !strings.Contains(log, "=--") {
|
|
t.Fatalf("expected -- delimiter in argv; log:\n%s", log)
|
|
}
|
|
}
|
|
|
|
// TestFsBridgeWriteTeeArgs_Overwrite checks the tee args slice for overwrite mode.
|
|
func TestFsBridgeWriteTeeArgs_Overwrite(t *testing.T) {
|
|
args := fsBridgeWriteTeeArgs("/workspace/file.txt", false)
|
|
if len(args) != 3 {
|
|
t.Fatalf("expected 3 args, got %d: %v", len(args), args)
|
|
}
|
|
if args[0] != "tee" {
|
|
t.Errorf("args[0] = %q, want tee", args[0])
|
|
}
|
|
if args[1] != "--" {
|
|
t.Errorf("args[1] = %q, want --", args[1])
|
|
}
|
|
if args[2] != "/workspace/file.txt" {
|
|
t.Errorf("args[2] = %q, want /workspace/file.txt", args[2])
|
|
}
|
|
// Must not contain -a in overwrite mode.
|
|
for _, a := range args {
|
|
if a == "-a" {
|
|
t.Errorf("overwrite mode must not contain -a: %v", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFsBridgeWriteTeeArgs_Append checks the tee args slice for append mode.
|
|
func TestFsBridgeWriteTeeArgs_Append(t *testing.T) {
|
|
args := fsBridgeWriteTeeArgs("/workspace/file.txt", true)
|
|
if len(args) != 4 {
|
|
t.Fatalf("expected 4 args, got %d: %v", len(args), args)
|
|
}
|
|
if args[0] != "tee" {
|
|
t.Errorf("args[0] = %q, want tee", args[0])
|
|
}
|
|
if args[1] != "-a" {
|
|
t.Errorf("args[1] = %q, want -a", args[1])
|
|
}
|
|
if args[2] != "--" {
|
|
t.Errorf("args[2] = %q, want --", args[2])
|
|
}
|
|
if args[3] != "/workspace/file.txt" {
|
|
t.Errorf("args[3] = %q, want /workspace/file.txt", args[3])
|
|
}
|
|
}
|