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
11 changes: 10 additions & 1 deletion internal/codespaces/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func newSSHCommand(ctx context.Context, port int, dst string, cmdArgs []string,

cmdArgs = append(cmdArgs, connArgs...)
cmdArgs = append(cmdArgs, "-C") // Compression
cmdArgs = append(cmdArgs, "--") // end of ssh options
cmdArgs = append(cmdArgs, dst) // user@host

if command != nil {
Expand Down Expand Up @@ -118,6 +119,7 @@ func newSCPCommand(ctx context.Context, port int, dst string, cmdArgs []string)
}

cmdArgs = append(cmdArgs, connArgs...)
cmdArgs = append(cmdArgs, "--") // end of scp options

Comment on lines 121 to 123

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Copilot is right. I think we should respect a user-provided --. WDYT @anumol-baby?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — addressed in latest commit. parseArgs now treats a bare -- as the end-of-options marker: it's dropped, and everything after it becomes the command, so a user-supplied -- is respected.

for _, arg := range command {
// Replace "remote:" prefix with (e.g.) "root@localhost:".
Expand Down Expand Up @@ -149,11 +151,18 @@ func parseSCPArgs(args []string) (cmdArgs, command []string, err error) {

// parseArgs parses arguments into two distinct slices of flags and command. Parsing stops
// as soon as a non-flag argument is found assuming the remaining arguments are the command.
// It returns an error if a unary flag is provided without an argument.
// A bare "--" is treated as the end-of-options marker: it is dropped and everything after it
// is returned as the command. It returns an error if a unary flag is provided without an argument.
func parseArgs(args []string, unaryFlags string) (cmdArgs, command []string, err error) {
for i := 0; i < len(args); i++ {
arg := args[i]

// "--" marks the end of options; everything after it is the command.
if arg == "--" {
command = args[i+1:]
break
}

// if we've started parsing the command, set it to the rest of the args
if !strings.HasPrefix(arg, "-") {
command = args[i:]
Expand Down
105 changes: 105 additions & 0 deletions internal/codespaces/ssh_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package codespaces

import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
)

Expand Down Expand Up @@ -65,6 +71,21 @@ func TestParseSSHArgs(t *testing.T) {
ParsedArgs: []string{"-v"},
Command: []string{"echo", "-b", "test"},
},
{
Args: []string{"-v", "--", "echo", "hi"},
ParsedArgs: []string{"-v"},
Command: []string{"echo", "hi"},
},
{
Args: []string{"--", "-Fconfig", "arg"},
ParsedArgs: []string{},
Command: []string{"-Fconfig", "arg"},
},
{
Args: []string{"-v", "--"},
ParsedArgs: []string{"-v"},
Command: nil,
},
{
Args: []string{"-b"},
ParsedArgs: nil,
Expand Down Expand Up @@ -108,6 +129,11 @@ func TestParseSCPArgs(t *testing.T) {
ParsedArgs: []string{},
Command: []string{"local/file", "remote:file"},
},
{
Args: []string{"--", "-Fconfig", "remote:file"},
ParsedArgs: []string{},
Command: []string{"-Fconfig", "remote:file"},
},
{
Args: []string{"-c"},
ParsedArgs: nil,
Expand Down Expand Up @@ -151,3 +177,82 @@ func checkParseResult(t *testing.T, tcase parseTestCase, gotArgs, gotCmd []strin
t.Errorf("command does not match parsed command. got: '%s', expected: '%s'", commandStr, parsedCommandStr)
}
}

// TestNewSSHCommandUsesEndOfOptionsSeparator asserts that "--" is placed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think these new tests could just be one table test

nit: I know this file doesn't already use it, so this isn't a blocking thing, but it would be ideal if we could modernize the tests by using testify.Assert / testify.Require like the rest of the codebase. Happy to tackle that as a follow-up ourselves if no appetite for it 😁

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To keep this PR focused on the security fix, I'd prefer to leave the table-test consolidation (and the testify migration you mentioned) for a follow-up rather than expand the diff here. Happy to open that follow-up, or I can fold it in now if you'd rather — your call.
Thank you so much for the review.

// immediately before the destination in the ssh argv.
func TestNewSSHCommandUsesEndOfOptionsSeparator(t *testing.T) {
stubExecutablesOnPath(t, "ssh")

cmd, _, err := newSSHCommand(context.Background(), 1234, "user@localhost", []string{"-v"}, []string{"echo", "hello"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// cmd.Args[0] is the ssh executable path; the rest are arguments.
args := cmd.Args[1:]

dashDashIdx := slices.Index(args, "--")
if dashDashIdx == -1 {
t.Fatalf("expected ssh args to contain a \"--\" separator, got: %v", args)
}

dstIdx := slices.Index(args, "user@localhost")
if dstIdx == -1 {
t.Fatalf("expected destination in ssh args, got: %v", args)
}

if dashDashIdx+1 != dstIdx {
t.Errorf("expected \"--\" to immediately precede destination, got args: %v", args)
}
}

// TestNewSCPCommandUsesEndOfOptionsSeparator asserts that "--" precedes
// the file arguments in the scp argv.
func TestNewSCPCommandUsesEndOfOptionsSeparator(t *testing.T) {
stubExecutablesOnPath(t, "scp")

cmd, err := newSCPCommand(context.Background(), 1234, "user@localhost", []string{"local/file", "remote:file"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

args := cmd.Args[1:]

dashDashIdx := slices.Index(args, "--")
if dashDashIdx == -1 {
t.Fatalf("expected scp args to contain a \"--\" separator, got: %v", args)
}

localIdx := slices.Index(args, "local/file")
if localIdx == -1 {
t.Fatalf("expected file arg in scp args, got: %v", args)
}

if dashDashIdx+1 != localIdx {
t.Errorf("expected \"--\" to immediately precede file arguments, got args: %v", args)
}
}

// stubExecutablesOnPath creates empty executable files for names in a temp
// dir and prepends it to PATH for the test, so safeexec.LookPath resolves
// without requiring the real binaries on the host.
func stubExecutablesOnPath(t *testing.T, names ...string) {
t.Helper()

dir := t.TempDir()
for _, name := range names {
if runtime.GOOS == "windows" {
name += ".exe"
}
path := filepath.Join(dir, name)
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0755)
if err != nil {
t.Fatalf("failed to create stub %s: %v", name, err)
}
if err := f.Close(); err != nil {
t.Fatalf("failed to close stub %s: %v", name, err)
}
}

t.Setenv("PATH", strings.Join([]string{dir, os.Getenv("PATH")}, string(os.PathListSeparator)))
}