diff --git a/cmd/entire/cli/git_operations.go b/cmd/entire/cli/git_operations.go index f75e018cd..3b295278a 100644 --- a/cmd/entire/cli/git_operations.go +++ b/cmd/entire/cli/git_operations.go @@ -291,6 +291,9 @@ func BranchExistsLocally(branchName string) (bool, error) { // Should be switched back to go-git once we upgrade to go-git v6 // Returns an error if the ref doesn't exist or checkout fails. func CheckoutBranch(ref string) error { + if strings.HasPrefix(ref, "-") { + return fmt.Errorf("checkout failed: invalid ref %q", ref) + } ctx := context.Background() cmd := exec.CommandContext(ctx, "git", "checkout", ref) if output, err := cmd.CombinedOutput(); err != nil { diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index 9bd0f92f7..f2430e381 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" @@ -163,6 +164,35 @@ func TestCheckoutBranch(t *testing.T) { t.Error("CheckoutBranch() expected error for nonexistent branch, got nil") } }) + + t.Run("rejects ref starting with dash to prevent argument injection", func(t *testing.T) { + // "git checkout -b evil" would create a new branch named "evil" instead + // of failing, because git interprets "-b" as a flag. + err := CheckoutBranch("-b evil") + if err == nil { + t.Fatal("CheckoutBranch() should reject refs starting with '-', got nil") + } + if !strings.Contains(err.Error(), "invalid ref") { + t.Errorf("CheckoutBranch() error = %q, want error containing 'invalid ref'", err.Error()) + } + }) +} + +func TestPerformGitResetHard_RejectsArgumentInjection(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + setupResumeTestRepo(t, tmpDir, false) + + // "git reset --hard -q" would silently reset to HEAD in quiet mode instead + // of failing, because git interprets "-q" as the --quiet flag. + err := performGitResetHard("-q") + if err == nil { + t.Fatal("performGitResetHard() should reject hashes starting with '-', got nil") + } + if !strings.Contains(err.Error(), "invalid commit hash") { + t.Errorf("performGitResetHard() error = %q, want error containing 'invalid commit hash'", err.Error()) + } } func TestResumeFromCurrentBranch_NoCheckpoint(t *testing.T) { diff --git a/cmd/entire/cli/rewind.go b/cmd/entire/cli/rewind.go index 026ee529a..47bd247dc 100644 --- a/cmd/entire/cli/rewind.go +++ b/cmd/entire/cli/rewind.go @@ -1179,6 +1179,9 @@ func countCommitsBetween(repo *git.Repository, ancestor, descendant plumbing.Has // Uses the git CLI instead of go-git because go-git's HardReset incorrectly // deletes untracked directories (like .entire/) even when they're in .gitignore. func performGitResetHard(commitHash string) error { + if strings.HasPrefix(commitHash, "-") { + return fmt.Errorf("reset failed: invalid commit hash %q", commitHash) + } ctx := context.Background() cmd := exec.CommandContext(ctx, "git", "reset", "--hard", commitHash) if output, err := cmd.CombinedOutput(); err != nil {