Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Every access path is default-deny:
| External commands | Blocked (exit code 127) | Provide an `ExecHandler` |
| Filesystem access | Blocked | Configure `AllowedPaths` with directory list |
| Environment variables| Empty (no host env inherited) | Pass variables via the `Env` option |
| Output redirections | Blocked at validation (exit code 2) | Not configurable — always blocked |
| Output redirections | Only `/dev/null` allowed (exit code 2 for other targets) | `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1` |

**AllowedPaths** restricts all file operations to specified directories using Go's `os.Root` API (`openat` syscalls), making it immune to symlink traversal, TOCTOU races, and `..` escape attacks.

Expand Down
14 changes: 9 additions & 5 deletions SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,18 @@ Blocked features are rejected before execution with exit code 2.
- ✅ `<` — input redirection (read-only, within AllowedPaths)
- ✅ `<<DELIM` — heredoc
- ✅ `<<-DELIM` — heredoc with tab stripping
- ✅ `>/dev/null`, `2>/dev/null` — redirect stdout or stderr to /dev/null (output is discarded; only `/dev/null` is allowed as target)
- ✅ `&>/dev/null` — redirect both stdout and stderr to /dev/null
- ✅ `>>/dev/null`, `&>>/dev/null` — append redirect to /dev/null (same effect as truncate)
- ✅ `2>&1`, `>&2` — file descriptor duplication between stdout (1) and stderr (2)
- ❌ `|&` — pipe stdout and stderr (bash extension)
- ❌ `<<<` — herestring (bash extension)
- ❌ `>` — write/truncate
- ❌ `>>` — append
- ❌ `&>` — redirect all
- ❌ `&>>` — append all
- ❌ `> FILE` — write/truncate to any file other than /dev/null
- ❌ `>> FILE` — append to any file other than /dev/null
- ❌ `&> FILE` — redirect all to any file other than /dev/null
- ❌ `&>> FILE` — append all to any file other than /dev/null
- ❌ `<>` — read-write
- ❌ `>&N` / `<&N` — file descriptor duplication
- ❌ `<&N` — input file descriptor duplication

## Quoting and Expansion

Expand Down
241 changes: 241 additions & 0 deletions interp/redir_devnull_pentest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// 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 interp_test

import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"

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

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

func pentestRedirRun(t *testing.T, script, dir string) (string, string, int) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return pentestRedirRunCtx(ctx, t, script, dir)
}

func pentestRedirRunCtx(ctx context.Context, t *testing.T, script, dir string) (string, string, int) {
t.Helper()
parser := syntax.NewParser()
prog, err := parser.Parse(strings.NewReader(script), "")
if err != nil {
// Parse errors are expected for some pentest cases
return "", err.Error(), 2
}

var outBuf, errBuf bytes.Buffer
opts := []interp.RunnerOption{
interp.StdIO(nil, &outBuf, &errBuf),
}
if dir != "" {
opts = append(opts, interp.AllowedPaths([]string{dir}))
}

runner, err := interp.New(opts...)
require.NoError(t, err)
defer runner.Close()

if dir != "" {
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 {
return outBuf.String(), errBuf.String(), -1 // timeout
} else {
t.Fatalf("unexpected error: %v", err)
}
}
return outBuf.String(), errBuf.String(), exitCode
}

// --- Path traversal attacks ---

func TestPentestRedirPathTraversal(t *testing.T) {
dir := t.TempDir()
tests := []struct {
name string
script string
}{
{"dot-dot traversal", "echo hello > /dev/null/../../tmp/evil"},
{"dot-dot from devnull", "echo hello > /dev/null/../passwd"},
{"double slash", "echo hello > /dev//null"},
{"dot in path", "echo hello > /dev/./null"},
{"trailing slash", "echo hello > /dev/null/"},
{"case variation", "echo hello > /Dev/Null"},
{"relative devnull", "echo hello > dev/null"},
{"bare null", "echo hello > null"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stdout, stderr, code := pentestRedirRun(t, tt.script, dir)
assert.Equal(t, "", stdout, "should produce no stdout")
assert.NotEqual(t, 0, code, "should fail with non-zero exit")
// Should either be validation error (exit 2) or runtime error
assert.True(t, code == 2 || code == 1, "exit code should be 1 or 2, got %d", code)
_ = stderr // error message varies
})
}
}

// --- Variable expansion attacks ---

func TestPentestRedirVariableExpansion(t *testing.T) {
dir := t.TempDir()
tests := []struct {
name string
script string
}{
{"variable target", "TARGET=/dev/null; echo hello > $TARGET"},
{"variable partial", "DEV=/dev; echo hello > $DEV/null"},
{"variable with braces", "TARGET=/dev/null; echo hello > ${TARGET}"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, code := pentestRedirRun(t, tt.script, dir)
assert.Equal(t, 2, code, "variable expansion in redirect target should be blocked at validation")
})
}
}

// --- Quoting attacks ---

func TestPentestRedirQuotedDevNull(t *testing.T) {
dir := t.TempDir()
tests := []struct {
name string
script string
}{
{"single quoted", "echo hello > '/dev/null'"},
{"double quoted", `echo hello > "/dev/null"`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, code := pentestRedirRun(t, tt.script, dir)
// Quoted paths have different AST structure (SglQuoted/DblQuoted vs Lit)
// Our check requires a single Lit part, so quoted paths should be rejected
assert.Equal(t, 2, code, "quoted /dev/null in redirect should be blocked at validation")
})
}
}

// --- Glob/wildcard attacks ---

func TestPentestRedirGlobInTarget(t *testing.T) {
dir := t.TempDir()
// Glob characters in redirect targets
_, _, code := pentestRedirRun(t, "echo hello > /dev/nul?", dir)
assert.Equal(t, 2, code, "glob in redirect target should be rejected")
}

// --- fd duplication attacks ---

func TestPentestRedirFdDupAttacks(t *testing.T) {
dir := t.TempDir()
tests := []struct {
name string
script string
}{
{"fd 0 dup", "echo hello 0>&1"},
{"fd 3 dup", "echo hello 3>&1"},
{"fd 9 dup", "echo hello 9>&1"},
{"fd to 0", "echo hello >&0"},
{"fd to 3", "echo hello >&3"},
{"close fd", "echo hello >&-"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, code := pentestRedirRun(t, tt.script, dir)
assert.Equal(t, 2, code, "unsupported fd duplication should be blocked")
})
}
}

// --- Redirect-to-file attacks that must remain blocked ---

func TestPentestRedirToSensitiveFiles(t *testing.T) {
dir := t.TempDir()
tests := []struct {
name string
script string
}{
{"etc passwd", "echo evil > /etc/passwd"},
{"etc shadow", "echo evil > /etc/shadow"},
{"tmp file", "echo evil > /tmp/evil.txt"},
{"home file", "echo evil > ~/.bashrc"},
{"proc self", "echo evil > /proc/self/mem"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, stderr, code := pentestRedirRun(t, tt.script, dir)
assert.NotEqual(t, 0, code, "redirect to %s should be blocked", tt.name)
_ = stderr
})
}
}

// --- DplIn still blocked ---

func TestPentestRedirDplInBlocked(t *testing.T) {
dir := t.TempDir()
_, stderr, code := pentestRedirRun(t, "echo hello <&0", dir)
assert.Equal(t, 2, code)
assert.Contains(t, stderr, "fd duplication is not supported")
}

// --- Read-write redirect still blocked ---

func TestPentestRedirReadWriteBlocked(t *testing.T) {
dir := t.TempDir()
_, stderr, code := pentestRedirRun(t, "echo hello <> /dev/null", dir)
assert.Equal(t, 2, code)
assert.Contains(t, stderr, "file redirection is not supported")
}

// --- Herestring still blocked ---

func TestPentestRedirHerestringBlocked(t *testing.T) {
dir := t.TempDir()
_, stderr, code := pentestRedirRun(t, "cat <<< 'hello'", dir)
assert.Equal(t, 2, code)
assert.Contains(t, stderr, "herestring")
}

// --- Multiple redirects mixing allowed and blocked ---

func TestPentestRedirMixedAllowedBlocked(t *testing.T) {
dir := t.TempDir()
// First redirect is allowed, second is not
_, _, code := pentestRedirRun(t, "echo hello >/dev/null > /tmp/evil", dir)
assert.Equal(t, 2, code, "mixed redirects with blocked target should fail at validation")
}

// --- Ensure /dev/null redirect doesn't create any files ---

func TestPentestRedirNoFileCreated(t *testing.T) {
dir := t.TempDir()
// Run a redirect to /dev/null
pentestRedirRun(t, "echo hello >/dev/null", dir)

// Simple check: the temp dir should be empty (ls with no flags on empty dir produces no output)
stdout, _, _ := redirRun(t, "ls "+dir, dir)
assert.Equal(t, "", stdout, "no files should have been created")
}
Loading
Loading