From de7ef433fe08c45e16a699c41d7e867aed84b780 Mon Sep 17 00:00:00 2001 From: Ali Firas Date: Wed, 16 Sep 2026 14:10:09 +0300 Subject: [PATCH] Validate package command names before writing binaries --- pkg/commands/subcommands.go | 27 +++++++++++++++++++----- pkg/commands/subcommands_test.go | 36 ++++++++++++++++++++++++++++++++ pkg/packages/golang.go | 17 +++++++++++++-- pkg/packages/golang_test.go | 7 +++++++ pkg/tools/util.go | 15 +++++++++++++ 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/pkg/commands/subcommands.go b/pkg/commands/subcommands.go index ef3fdaf..ef667f6 100644 --- a/pkg/commands/subcommands.go +++ b/pkg/commands/subcommands.go @@ -46,8 +46,8 @@ func readPackage(dir string) (subcommands, error) { return subcommands{}, fmt.Errorf("unable to unmarshal package: %v", err) } - for key := range packageData.Commands { - packageData.Commands[key].Name = strings.ToLower(packageData.Commands[key].Name) + if err := normalizeCommandNames(&packageData); err != nil { + return subcommands{}, err } packageData.Pkg = filepath.Base(strings.Replace(dir, "cli-", "", 1)) @@ -75,8 +75,8 @@ func readPackageFromGithub(url, dir string) (subcommands, error) { packageData.raw = cliJSON - for key := range packageData.Commands { - packageData.Commands[key].Name = strings.ToLower(packageData.Commands[key].Name) + if err := normalizeCommandNames(&packageData); err != nil { + return subcommands{}, err } packageData.Pkg = filepath.Base(strings.Replace(dir, "cli-", "", 1)) @@ -142,6 +142,11 @@ func downloadBin(ctx context.Context, dir string, cmd command) error { cmd.BinSuffix = ".exe" } + commandName, err := tools.NormalizeCommandName(cmd.Name) + if err != nil { + return fmt.Errorf("invalid command name in cli.json: %w", err) + } + t := template.Must(template.New("url").Parse(cmd.Bin)) buf := &bytes.Buffer{} if err := t.Execute(buf, cmd); err != nil { @@ -152,7 +157,7 @@ func downloadBin(ctx context.Context, dir string, cmd command) error { url := buf.String() logger.Debug(fmt.Sprintf("Fetching binary from %s", url)) - binName := filepath.Join(dir, "akamai-"+strings.ToLower(cmd.Name)+cmd.BinSuffix) + binName := filepath.Join(dir, "akamai-"+commandName+cmd.BinSuffix) bin, err := os.Create(binName) if err != nil { logger.Error(fmt.Sprintf("Unable to create %s file: %v", binName, err)) @@ -192,3 +197,15 @@ func downloadBin(ctx context.Context, dir string, cmd command) error { return nil } + +func normalizeCommandNames(packageData *subcommands) error { + for i := range packageData.Commands { + name, err := tools.NormalizeCommandName(packageData.Commands[i].Name) + if err != nil { + return fmt.Errorf("invalid command name in cli.json: %w", err) + } + packageData.Commands[i].Name = name + } + + return nil +} diff --git a/pkg/commands/subcommands_test.go b/pkg/commands/subcommands_test.go index c0eb2db..79cbe79 100644 --- a/pkg/commands/subcommands_test.go +++ b/pkg/commands/subcommands_test.go @@ -1,6 +1,11 @@ package commands import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -44,3 +49,34 @@ func TestReadPackage(t *testing.T) { }) } } + +func TestReadPackageRejectsTraversalCommandName(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "cli.json"), []byte(`{"commands":[{"name":"x/../../escaped"}]}`), 0600)) + + _, err := readPackage(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid command name in cli.json") +} + +func TestReadPackageFromGithubRejectsTraversalCommandName(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(`{"commands":[{"name":"x/../../escaped"}]}`)) + require.NoError(t, err) + })) + defer server.Close() + + _, err := readPackageFromGithub(server.URL, t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid command name in cli.json") +} + +func TestDownloadBinRejectsTraversalCommandName(t *testing.T) { + dir := t.TempDir() + escapedPath := filepath.Join(filepath.Dir(dir), "escaped") + + err := downloadBin(context.Background(), dir, command{Name: "x/../../escaped"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid command name in cli.json") + assert.NoFileExists(t, escapedPath) +} diff --git a/pkg/packages/golang.go b/pkg/packages/golang.go index 3bde44a..a48ba94 100644 --- a/pkg/packages/golang.go +++ b/pkg/packages/golang.go @@ -10,7 +10,6 @@ import ( "os/exec" "path/filepath" "regexp" - "strings" "github.com/akamai/cli/v2/pkg/color" "github.com/akamai/cli/v2/pkg/log" @@ -22,6 +21,12 @@ import ( func (l *langManager) installGolang(ctx context.Context, dir, ver string, commands, ldFlags []string) error { logger := log.FromContext(ctx) + for _, command := range commands { + if _, err := tools.NormalizeCommandName(command); err != nil { + return fmt.Errorf("invalid command name: %w", err) + } + } + goBin, err := l.commandExecutor.LookPath("go") if err != nil { logger.Error("Go executable not found") @@ -72,7 +77,15 @@ func (l *langManager) installGolang(ctx context.Context, dir, ver string, comman for n, command := range commands { ldFlag := ldFlags[n] - execName := "akamai-" + strings.ToLower(command) + commandName, _ := tools.NormalizeCommandName(command) + execName := "akamai-" + commandName + outputPath := filepath.Join(dir, execName) + + // A package repository controls its own contents. Remove a pre-existing + // output entry so go build cannot follow a symlink planted in that repo. + if err := os.Remove(outputPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove existing Go build output %q: %w", outputPath, err) + } var cmd *exec.Cmd params := []string{"build", "-o", execName} diff --git a/pkg/packages/golang_test.go b/pkg/packages/golang_test.go index 28292a3..7b139e8 100644 --- a/pkg/packages/golang_test.go +++ b/pkg/packages/golang_test.go @@ -219,3 +219,10 @@ func TestInstallGolang(t *testing.T) { }) } } + +func TestInstallGolangRejectsTraversalCommandName(t *testing.T) { + l := langManager{new(mocked)} + err := l.installGolang(context.Background(), t.TempDir(), "*", []string{"x/../../escaped"}, []string{""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid command name") +} diff --git a/pkg/tools/util.go b/pkg/tools/util.go index cf0ea91..4ac1a62 100644 --- a/pkg/tools/util.go +++ b/pkg/tools/util.go @@ -1,14 +1,29 @@ package tools import ( + "fmt" "os" "path/filepath" + "regexp" "strings" "github.com/mitchellh/go-homedir" "github.com/urfave/cli/v2" ) +var commandNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +// NormalizeCommandName converts a package command name to its canonical form +// and rejects values that cannot safely be used as a command identifier. +func NormalizeCommandName(name string) (string, error) { + normalized := strings.ToLower(name) + if !commandNamePattern.MatchString(normalized) { + return "", fmt.Errorf("%q must match [A-Za-z0-9][A-Za-z0-9_-]*", name) + } + + return normalized, nil +} + // Self ... func Self() string { return filepath.Base(os.Args[0])