diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index 705dbe85..2d04d9cf 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -41,7 +41,8 @@ type root struct { // The restriction is enforced using os.Root (Go 1.24+), which uses openat // syscalls for atomic path validation — immune to symlink and ".." traversal attacks. type Sandbox struct { - roots []root + roots []root + hostPrefix string // when non-empty, enables container symlink resolution } // New creates a sandbox from an allowlist of directory paths. Paths that do @@ -171,6 +172,14 @@ func (s *Sandbox) resolveRootFollowingSymlinks(absPath string, preserveLast bool target = filepath.Join(target, remaining) } absPath = filepath.Clean(target) + // In containers, host symlinks use host-absolute paths + // (e.g. /var/log/pods/...) that don't include the mount + // prefix. Prepend it so the path matches our roots. Skip + // if the path already starts with the prefix (e.g. a + // relative symlink that resolved within the same root). + if s.hostPrefix != "" && !strings.HasPrefix(absPath, s.hostPrefix+string(filepath.Separator)) { + absPath = filepath.Join(s.hostPrefix, absPath) + } symlinkFound = true break } @@ -603,6 +612,17 @@ func (s *Sandbox) Readlink(path string, cwd string) (string, error) { return target, nil } +// SetHostPrefix overrides the mount prefix used to translate host-absolute +// symlink targets inside containers. +func (s *Sandbox) SetHostPrefix(prefix string) { + s.hostPrefix = filepath.Clean(prefix) +} + +// HostPrefix returns the current host mount prefix. +func (s *Sandbox) HostPrefix() string { + return s.hostPrefix +} + // Close releases all os.Root file descriptors. It is safe to call multiple times. func (s *Sandbox) Close() error { if s == nil { diff --git a/allowedpaths/sandbox_unix_test.go b/allowedpaths/sandbox_unix_test.go index f9568edc..e6a85924 100644 --- a/allowedpaths/sandbox_unix_test.go +++ b/allowedpaths/sandbox_unix_test.go @@ -696,3 +696,141 @@ func TestCrossRootSymlinkSiblingDirs(t *testing.T) { assert.NoError(t, sb.Access(filepath.Join(dir2, "sym.txt"), "/", modeRead)) } + +// --- Container /host prefix tests --- + +// newContainerSandbox creates a sandbox with a host prefix set, +// simulating a containerized environment where host filesystems +// are mounted under prefix. +func newContainerSandbox(t *testing.T, paths []string, hostPrefix string) *Sandbox { + t.Helper() + sb, _, err := New(paths) + require.NoError(t, err) + sb.SetHostPrefix(hostPrefix) + return sb +} + +// setupContainerDirs creates a directory structure simulating a container +// where host paths are mounted under a prefix. The symlink target is a +// "host-absolute" path (e.g. /var/log/pods/app.log) that only resolves +// after the host prefix is prepended. The symlink is dangling on the test +// machine, but our code reads it via Readlink and constructs the correct +// path. +func setupContainerDirs(t *testing.T) (hostPrefix, pods, containers string) { + t.Helper() + root := t.TempDir() + hostPrefix = root + pods = filepath.Join(root, "var", "log", "pods") + containers = filepath.Join(root, "var", "log", "containers") + require.NoError(t, os.MkdirAll(pods, 0755)) + require.NoError(t, os.MkdirAll(containers, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(pods, "app.log"), []byte("log line"), 0644)) + // Symlink points to "/var/log/pods/app.log" — a host-absolute path. + // This is dangling on the test machine, but after our code prepends + // hostPrefix (root), it becomes root/var/log/pods/app.log which exists. + require.NoError(t, os.Symlink("/var/log/pods/app.log", filepath.Join(containers, "app.log"))) + return hostPrefix, pods, containers +} + +// TestContainerSymlinkHostPrefixOpen verifies that a symlink using a +// host-absolute path (without the mount prefix) can be opened when +// containerized. +func TestContainerSymlinkHostPrefixOpen(t *testing.T) { + hostPrefix, pods, containers := setupContainerDirs(t) + + sb := newContainerSandbox(t, []string{pods, containers}, hostPrefix) + defer sb.Close() + + f, err := sb.Open("app.log", containers, os.O_RDONLY, 0) + require.NoError(t, err, "container symlink with host prefix should be readable") + defer f.Close() + + buf := make([]byte, 64) + n, _ := f.Read(buf) + assert.Equal(t, "log line", string(buf[:n])) +} + +// TestContainerSymlinkHostPrefixStat verifies Stat through a container +// symlink with host-absolute target. +func TestContainerSymlinkHostPrefixStat(t *testing.T) { + hostPrefix, pods, containers := setupContainerDirs(t) + + sb := newContainerSandbox(t, []string{pods, containers}, hostPrefix) + defer sb.Close() + + info, err := sb.Stat("app.log", containers) + require.NoError(t, err) + assert.Equal(t, int64(8), info.Size()) // "log line" = 8 bytes +} + +// TestContainerSymlinkHostPrefixAccess verifies Access through a container +// symlink with host-absolute target. +func TestContainerSymlinkHostPrefixAccess(t *testing.T) { + hostPrefix, pods, containers := setupContainerDirs(t) + + sb := newContainerSandbox(t, []string{pods, containers}, hostPrefix) + defer sb.Close() + + assert.NoError(t, sb.Access("app.log", containers, modeRead)) +} + +// TestContainerSymlinkHostPrefixNotAppliedWhenNotContainerized verifies +// that the host prefix is NOT prepended when not in a container. +func TestContainerSymlinkHostPrefixNotAppliedWhenNotContainerized(t *testing.T) { + // Use the same setup but don't enable containerized mode. + _, pods, containers := setupContainerDirs(t) + + sb, _, err := New([]string{pods, containers}) + require.NoError(t, err) + defer sb.Close() + + _, err = sb.Open("app.log", containers, os.O_RDONLY, 0) + assert.Error(t, err, "without container mode, host-absolute symlinks should fail") +} + +// TestContainerSymlinkHostPrefixOutsideAllRoots verifies that a container +// symlink pointing outside all allowed roots is still blocked even with +// the host prefix applied. +func TestContainerSymlinkHostPrefixOutsideAllRoots(t *testing.T) { + root := t.TempDir() + hostPrefix := root + containers := filepath.Join(root, "var", "log", "containers") + require.NoError(t, os.MkdirAll(containers, 0755)) + // /etc/secret exists under the host prefix, but is NOT in allowed roots. + outside := filepath.Join(root, "etc", "secret") + require.NoError(t, os.MkdirAll(outside, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "passwd"), []byte("secret"), 0644)) + // Symlink uses host-absolute path to /etc/secret/passwd. + require.NoError(t, os.Symlink("/etc/secret/passwd", filepath.Join(containers, "escape.log"))) + + sb := newContainerSandbox(t, []string{containers}, hostPrefix) + defer sb.Close() + + _, err := sb.Open("escape.log", containers, os.O_RDONLY, 0) + assert.Error(t, err, "container symlink to path outside allowed roots should be blocked") +} + +// TestContainerSymlinkRelativeTarget verifies that a relative symlink in +// container mode resolves correctly without double-prepending the prefix. +func TestContainerSymlinkRelativeTarget(t *testing.T) { + root := t.TempDir() + hostPrefix := root + pods := filepath.Join(root, "var", "log", "pods") + containers := filepath.Join(root, "var", "log", "containers") + require.NoError(t, os.MkdirAll(pods, 0755)) + require.NoError(t, os.MkdirAll(containers, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(pods, "app.log"), []byte("relative"), 0644)) + // Relative symlink — target already resolves within the host prefix. + require.NoError(t, os.Symlink("../pods/app.log", filepath.Join(containers, "app.log"))) + + sb := newContainerSandbox(t, []string{pods, containers}, hostPrefix) + defer sb.Close() + + f, err := sb.Open("app.log", containers, os.O_RDONLY, 0) + require.NoError(t, err, "relative symlink in container mode should not double-prepend prefix") + defer f.Close() + + buf := make([]byte, 64) + n, _ := f.Read(buf) + assert.Equal(t, "relative", string(buf[:n])) +} diff --git a/interp/allowed_paths_internal_test.go b/interp/allowed_paths_internal_test.go index a4469a0d..869b79f5 100644 --- a/interp/allowed_paths_internal_test.go +++ b/interp/allowed_paths_internal_test.go @@ -200,3 +200,62 @@ func TestAllowedPathsExecDefaultBlocksAll(t *testing.T) { assert.Equal(t, 127, exitCode) assert.Contains(t, stderr, "command not found") } + +// TestHostPrefixAfterAllowedPaths verifies that HostPrefix applied after +// AllowedPaths correctly sets the sandbox's host prefix. +func TestHostPrefixAfterAllowedPaths(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("container host prefix is not supported on Windows") + } + dir := t.TempDir() + runner, err := New( + AllowedPaths([]string{dir}), + HostPrefix("/custom"), + ) + require.NoError(t, err) + defer runner.Close() + + assert.Equal(t, "/custom", runner.sandbox.HostPrefix()) +} + +// TestHostPrefixBeforeAllowedPaths verifies that HostPrefix applied before +// AllowedPaths still correctly sets the sandbox's host prefix. +func TestHostPrefixBeforeAllowedPaths(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("container host prefix is not supported on Windows") + } + dir := t.TempDir() + runner, err := New( + HostPrefix("/custom"), + AllowedPaths([]string{dir}), + ) + require.NoError(t, err) + defer runner.Close() + + assert.Equal(t, "/custom", runner.sandbox.HostPrefix()) +} + +// TestHostPrefixWithoutAllowedPaths verifies that HostPrefix is silently +// ignored when no AllowedPaths is configured (sandbox is nil). +func TestHostPrefixWithoutAllowedPaths(t *testing.T) { + runner, err := New( + HostPrefix("/custom"), + ) + require.NoError(t, err) + defer runner.Close() + + assert.Nil(t, runner.sandbox, "sandbox should be nil when AllowedPaths is not set") +} + +// TestHostPrefixDefaultWhenNotSet verifies that the sandbox has no host +// prefix when HostPrefix is not called (container resolution disabled). +func TestHostPrefixDefaultWhenNotSet(t *testing.T) { + dir := t.TempDir() + runner, err := New( + AllowedPaths([]string{dir}), + ) + require.NoError(t, err) + defer runner.Close() + + assert.Empty(t, runner.sandbox.HostPrefix()) +} diff --git a/interp/api.go b/interp/api.go index 0f2bc50d..9fec73ba 100644 --- a/interp/api.go +++ b/interp/api.go @@ -56,6 +56,11 @@ type runnerConfig struct { // are set, so the output target is independent of option ordering. sandboxWarnings []byte + // hostPrefix is stored here so HostPrefix can be applied before or + // after AllowedPaths. Applied to the sandbox in New() after all + // options are processed. + hostPrefix string + // allowedCommands is the set of command names (builtins or external) that // the interpreter is permitted to execute. If nil and allowAllCommands is // false, no commands are allowed. @@ -240,6 +245,11 @@ func New(opts ...RunnerOption) (*Runner, error) { if r.stdout == nil || r.stderr == nil { StdIO(r.stdin, r.stdout, r.stderr)(r) } + // Apply host prefix if set, now that both HostPrefix and AllowedPaths + // have been processed regardless of option ordering. + if r.hostPrefix != "" && r.sandbox != nil { + r.sandbox.SetHostPrefix(r.hostPrefix) + } // Flush any sandbox warnings now that stderr is guaranteed to be set. if len(r.sandboxWarnings) > 0 { r.stderr.Write(r.sandboxWarnings) @@ -569,6 +579,17 @@ func AllowedPaths(paths []string) RunnerOption { } } +// HostPrefix enables container symlink resolution and sets the mount prefix +// used to translate host-absolute symlink targets. When set, symlink targets +// resolved during cross-root fallback are prepended with this prefix. +// Can be applied before or after AllowedPaths. +func HostPrefix(prefix string) RunnerOption { + return func(r *Runner) error { + r.hostPrefix = prefix + return nil + } +} + // AllowedCommands restricts command execution to the specified command names. // Names must use the "rshell:" namespace prefix (e.g. "rshell:cat", // "rshell:find"). Names without a colon separator or with an unknown namespace diff --git a/tests/scenarios/shell/allowed_paths/container_host_prefix_file_commands.yaml b/tests/scenarios/shell/allowed_paths/container_host_prefix_file_commands.yaml new file mode 100644 index 00000000..b8575286 --- /dev/null +++ b/tests/scenarios/shell/allowed_paths/container_host_prefix_file_commands.yaml @@ -0,0 +1,48 @@ +description: File-reading commands work through container symlinks with host-absolute targets +# skip: container host-prefix symlink resolution is an rshell-specific feature +skip_assert_against_bash: true +containerized: true +setup: + files: + - path: host/var/log/pods/app/0.log + content: | + line1 + line2 + line3 + line4 + line5 + - path: host/var/log/containers/app.log + symlink: /var/log/pods/app/0.log +input: + allowed_paths: ["host/var/log"] + script: |+ + echo "=== cat ===" + cat host/var/log/containers/app.log + echo "=== head ===" + head -n 2 host/var/log/containers/app.log + echo "=== tail ===" + tail -n 2 host/var/log/containers/app.log + echo "=== wc ===" + wc -l host/var/log/containers/app.log + echo "=== grep ===" + grep line3 host/var/log/containers/app.log +expect: + stdout: |+ + === cat === + line1 + line2 + line3 + line4 + line5 + === head === + line1 + line2 + === tail === + line4 + line5 + === wc === + 5 host/var/log/containers/app.log + === grep === + line3 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/shell/allowed_paths/container_host_prefix_grep.yaml b/tests/scenarios/shell/allowed_paths/container_host_prefix_grep.yaml new file mode 100644 index 00000000..68963a82 --- /dev/null +++ b/tests/scenarios/shell/allowed_paths/container_host_prefix_grep.yaml @@ -0,0 +1,22 @@ +description: grep works through container symlinks with host-absolute targets +# skip: container host-prefix symlink resolution is an rshell-specific feature +skip_assert_against_bash: true +containerized: true +setup: + files: + - path: host/var/log/pods/app/0.log + content: | + INFO starting up + ERROR something broke + INFO recovered + - path: host/var/log/containers/app.log + symlink: /var/log/pods/app/0.log +input: + allowed_paths: ["host/var/log"] + script: |+ + grep ERROR host/var/log/containers/app.log +expect: + stdout: |+ + ERROR something broke + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/shell/allowed_paths/container_host_prefix_ls.yaml b/tests/scenarios/shell/allowed_paths/container_host_prefix_ls.yaml new file mode 100644 index 00000000..d521746f --- /dev/null +++ b/tests/scenarios/shell/allowed_paths/container_host_prefix_ls.yaml @@ -0,0 +1,19 @@ +description: ls can list files through container symlinks with host-absolute targets +# skip: container host-prefix symlink resolution is an rshell-specific feature +skip_assert_against_bash: true +containerized: true +setup: + files: + - path: host/var/log/pods/pod-abc/0.log + content: "pod log\n" + - path: host/var/log/containers/app.log + symlink: /var/log/pods/pod-abc/0.log +input: + allowed_paths: ["host/var/log"] + script: |+ + ls host/var/log/containers/ +expect: + stdout: |+ + app.log + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/shell/allowed_paths/container_host_prefix_multiple_reads.yaml b/tests/scenarios/shell/allowed_paths/container_host_prefix_multiple_reads.yaml new file mode 100644 index 00000000..bf351c8d --- /dev/null +++ b/tests/scenarios/shell/allowed_paths/container_host_prefix_multiple_reads.yaml @@ -0,0 +1,25 @@ +description: Multiple container symlinks can be read in sequence +# skip: container host-prefix symlink resolution is an rshell-specific feature +skip_assert_against_bash: true +containerized: true +setup: + files: + - path: host/var/log/pods/pod-a/0.log + content: "log a\n" + - path: host/var/log/pods/pod-b/0.log + content: "log b\n" + - path: host/var/log/containers/a.log + symlink: /var/log/pods/pod-a/0.log + - path: host/var/log/containers/b.log + symlink: /var/log/pods/pod-b/0.log +input: + allowed_paths: ["host/var/log"] + script: |+ + cat host/var/log/containers/a.log + cat host/var/log/containers/b.log +expect: + stdout: |+ + log a + log b + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/shell/allowed_paths/container_host_prefix_no_allowed_root.yaml b/tests/scenarios/shell/allowed_paths/container_host_prefix_no_allowed_root.yaml new file mode 100644 index 00000000..1ba507ac --- /dev/null +++ b/tests/scenarios/shell/allowed_paths/container_host_prefix_no_allowed_root.yaml @@ -0,0 +1,19 @@ +description: Container symlink to a host path not under any allowed root is blocked +# skip: container host-prefix symlink resolution is an rshell-specific feature +skip_assert_against_bash: true +containerized: true +setup: + files: + - path: host/opt/data/secret.txt + content: "secret\n" + - path: host/var/log/containers/sneaky.log + symlink: /opt/data/secret.txt +input: + allowed_paths: ["host/var/log"] + script: |+ + cat host/var/log/containers/sneaky.log +expect: + stdout: |+ + stderr_contains: + - "sneaky.log" + exit_code: 1 diff --git a/tests/scenarios/shell/allowed_paths/container_host_prefix_outside_blocked.yaml b/tests/scenarios/shell/allowed_paths/container_host_prefix_outside_blocked.yaml new file mode 100644 index 00000000..1600ab9c --- /dev/null +++ b/tests/scenarios/shell/allowed_paths/container_host_prefix_outside_blocked.yaml @@ -0,0 +1,19 @@ +description: Container symlink to path outside allowed roots is blocked even with host prefix +# skip: container host-prefix symlink resolution is an rshell-specific feature +skip_assert_against_bash: true +containerized: true +setup: + files: + - path: host/etc/secret/passwd + content: "secret\n" + - path: host/var/log/containers/escape.log + symlink: /etc/secret/passwd +input: + allowed_paths: ["host/var/log"] + script: |+ + cat host/var/log/containers/escape.log +expect: + stdout: |+ + stderr_contains: + - "escape.log" + exit_code: 1 diff --git a/tests/scenarios/shell/allowed_paths/container_host_prefix_parent_traversal.yaml b/tests/scenarios/shell/allowed_paths/container_host_prefix_parent_traversal.yaml new file mode 100644 index 00000000..0e817c98 --- /dev/null +++ b/tests/scenarios/shell/allowed_paths/container_host_prefix_parent_traversal.yaml @@ -0,0 +1,19 @@ +description: Container symlink using parent traversal to escape allowed root is blocked +# skip: container host-prefix symlink resolution is an rshell-specific feature +skip_assert_against_bash: true +containerized: true +setup: + files: + - path: host/etc/shadow + content: "root:x\n" + - path: host/var/log/containers/escape.log + symlink: /var/log/../../etc/shadow +input: + allowed_paths: ["host/var/log"] + script: |+ + cat host/var/log/containers/escape.log +expect: + stdout: |+ + stderr_contains: + - "escape.log" + exit_code: 1 diff --git a/tests/scenarios/shell/allowed_paths/container_host_prefix_relative_symlink.yaml b/tests/scenarios/shell/allowed_paths/container_host_prefix_relative_symlink.yaml new file mode 100644 index 00000000..7ae74469 --- /dev/null +++ b/tests/scenarios/shell/allowed_paths/container_host_prefix_relative_symlink.yaml @@ -0,0 +1,19 @@ +description: Relative symlinks in container mode resolve without double-prepending the host prefix +# skip: container host-prefix symlink resolution is an rshell-specific feature +skip_assert_against_bash: true +containerized: true +setup: + files: + - path: host/var/log/pods/app.log + content: "relative log\n" + - path: host/var/log/containers/app.log + symlink: ../pods/app.log +input: + allowed_paths: ["host/var/log"] + script: |+ + cat host/var/log/containers/app.log +expect: + stdout: |+ + relative log + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/shell/allowed_paths/container_host_prefix_symlink.yaml b/tests/scenarios/shell/allowed_paths/container_host_prefix_symlink.yaml new file mode 100644 index 00000000..07e72dce --- /dev/null +++ b/tests/scenarios/shell/allowed_paths/container_host_prefix_symlink.yaml @@ -0,0 +1,19 @@ +description: Container symlink with host-absolute target is readable after /host prefix +# skip: container host-prefix symlink resolution is an rshell-specific feature +skip_assert_against_bash: true +containerized: true +setup: + files: + - path: host/var/log/pods/app.log + content: "container log\n" + - path: host/var/log/containers/app.log + symlink: /var/log/pods/app.log +input: + allowed_paths: ["host/var/log"] + script: |+ + cat host/var/log/containers/app.log +expect: + stdout: |+ + container log + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios_test.go b/tests/scenarios_test.go index a3d6ccb8..d53e2dfa 100644 --- a/tests/scenarios_test.go +++ b/tests/scenarios_test.go @@ -34,11 +34,14 @@ const dockerBashImage = "debian:bookworm-slim" // scenario represents a single test scenario. type scenario struct { - Description string `yaml:"description"` - SkipAssertAgainstBash bool `yaml:"skip_assert_against_bash"` // true = skip bash comparison - Setup setup `yaml:"setup"` - Input input `yaml:"input"` - Expect expected `yaml:"expect"` + Description string `yaml:"description"` + SkipAssertAgainstBash bool `yaml:"skip_assert_against_bash"` // true = skip bash comparison + // Containerized enables container symlink resolution by setting + // HostPrefix to the test directory's host/ subdirectory. + Containerized bool `yaml:"containerized"` + Setup setup `yaml:"setup"` + Input input `yaml:"input"` + Expect expected `yaml:"expect"` } // setup holds optional pre-test configuration such as files to create. @@ -216,6 +219,9 @@ func runScenario(t *testing.T, sc scenario) { // When allow_all_commands is explicitly false and allowed_commands is // empty, no AllowedCommands/AllowAllCommands option is added, so the // interpreter defaults to blocking all commands. + if sc.Containerized { + opts = append(opts, interp.HostPrefix(filepath.Join(dir, "host"))) + } runner, err := interp.New(opts...) require.NoError(t, err, "failed to create runner") defer runner.Close() @@ -454,6 +460,9 @@ func TestShellScenarios(t *testing.T) { sc := loadScenario(t, path) name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) t.Run(name, func(t *testing.T) { + if sc.Containerized && runtime.GOOS == "windows" { + t.Skip("containerized tests are not supported on Windows") + } t.Parallel() runScenario(t, sc) })