Files
coder/agent/agentcontainers/execer_internal_test.go
T
Zach 112c921235 fix(agent/agentcontainers): prevent command injection in shell execer (#26235)
commandEnvExecer.prepare rebuilt commands into a single shell string
using `fmt.Sprintf("%q", arg)`, which produces Go string literals, not
shell-quoted tokens. Go's %q does not escape `$`, backticks, or other
metacharacters that remain active inside double quotes, so an argument
such as `$(...)` was evaluated by the shell as command substitution.
Arguments flow from devcontainer config and workspace-folder, making
this exploitable.

Pass the command to the shell as positional parameters and run `"$@"` so
the shell forwards argv verbatim without re-parsing it.

The Windows previous handling is not required because Coder doesn't
support devcontainers on Windows, so it is removed.
2026-06-11 14:56:38 -04:00

85 lines
2.4 KiB
Go

package agentcontainers
import (
"bytes"
"context"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/usershell"
"github.com/coder/coder/v2/testutil"
)
func TestCommandEnvExecer_Prepare(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("the POSIX shell quoting under test does not apply on Windows")
}
const shell = "/bin/sh"
commandEnv := func(usershell.EnvInfoer, []string) (string, string, []string, error) {
return shell, "/tmp", []string{"FOO=bar"}, nil
}
e := newCommandEnvExecer(slogtest.Make(t, nil).Leveled(slog.LevelDebug), commandEnv, agentexec.DefaultExecer)
t.Run("ArgvPassthrough", func(t *testing.T) {
t.Parallel()
name, args, dir, env := e.prepare(context.Background(), "echo", "hello", "world")
// The command is run as: shell -c "$@" "" <argv...> so that the
// shell re-emits argv without re-parsing it. The empty $0 slot is
// discarded.
require.Equal(t, shell, name)
require.Equal(t, []string{"-c", `"$@"`, "", "echo", "hello", "world"}, args)
require.Equal(t, "/tmp", dir)
require.Equal(t, []string{"FOO=bar"}, env)
})
t.Run("MetacharactersNotInterpreted", func(t *testing.T) {
t.Parallel()
payloads := []string{
"$(echo INJECTED)",
"`echo INJECTED`",
"$HOME",
"a; echo INJECTED",
"a && echo INJECTED",
"a | echo INJECTED",
"a\necho INJECTED",
"it's a \"test\" \\ end",
"",
}
for _, payload := range payloads {
ctx := testutil.Context(t, testutil.WaitShort)
cmd := e.CommandContext(ctx, "printf", "%s", payload)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
require.NoError(t, cmd.Run(), "payload %q", payload)
assert.Equal(t, payload, out.String(), "payload %q was altered by the shell", payload)
}
})
t.Run("CommandSubstitutionHasNoSideEffect", func(t *testing.T) {
t.Parallel()
marker := filepath.Join(t.TempDir(), "pwned")
ctx := testutil.Context(t, testutil.WaitShort)
cmd := e.CommandContext(ctx, "echo", "$(touch "+marker+")")
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
require.NoError(t, cmd.Run())
require.Equal(t, "$(touch "+marker+")\n", out.String())
require.NoFileExists(t, marker, "command substitution executed; injection is possible")
})
}