Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 19 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}'
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
5 changes: 4 additions & 1 deletion cmd/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"time"

"github.com/getoptimum/mump2p-cli/internal/config"
"github.com/getoptimum/mump2p-cli/internal/formatter"
Expand Down Expand Up @@ -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)
}
Expand Down
12 changes: 9 additions & 3 deletions cmd/tracer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
23 changes: 22 additions & 1 deletion e2e/cli_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
187 changes: 187 additions & 0 deletions e2e/fuzz_test.go
Original file line number Diff line number Diff line change
@@ -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<script>alert(1)</script>")

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)
}
})
}
18 changes: 11 additions & 7 deletions e2e/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading