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
2 changes: 1 addition & 1 deletion SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Blocked features are rejected before execution with exit code 2.
- ✅ Expansion: `$VAR`, `${VAR}`
- ✅ `$?` — last exit code (the only supported special variable)
- ✅ Inline assignment: `VAR=value command` (scoped to that command)
- ✅ Command substitution: `$(cmd)`, `` `cmd` `` — captures stdout; trailing newlines stripped; `$(<file)` shortcut reads file directly; output capped at 1 MiB
- ✅ Command substitution: `$(cmd)`, `` `cmd` `` — captures stdout; trailing newlines stripped; `$(<file)` shortcut reads file directly (gated on `cat` being in the command allowlist); output capped at 1 MiB
- ❌ Arithmetic expansion: `$(( expr ))`
- ❌ Array assignment: `arr=(a b c)`, `arr[0]=x`
- ❌ Append assignment: `VAR+=value`
Expand Down
13 changes: 13 additions & 0 deletions interp/runner_expand.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,26 @@ const MaxGlobReadDirCalls = 10_000

// cmdSubst handles command substitution ($(...) and `...`).
// It runs the commands in a subshell and writes their stdout to w.
//
// Special case: the POSIX `$(<file)` shortcut reads file contents
// directly without spawning a subshell. Because that path performs a
// file read without invoking any command, it would bypass the
// AllowedCommands allowlist if left unchecked. We therefore gate the
// shortcut on `cat` being an allowed command — `$(<file)` is treated as
// an implicit `$(cat file)` for allowlist purposes.
func (r *Runner) cmdSubst(w io.Writer, cs *syntax.CmdSubst) error {
if len(cs.Stmts) == 0 {
return nil
}

// $(<file) shortcut: read file contents directly without a subshell.
if word := catShortcutArg(cs.Stmts[0]); word != nil && len(cs.Stmts) == 1 {
if !r.allowAllCommands && !r.allowedCommands["cat"] {
Comment thread
matt-dz marked this conversation as resolved.
r.errf("$(<file): file read not permitted (cat not in allowed commands)\n")
r.lastExpandExit = exitStatus{code: 1}
r.lastExit = r.lastExpandExit
return nil
}
path := r.literal(word)
f, err := r.open(r.ectx, path, os.O_RDONLY, 0, true)
if err != nil {
Expand Down
35 changes: 35 additions & 0 deletions interp/tests/cmdsubst_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,41 @@ func TestCmdSubstCatShortcutMissingFile(t *testing.T) {
assert.Equal(t, "1\n", stdout, "$? should be 1 from the failed substitution")
}

// TestCmdSubstCatShortcutCommandAllowlistBypass is the regression test
// for the original vulnerability: $(<file) must be gated on `cat` being
// in AllowedCommands. With the file in AllowedPaths but cat absent from
// the allowlist, the shortcut must refuse to read.
func TestCmdSubstCatShortcutCommandAllowlistBypass(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("top secret"), 0644))
stdout, stderr, code := cmdSubstRunWithOpts(t,
`x=$(<secret.txt); echo "[$x]"`,
dir,
interp.AllowedPaths([]string{dir}),
interp.AllowedCommands([]string{"rshell:echo"}),
)
assert.Equal(t, 0, code, "script completes; only the substitution fails")
assert.Equal(t, "[]\n", stdout, "must not leak the file contents")
assert.Contains(t, stderr, "file read not permitted")
assert.Contains(t, stderr, "cat not in allowed commands")
}

// TestCmdSubstCatShortcutAllowedWhenCatAllowed verifies the shortcut
// works when cat is explicitly in AllowedCommands (not just
// AllowAllCommands).
func TestCmdSubstCatShortcutAllowedWhenCatAllowed(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "data.txt"), []byte("ok"), 0644))
stdout, _, code := cmdSubstRunWithOpts(t,
`x=$(<data.txt); echo "$x"`,
dir,
interp.AllowedPaths([]string{dir}),
interp.AllowedCommands([]string{"rshell:cat", "rshell:echo"}),
)
assert.Equal(t, 0, code)
assert.Equal(t, "ok\n", stdout)
}

// --- For loop integration ---

func TestCmdSubstInForLoop(t *testing.T) {
Expand Down
271 changes: 271 additions & 0 deletions interp/tests/redir_input_bypass_pentest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2026-present Datadog, Inc.

package tests_test

import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"mvdan.cc/sh/v3/syntax"

"github.com/DataDog/rshell/interp"
)

// Pentest suite for the `$(<file)` command-allowlist bypass.
//
// Historical vulnerability: the `$(<file)` POSIX shortcut in command
// substitution was resolved by reading the file directly from the
// interpreter, short-circuiting the normal command dispatch. That path
// never consulted AllowedCommands, so a caller who had `rshell:echo`
// whitelisted but not `rshell:cat` could still read any file inside
// AllowedPaths via `x=$(<file); echo "$x"`.
//
// The mitigation keeps the shortcut functional but gates it on `cat`
// being in the allowlist — $(<file) is treated as an implicit
// $(cat file) for allowlist purposes. These tests verify both the gate
// (reads refused when cat is missing) and the happy path (reads work
// when cat is allowed).

// runBypass runs script with AllowedPaths=[dir] and the given allowed
// commands. It returns stdout, stderr, and exit code.
func runBypass(t *testing.T, script, dir string, allowedCmds []string) (string, string, int) {
t.Helper()
parser := syntax.NewParser()
prog, err := parser.Parse(strings.NewReader(script), "")
if err != nil {
return "", err.Error(), 2
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

var outBuf, errBuf bytes.Buffer
opts := []interp.RunnerOption{
interp.StdIO(nil, &outBuf, &errBuf),
interp.AllowedPaths([]string{dir}),
interp.AllowedCommands(allowedCmds),
}
runner, err := interp.New(opts...)
require.NoError(t, err)
defer runner.Close()
runner.Dir = dir

err = runner.Run(ctx, prog)
exitCode := 0
if err != nil {
var es interp.ExitStatus
if errors.As(err, &es) {
exitCode = int(es)
} else if ctx.Err() == nil {
t.Fatalf("unexpected error: %v", err)
}
}
return outBuf.String(), errBuf.String(), exitCode
}

// TestPentestCatShortcut_BlockedWhenCatNotAllowed is the exact exploit
// reported: cat is NOT in the allowlist, but a caller tries to read a
// file via $(<file). The gate must refuse without leaking content.
func TestPentestCatShortcut_BlockedWhenCatNotAllowed(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("TOP SECRET"), 0644))

stdout, stderr, code := runBypass(t,
`x=$(<secret.txt); echo "[$x]"`,
dir,
[]string{"rshell:echo"},
)
// Script completes (echo runs); only the substitution fails with
// $?=1 and a diagnostic message. stdout shows the empty
// substitution, not the file contents.
assert.Equal(t, 0, code)
assert.Equal(t, "[]\n", stdout, "must not leak file contents")
assert.Contains(t, stderr, "file read not permitted")
assert.Contains(t, stderr, "cat not in allowed commands")
assert.NotContains(t, stdout, "TOP SECRET")
}

// TestPentestCatShortcut_AllowedWhenCatAllowed verifies the happy path:
// with cat in the allowlist the shortcut reads the file as expected.
func TestPentestCatShortcut_AllowedWhenCatAllowed(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "data.txt"), []byte("content"), 0644))

stdout, stderr, code := runBypass(t,
`x=$(<data.txt); echo "[$x]"`,
dir,
[]string{"rshell:cat", "rshell:echo"},
)
assert.Equal(t, 0, code, "should succeed when cat is allowed; stderr: %s", stderr)
assert.Equal(t, "[content]\n", stdout)
}

// TestPentestCatShortcut_ExitStatusPropagates verifies that the
// blocked-shortcut sets $?=1 so scripts can detect the refusal with
// standard control flow.
func TestPentestCatShortcut_ExitStatusPropagates(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("nope"), 0644))

stdout, _, code := runBypass(t,
`x=$(<secret.txt); echo "$?"`,
dir,
[]string{"rshell:echo"},
)
assert.Equal(t, 0, code)
assert.Equal(t, "1\n", stdout, "$? must be 1 after the blocked shortcut")
}

// TestPentestCatShortcut_NoFileAccessWhenBlocked verifies that when the
// gate refuses, no file is opened — a missing file yields the
// allowlist-denial message, not a "no such file" error.
func TestPentestCatShortcut_NoFileAccessWhenBlocked(t *testing.T) {
dir := t.TempDir()
_, stderr, code := runBypass(t,
`x=$(<nonexistent-path.txt); echo "$x"`,
dir,
[]string{"rshell:echo"},
)
assert.Equal(t, 0, code)
assert.Contains(t, stderr, "cat not in allowed commands")
assert.NotContains(t, strings.ToLower(stderr), "no such file",
"gate should short-circuit before any filesystem access")
}

// TestPentestCatShortcut_SandboxEnforcedWhenCatAllowed verifies that
// permitting `cat` in AllowedCommands does not weaken AllowedPaths.
// Even with the gate satisfied, a path outside the sandbox must be
// refused by the path layer — the two defences are independent.
func TestPentestCatShortcut_SandboxEnforcedWhenCatAllowed(t *testing.T) {
dir := t.TempDir()
// Put a file somewhere outside `dir` so we have a concrete target
// to attempt reading.
outside := t.TempDir()
outsidePath := filepath.Join(outside, "secret.txt")
require.NoError(t, os.WriteFile(outsidePath, []byte("CROSS-BOUNDARY"), 0644))

// `echo "$?[$x]"` is evaluated before echo runs, so $? still
// carries the substitution's exit code (1 on failure) and $x is
// empty if the read was refused. The trailing echo prevents the
// script's top-level exit code from being overwritten.
script := `x=$(<` + outsidePath + `); echo "$?[$x]"`
stdout, stderr, code := runBypass(t, script, dir,
[]string{"rshell:cat", "rshell:echo"})

assert.Equal(t, 0, code)
assert.Equal(t, "1[]\n", stdout,
"substitution should fail ($?=1) and bind empty content")
assert.NotContains(t, stdout, "CROSS-BOUNDARY", "file contents must not leak")
assert.NotContains(t, stderr, "cat not in allowed commands",
"failure should be path-level, not gate-level")
assert.NotEmpty(t, stderr, "sandbox layer should have logged an error")
}

// TestPentestCatShortcut_SymlinkEscapeBlocked verifies that a symlink
// planted inside AllowedPaths pointing outside is refused by the
// sandbox. The $(<file) shortcut uses the same r.open path as builtins,
// so this is really a sanity check that symlinks do not bypass os.Root.
func TestPentestCatShortcut_SymlinkEscapeBlocked(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires elevation on Windows")
}
dir := t.TempDir()
outside := t.TempDir()
outsidePath := filepath.Join(outside, "secret.txt")
require.NoError(t, os.WriteFile(outsidePath, []byte("SYMLINK TARGET"), 0644))

// Plant a symlink inside the sandbox that points outside it.
linkPath := filepath.Join(dir, "escape.lnk")
require.NoError(t, os.Symlink(outsidePath, linkPath))

stdout, stderr, code := runBypass(t,
`x=$(<escape.lnk); echo "$?[$x]"`,
dir,
[]string{"rshell:cat", "rshell:echo"})

assert.Equal(t, 0, code)
assert.Equal(t, "1[]\n", stdout,
"symlink escape should fail ($?=1) and bind empty content")
assert.NotContains(t, stdout, "SYMLINK TARGET", "contents behind symlink must not leak")
assert.NotContains(t, stderr, "cat not in allowed commands",
"refusal should be sandbox-level, not gate-level")
assert.NotEmpty(t, stderr, "sandbox layer should have logged an error")
}

// TestPentestCatShortcut_VariableExpandedPath verifies that the
// shortcut path goes through normal word expansion — an attacker
// cannot dodge the allowlist check by hiding the path behind a
// variable, and a legitimate user can use variables to build paths.
func TestPentestCatShortcut_VariableExpandedPath(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "data.txt"), []byte("expanded"), 0644))

// Happy path: expanded path works when cat is allowed.
t.Run("allowed", func(t *testing.T) {
stdout, stderr, code := runBypass(t,
`F=data.txt; x=$(<$F); echo "[$x]"`,
dir,
[]string{"rshell:cat", "rshell:echo"})
assert.Equal(t, 0, code, "should succeed; stderr: %s", stderr)
assert.Equal(t, "[expanded]\n", stdout)
})

// The gate fires regardless of whether the path is literal or
// variable-expanded — variables cannot be used as an evasion.
t.Run("blocked", func(t *testing.T) {
stdout, stderr, code := runBypass(t,
`F=data.txt; x=$(<$F); echo "[$x]"`,
dir,
[]string{"rshell:echo"})
assert.Equal(t, 0, code)
assert.Equal(t, "[]\n", stdout, "must not leak content via variable-expanded path")
assert.Contains(t, stderr, "cat not in allowed commands")
})
}

// TestPentestCatShortcut_VariousContexts exercises the shortcut in
// several expansion contexts (nested echo arg, if condition, for
// iterator). In every context it must succeed when cat is allowed and
// refuse when cat is not.
func TestPentestCatShortcut_VariousContexts(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "data.txt"), []byte("alpha"), 0644))

contexts := []struct {
name string
script string
}{
{"assignment", `x=$(<data.txt); echo "$x"`},
{"echo arg", `echo "[$(<data.txt)]"`},
{"backtick", "echo \"[`<data.txt`]\""},
{"for iterator", `for x in $(<data.txt); do echo "$x"; done`},
}

for _, tc := range contexts {
t.Run(tc.name+"/allowed", func(t *testing.T) {
stdout, stderr, code := runBypass(t, tc.script, dir,
[]string{"rshell:cat", "rshell:echo"})
assert.Equal(t, 0, code, "should succeed; stderr: %s", stderr)
assert.Contains(t, stdout, "alpha")
})
t.Run(tc.name+"/blocked", func(t *testing.T) {
stdout, stderr, code := runBypass(t, tc.script, dir,
[]string{"rshell:echo"})
assert.Equal(t, 0, code)
assert.NotContains(t, stdout, "alpha",
"blocked shortcut must not leak file contents")
assert.Contains(t, stderr, "cat not in allowed commands")
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
description: $(<file) must not bypass the command allowlist — with the file in allowed_paths but cat NOT in allowed_commands, the shortcut is blocked at runtime.
skip_assert_against_bash: true
setup:
files:
- path: secret.txt
content: "top secret"
input:
allowed_paths: ["$DIR"]
allowed_commands: ["rshell:echo"]
Comment thread
matt-dz marked this conversation as resolved.
script: |+
x=$(<secret.txt)
echo "$x"
expect:
stdout: |+

stderr: |+
$(<file): file read not permitted (cat not in allowed commands)
exit_code: 0
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
description: $(<file) shortcut reads file contents directly.
description: $(<file) shortcut reads file contents directly when cat is allowed.
setup:
files:
- path: data.txt
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
description: $(<file) shortcut works when rshell:cat is explicitly in allowed_commands (inverse of blocked_redirects/cat_shortcut_bypass).
skip_assert_against_bash: true
setup:
files:
- path: data.txt
content: "hello from file"
input:
allowed_paths: ["$DIR"]
allowed_commands: ["rshell:cat", "rshell:echo"]
script: |+
x=$(<data.txt)
echo "[$x]"
expect:
stdout: |+
[hello from file]
stderr: |+
exit_code: 0
Loading