fix(security): harden env file permissions and block NUL byte injection (#306, #44)

- prepare-env.sh: chmod 600 after .env creation to prevent world-readable secrets
- shell.go: reject commands containing NUL bytes before execution
- credentialed_exec.go: defense-in-depth NUL check in credentialed path
- shell_deny_test.go: add TestExecute_RejectsNULByte with 6 test cases
This commit is contained in:
viettranx committed 2026-03-30 14:11:42 +07:00
1 parent 533ca44e99
commit 011f5f1a3d
4 files changed
+45 -1

No files matched your search

+6 -1
View File
@@ -104,8 +104,13 @@ func matchesBinaryDeny(args []string, denyPatternsJSON json.RawMessage) string {
func (t *ExecTool) executeCredentialed(ctx context.Context, cred *store.SecureCLIBinary,
binary string, args []string, cwd string, sandboxKey string) *Result {
// Step 1: Check for shell operators (early detection for clear error)
// Step 0: Reject NUL bytes (defense-in-depth — also checked in Execute()).
rawCommand := binary + " " + strings.Join(args, " ")
if strings.ContainsRune(rawCommand, '\x00') {
return ErrorResult("command contains invalid NUL byte")
}
// Step 1: Check for shell operators (early detection for clear error)
if ops := detectShellOperators(rawCommand); len(ops) > 0 {
return credentialedShellOperatorError(rawCommand, ops)
}
+5
View File
@@ -113,6 +113,11 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result {
return ErrorResult("command is required")
}
// Reject NUL bytes — they cause silent shell truncation enabling injection.
if strings.ContainsRune(command, '\x00') {
return ErrorResult("command contains invalid NUL byte")
}
// Resolve deny patterns: per-agent overrides from context, fallback to all defaults.
denyOverrides := store.ShellDenyGroupsFromContext(ctx)
groupPatterns := ResolveDenyPatterns(denyOverrides)
+32
View File
@@ -1,7 +1,9 @@
package tools
import (
"context"
"regexp"
"strings"
"testing"
)
@@ -138,6 +140,36 @@ func TestPrivilegeEscalationGaps(t *testing.T) {
)
}
func TestExecute_RejectsNULByte(t *testing.T) {
tool := &ExecTool{} // minimal instance, no sandbox/workspace needed
cases := []struct {
name string
command string
reject bool
}{
{"nul_mid", "ls\x00/etc/passwd", true},
{"nul_mid_echo", "echo hello\x00world", true},
{"nul_prefix", "\x00rm -rf /", true},
{"nul_only", "\x00", true},
{"normal_cmd", "echo normal", false},
{"empty_cmd", "", false}, // handled by "command is required"
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := tool.Execute(context.Background(), map[string]any{"command": tc.command})
hasNULError := result != nil && strings.Contains(result.ForLLM, "NUL byte")
if tc.reject && !hasNULError {
t.Errorf("expected NUL rejection for %q, got: %v", tc.name, result.ForLLM)
}
if !tc.reject && hasNULError {
t.Errorf("unexpected NUL rejection for %q", tc.name)
}
})
}
}
func TestLimitedBuffer(t *testing.T) {
t.Run("under limit", func(t *testing.T) {
lb := &limitedBuffer{max: 100}
+2
View File
@@ -52,9 +52,11 @@ if [ ! -f "$ENV_FILE" ]; then
if [ -f ".env.example" ]; then
# Strip 'export ' prefix for Docker Compose compatibility
sed 's/^export //' .env.example > "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo " [created] .env from .env.example"
else
touch "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo " [created] .env (empty)"
fi
else