diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cffbe27..47c4f7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,17 @@ jobs: # Optional: show only new issues if it's a pull request. The default value is `false`. only-new-issues: true working-directory: ./ + + fuzz: + name: fuzz tests + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.go-version }} + cache: true + - uses: actions/checkout@v4 + - name: Build CLI binary + run: make build + - name: Run fuzz tests + run: make e2e-fuzz diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7fe16d..55b87e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,9 @@ jobs: MUMP2P_E2E_TOKEN_B64: ${{ secrets.MUMP2P_E2E_TOKEN_B64 }} run: make e2e-test + - name: Run Fuzz Tests + run: make e2e-fuzz + - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: diff --git a/Makefile b/Makefile index d02e8cb..e0adfe2 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ LD_FLAGS := -X github.com/getoptimum/mump2p-cli/internal/config.Domain=$(DOMAIN) .DEFAULT_GOAL := help -.PHONY: all build run clean test help lint tag release print-cli-name e2e-test coverage +.PHONY: all build run clean test help lint build tag release print-cli-name e2e-test e2e-fuzz coverage all: lint build @@ -80,5 +80,22 @@ e2e-test: ## Run E2E tests against dist/ binary fi go test ./e2e -v -timeout 10m +e2e-fuzz: ## Run fuzz tests against dist/ binary + @echo "Running fuzz tests..." + @if [ ! -f "$(BUILD_DIR)/$(CLI_NAME)-linux" ] && [ ! -f "$(BUILD_DIR)/$(CLI_NAME)-mac" ]; then \ + echo "Error: No binary found in $(BUILD_DIR)/"; \ + echo "Run 'make build' first with release credentials"; \ + exit 1; \ + fi + @echo "Fuzzing publish topic names..." + @go test ./e2e -run='^$$' -fuzz=FuzzPublishTopicName -fuzztime=1m -timeout=3m + @echo "Fuzzing publish messages..." + @go test ./e2e -run='^$$' -fuzz=FuzzPublishMessage -fuzztime=1m -timeout=3m + @echo "Fuzzing service URLs..." + @go test ./e2e -run='^$$' -fuzz=FuzzServiceURL -fuzztime=1m -timeout=3m + @echo "Fuzzing file paths..." + @go test ./e2e -run='^$$' -fuzz=FuzzFilePath -fuzztime=1m -timeout=3m + @echo "All fuzz tests passed!" + help: ## Show help - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' \ No newline at end of file + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' diff --git a/cmd/health.go b/cmd/health.go index 45e8c3b..4b07c3a 100644 --- a/cmd/health.go +++ b/cmd/health.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "time" "github.com/getoptimum/mump2p-cli/internal/config" "github.com/getoptimum/mump2p-cli/internal/formatter" @@ -42,7 +43,9 @@ var healthCmd = &cobra.Command{ return fmt.Errorf("failed to create request: %v", err) } - resp, err := http.DefaultClient.Do(req) + // Use HTTP client with timeout to prevent hanging during fuzzing + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) if err != nil { return fmt.Errorf("health check failed: %v", err) } diff --git a/cmd/tracer.go b/cmd/tracer.go index 59ab38b..e830d2c 100644 --- a/cmd/tracer.go +++ b/cmd/tracer.go @@ -592,7 +592,9 @@ func resetStats(ctx context.Context, base, jwt string) error { if jwt != "" { req.Header.Set("Authorization", "Bearer "+jwt) } - resp, err := http.DefaultClient.Do(req) + // Use HTTP client with timeout to prevent hanging during fuzzing + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) if err != nil { return err } @@ -677,7 +679,9 @@ func proxyPublishRandom(base, jwt, clientID, topic string, length uint64) error if !IsAuthDisabled() && jwt != "" { req.Header.Set("Authorization", "Bearer "+jwt) } - resp, err := http.DefaultClient.Do(req) + // Use HTTP client with timeout to prevent hanging during fuzzing + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) if err != nil { return err } @@ -708,7 +712,9 @@ func proxySubscribe(base, jwt, topic, clientID string, threshold int) error { if !IsAuthDisabled() && jwt != "" { req.Header.Set("Authorization", "Bearer "+jwt) } - resp, err := http.DefaultClient.Do(req) + // Use HTTP client with timeout to prevent hanging during fuzzing + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) if err != nil { return err } diff --git a/e2e/cli_runner.go b/e2e/cli_runner.go index a354699..0cd0156 100644 --- a/e2e/cli_runner.go +++ b/e2e/cli_runner.go @@ -2,22 +2,43 @@ package main import ( "bytes" + "context" "os" "os/exec" + "time" +) + +const ( + // DefaultCommandTimeout is the timeout for CLI commands in fuzz tests + // This prevents hanging when fuzzing creates valid URLs that don't respond + DefaultCommandTimeout = 10 * time.Second ) // RunCommand executes the CLI binary with given arguments and returns output +// It uses a timeout to prevent hanging during fuzz tests func RunCommand(bin string, args ...string) (string, error) { + return RunCommandWithTimeout(bin, DefaultCommandTimeout, args...) +} + +// RunCommandWithTimeout executes the CLI binary with a specific timeout +func RunCommandWithTimeout(bin string, timeout time.Duration, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + var out bytes.Buffer var stderr bytes.Buffer - cmd := exec.Command(bin, args...) + cmd := exec.CommandContext(ctx, bin, args...) cmd.Env = os.Environ() cmd.Stdout = &out cmd.Stderr = &stderr err := cmd.Run() if err != nil { + // Check if timeout was exceeded + if ctx.Err() == context.DeadlineExceeded { + return stderr.String(), context.DeadlineExceeded + } return stderr.String(), err } return out.String(), nil diff --git a/e2e/fuzz_test.go b/e2e/fuzz_test.go new file mode 100644 index 0000000..384c18f --- /dev/null +++ b/e2e/fuzz_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// FuzzPublishTopicName tests the publish command with a topic name +func FuzzPublishTopicName(f *testing.F) { + require.NotEmpty(f, cliBinaryPath, "CLI binary path must be set by TestMain") + + f.Add("") + f.Add("\x00") + f.Add("../../../etc/passwd") + f.Add(strings.Repeat("a", 1000)) + f.Add("test\n\r\t") + f.Add("test'; DROP TABLE") + f.Add("${HOME}") + + f.Fuzz(func(t *testing.T, topic string) { + if len(topic) > 5000 { + t.Skip() + } + + // For obviously invalid input, verify graceful error handling + if strings.Contains(topic, "\x00") { + args := []string{"publish", "--topic=" + topic, "--message=test"} + out, err := RunCommand(cliBinaryPath, args...) + require.Error(t, err, "CLI should reject null bytes in topic name") + // Verify error is handled gracefully (not a panic) + if strings.Contains(out, "panic") || strings.Contains(out, "fatal") { + t.Fatalf("CLI panicked or crashed on topic with null byte: %v\nOutput: %s", err, out) + } + return + } + + args := []string{"publish", "--topic=" + topic, "--message=test"} + out, err := RunCommand(cliBinaryPath, args...) + // For fuzzing, we need to detect failures - invalid topics should fail gracefully + // Valid topics might succeed (if subscribed) or fail (if not subscribed) + // The key is that the CLI should handle the input without panicking + if err != nil { + // Any error should be handled gracefully (not a panic or crash) + if strings.Contains(out, "panic") || strings.Contains(out, "fatal") { + t.Fatalf("CLI panicked or crashed on topic %q: %v\nOutput: %s", topic, err, out) + } + // For invalid topics, errors are expected and acceptable + t.Logf("Command failed (expected for invalid input): %v\nOutput: %s", err, out) + } + }) +} + +// FuzzPublishMessage tests the publish command with a message. +// This is distinct from FuzzPublishTopicName which tests topic name validation; +// this test focuses on message content validation and handling. +func FuzzPublishMessage(f *testing.F) { + require.NotEmpty(f, cliBinaryPath, "CLI binary path must be set by TestMain") + + f.Add("") + f.Add("\x00") + f.Add(strings.Repeat("a", 10000)) + f.Add("{\"test\": \"value\"}") + f.Add("test") + + f.Fuzz(func(t *testing.T, message string) { + if len(message) > 50000 { + t.Skip() + } + + // For obviously invalid input, verify graceful error handling + if strings.Contains(message, "\x00") { + args := []string{"publish", "--topic=fuzz-test", "--message=" + message} + out, err := RunCommand(cliBinaryPath, args...) + require.Error(t, err, "CLI should reject null bytes in message") + // Verify error is handled gracefully (not a panic) + if strings.Contains(out, "panic") || strings.Contains(out, "fatal") { + t.Fatalf("CLI panicked or crashed on message with null byte: %v\nOutput: %s", err, out) + } + return + } + + args := []string{"publish", "--topic=fuzz-test", "--message=" + message} + out, err := RunCommand(cliBinaryPath, args...) + // For fuzzing, we need to detect failures - invalid messages should fail gracefully + // Valid messages might succeed (if topic is subscribed) or fail (if not subscribed) + // The key is that the CLI should handle the input without panicking + if err != nil { + // Any error should be handled gracefully (not a panic or crash) + if strings.Contains(out, "panic") || strings.Contains(out, "fatal") { + t.Fatalf("CLI panicked or crashed on message %q: %v\nOutput: %s", message, err, out) + } + // For invalid messages, errors are expected and acceptable + t.Logf("Command failed (expected for invalid input): %v\nOutput: %s", err, out) + } + }) +} + +// FuzzServiceURL tests the health command with a service URL +func FuzzServiceURL(f *testing.F) { + require.NotEmpty(f, cliBinaryPath, "CLI binary path must be set by TestMain") + + f.Add("not-a-url") + f.Add("://broken") + f.Add("http://") + f.Add("http://localhost:-8080") + f.Add("http://localhost:99999") + f.Add("javascript:alert(1)") + f.Add("\x00") + + f.Fuzz(func(t *testing.T, url string) { + if len(url) > 1000 { + t.Skip() + } + + // For obviously invalid input, verify graceful error handling + if strings.Contains(url, "\x00") { + args := []string{"health", "--service-url=" + url} + out, err := RunCommand(cliBinaryPath, args...) + require.Error(t, err, "CLI should reject null bytes in service URL") + // Verify error is handled gracefully (not a panic) + if strings.Contains(out, "panic") || strings.Contains(out, "fatal") { + t.Fatalf("CLI panicked or crashed on service URL with null byte: %v\nOutput: %s", err, out) + } + return + } + + args := []string{"health", "--service-url=" + url} + out, err := RunCommand(cliBinaryPath, args...) + // For fuzzing, we need to detect failures - invalid URLs should fail gracefully + // Valid URLs might succeed (if proxy is reachable) or fail (if not) + // The key is that the CLI should handle the input without panicking + if err != nil { + // Any error should be handled gracefully (not a panic or crash) + if strings.Contains(out, "panic") || strings.Contains(out, "fatal") { + t.Fatalf("CLI panicked or crashed on URL %q: %v\nOutput: %s", url, err, out) + } + // For invalid URLs, errors are expected and acceptable + t.Logf("Command failed (expected for invalid URL): %v\nOutput: %s", err, out) + } + }) +} + +// FuzzFilePath tests the publish command with a file path +func FuzzFilePath(f *testing.F) { + require.NotEmpty(f, cliBinaryPath, "CLI binary path must be set by TestMain") + + f.Add("") + f.Add("nonexistent.txt") + f.Add("../../../etc/passwd") + f.Add("/dev/null") + f.Add("test\x00file.txt") + f.Add(strings.Repeat("a", 500) + ".txt") + + f.Fuzz(func(t *testing.T, filepath string) { + if len(filepath) > 2000 { + t.Skip() + } + + // For obviously invalid input, verify graceful error handling + if strings.Contains(filepath, "\x00") { + args := []string{"publish", "--topic=fuzz-test", "--file=" + filepath} + out, err := RunCommand(cliBinaryPath, args...) + require.Error(t, err, "CLI should reject null bytes in file path") + // Verify error is handled gracefully (not a panic) + if strings.Contains(out, "panic") || strings.Contains(out, "fatal") { + t.Fatalf("CLI panicked or crashed on file path with null byte: %v\nOutput: %s", err, out) + } + return + } + + args := []string{"publish", "--topic=fuzz-test", "--file=" + filepath} + out, err := RunCommand(cliBinaryPath, args...) + // For fuzzing, we need to detect failures - invalid file paths should fail gracefully + // Valid file paths might succeed (if file exists and topic is subscribed) or fail (if not) + // The key is that the CLI should handle the input without panicking + if err != nil { + // Any error should be handled gracefully (not a panic or crash) + if strings.Contains(out, "panic") || strings.Contains(out, "fatal") { + t.Fatalf("CLI panicked or crashed on file path %q: %v\nOutput: %s", filepath, err, out) + } + // For invalid file paths, errors are expected and acceptable + t.Logf("Command failed (expected for invalid file path): %v\nOutput: %s", err, out) + } + }) +} diff --git a/e2e/setup.go b/e2e/setup.go index e90a14d..478ac3b 100644 --- a/e2e/setup.go +++ b/e2e/setup.go @@ -9,14 +9,16 @@ import ( ) // PrepareCLI sets up the test environment and returns the CLI binary path +// Token setup is optional - if it fails, we continue without auth (useful for fuzz tests) func PrepareCLI() (cliPath string, cleanup func(), err error) { - tokenPath, err := SetupTokenFile() - if err != nil { - return "", nil, err - } - if err := os.Setenv("MUMP2P_AUTH_PATH", tokenPath); err != nil { - return "", nil, fmt.Errorf("failed to set MUMP2P_AUTH_PATH: %w", err) + tokenPath, tokenErr := SetupTokenFile() + if tokenErr == nil { + // Token setup succeeded, set it up + if err := os.Setenv("MUMP2P_AUTH_PATH", tokenPath); err != nil { + return "", nil, fmt.Errorf("failed to set MUMP2P_AUTH_PATH: %w", err) + } } + // If token setup failed, we continue without auth (for fuzz tests that don't need it) repoRoot, err := findRepoRoot() if err != nil { return "", nil, err @@ -46,7 +48,9 @@ func PrepareCLI() (cliPath string, cleanup func(), err error) { } cleanup = func() { - _ = os.RemoveAll(filepath.Dir(tokenPath)) + if tokenPath != "" { + _ = os.RemoveAll(filepath.Dir(tokenPath)) + } } return cli, cleanup, nil