Skip to content
Merged
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
22 changes: 21 additions & 1 deletion allowedpaths/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
matt-dz marked this conversation as resolved.
}
symlinkFound = true
break
}
Expand Down Expand Up @@ -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 {
Expand Down
138 changes: 138 additions & 0 deletions allowedpaths/sandbox_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
matt-dz marked this conversation as resolved.
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]))
}
59 changes: 59 additions & 0 deletions interp/allowed_paths_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
21 changes: 21 additions & 0 deletions interp/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading