diff options
| -rw-r--r-- | AGENTS.md | 1 | ||||
| -rw-r--r-- | Makefile | 10 | ||||
| -rw-r--r-- | internal/cli/cli.go | 9 | ||||
| -rw-r--r-- | internal/namespace/namespace.go | 41 | ||||
| -rw-r--r-- | internal/namespace/namespace_test.go | 2 | ||||
| -rw-r--r-- | tests/e2e/arg_integrity_test.go | 45 | ||||
| -rw-r--r-- | tests/e2e/e2e_test.go | 6 | ||||
| -rw-r--r-- | tests/e2e/fuzz_args_test.go | 52 | ||||
| -rw-r--r-- | tests/e2e/test_helpers.go | 45 | ||||
| -rw-r--r-- | tests/e2e/testdata/fuzz/FuzzArgumentIntegrity/4316c263ab833860 | 2 | ||||
| -rw-r--r-- | tests/e2e/testdata/fuzz/FuzzArgumentIntegrity/771e938e4458e983 | 2 |
11 files changed, 198 insertions, 17 deletions
@@ -45,6 +45,7 @@ To maintain a high-velocity development cycle without sacrificing correctness, w - **Code Stubs**: Any unimplemented logic path must be explicitly marked with a `// TODO` comment and return a descriptive error (e.g., `fmt.Errorf("feature X not yet implemented")`). - **Test Stubs**: Any test that is planned but not yet implementable must use `t.Skip("not implemented")` and include a comment describing the specific scenario the test is intended to verify. - **Hermetic Configuration**: Tests involving profiles, settings, or filesystem state must not touch the actual user home directory. Use the `ConfigDir` injection pattern in the `App` struct combined with `t.TempDir()` to create isolated, temporary test environments. +- **Path Portability**: NEVER hardcode absolute paths (e.g., `/home/user/...`) in the source code or test suites. Always use relative paths, `os.Getwd()`, or environment-aware discovery to locate binaries and configuration files. - **Performance & Reliability**: - **Parallelism**: Use `t.Parallel()` in integration and E2E tests. Use `t.TempDir()` to ensure resource isolation. - **Granular Timeouts**: All system calls, network operations, and external command executions must be wrapped in a `context.WithTimeout` (typically 2-5 seconds) to prevent hanging tests. @@ -9,7 +9,11 @@ LAUNCHER_SRC = internal/namespace/launcher_src/launcher.c LAUNCHER_BIN = internal/namespace/launcher.bin BINARY = wg-wrap -.PHONY: all clean test +# Fuzzing settings +FUZZ_PARALLEL ?= 2 +FUZZ_TIME ?= 30s + +.PHONY: all clean test fuzz # Default target: build the final binary all: $(BINARY) @@ -30,3 +34,7 @@ test: all clean: rm -f $(BINARY) $(LAUNCHER_BIN) find . -name "*.test" -delete + +# Run fuzzing tests +fuzz: all + go test -v -fuzz=FuzzArgumentIntegrity -parallel $(FUZZ_PARALLEL) -fuzztime=$(FUZZ_TIME) ./tests/e2e/fuzz_args_test.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b315fba..eba7f68 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -18,6 +18,15 @@ func NewApp(args []string) *App { } func (a *App) Run() error { + // 1. Validate arguments for null bytes to prevent exec failures in the C launcher + for i, arg := range a.Args { + for j := 0; j < len(arg); j++ { + if arg[j] == 0 { + return fmt.Errorf("argument %d contains null byte at position %d", i, j) + } + } + } + // Handle the internal diagnostic commands first if len(a.Args) > 1 { switch a.Args[1] { diff --git a/internal/namespace/namespace.go b/internal/namespace/namespace.go index 1a09b78..b0794a4 100644 --- a/internal/namespace/namespace.go +++ b/internal/namespace/namespace.go @@ -2,7 +2,6 @@ package namespace import ( _ "embed" - "encoding/json" "fmt" "os" "os/exec" @@ -55,15 +54,13 @@ func VerifyIsolation() (bool, string) { return true, "Isolated and root" } -// VerifyArguments prints the current process arguments as a JSON array. -// This is used for E2E testing to verify that argument splitting and -// shell injection are not occurring during the bootstrap loop. +// VerifyArguments prints the current process arguments as hex-encoded strings. +// This is used for E2E testing to verify that the data path is 8-bit clean +// and that no bytes are mutated during the bootstrap loop. func VerifyArguments(args []string) error { - out, err := json.Marshal(args) - if err != nil { - return fmt.Errorf("failed to marshal arguments: %w", err) + for i, arg := range args { + fmt.Printf("%d:%x\n", i, arg) } - fmt.Println(string(out)) return nil } @@ -74,6 +71,16 @@ func Bootstrap() error { return nil } + // 0. Validate current arguments for null bytes before proceeding. + // If any argument contains a null byte, syscall.Exec will fail with 'invalid argument'. + for i, arg := range os.Args { + for j := 0; j < len(arg); j++ { + if arg[j] == 0 { + return fmt.Errorf("argument %d contains null byte at position %d", i, j) + } + } + } + self, err := os.Executable() if err != nil { return fmt.Errorf("failed to get executable path: %w", err) @@ -89,16 +96,16 @@ func Bootstrap() error { // 2. Write the embedded launcher binary to the temp file. if _, err := tmpFile.Write(launcherBytes); err != nil { - tmpFile.Close() + _ = tmpFile.Close() return fmt.Errorf("failed to write launcher binary: %w", err) } - + // Ensure the binary is executable (0700) if err := tmpFile.Chmod(0700); err != nil { - tmpFile.Close() + _ = tmpFile.Close() return fmt.Errorf("failed to set launcher permissions: %w", err) } - tmpFile.Close() + _ = tmpFile.Close() // 3. Prepare arguments for the launcher. // The launcher expects: launcher <command_to_run> [args...] @@ -106,6 +113,16 @@ func Bootstrap() error { args = append(args, os.Args[1:]...) // 4. Replace the current process with the launcher. + // We must check for null bytes in the arguments here because syscall.Exec + // (which calls execve) will return 'invalid argument' (EINVAL) if any + // string in the argv array contains a null byte. + for i, arg := range args { + for j := 0; j < len(arg); j++ { + if arg[j] == 0 { + return fmt.Errorf("launcher argument %d contains null byte at position %d", i, j) + } + } + } err = syscall.Exec(launcherPath, args, os.Environ()) if err != nil { return fmt.Errorf("launcher exec failed: %w", err) diff --git a/internal/namespace/namespace_test.go b/internal/namespace/namespace_test.go index 10511dd..54e3c93 100644 --- a/internal/namespace/namespace_test.go +++ b/internal/namespace/namespace_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -// We move the complex isolation testing to tests/e2e to avoid +// We move the complex isolation testing to tests/e2e to avoid // issues with Go's temporary test binaries and process replacement. func TestNamespacePackage(t *testing.T) { t.Skip("Namespace isolation tests moved to tests/e2e") diff --git a/tests/e2e/arg_integrity_test.go b/tests/e2e/arg_integrity_test.go new file mode 100644 index 0000000..7121c2b --- /dev/null +++ b/tests/e2e/arg_integrity_test.go @@ -0,0 +1,45 @@ +package e2e + +import ( + "fmt" + "os/exec" + "strings" + "testing" +) + +func TestArgumentIntegrity(t *testing.T) { + payloads := []string{ + "$(whoami)", + "; rm -rf /", + "`id`", + "| wall 'hacked'", + "\"'\"'\"", // Complex quoting + " spaced argument ", + "$\nnewline", + } + + for _, payload := range payloads { + t.Run(fmt.Sprintf("Payload_%s", payload), func(t *testing.T) { + binaryPath := GetBinaryPath() + cmd := exec.Command(binaryPath, "test-args", payload) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("wg-wrap test-args failed for payload %s: %v\nOutput: %s", payload, err, string(out)) + } + + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) < 3 { + t.Fatalf("Unexpected output format for payload %s\nOutput: %s", payload, string(out)) + } + + parts := strings.Split(lines[len(lines)-1], ":") + if len(parts) < 2 { + t.Fatalf("Malformed hex line for payload %s: %s", payload, lines[len(lines)-1]) + } + + if parts[1] != fmt.Sprintf("%x", payload) { + t.Errorf("8-bit mismatch!\nSent Hex: %s\nRecv Hex: %s\nPayload: %q", fmt.Sprintf("%x", payload), parts[1], payload) + } + }) + } +} diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 4339a8b..fb763b3 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -20,10 +20,10 @@ func TestNetworkIsolation(t *testing.T) { t.Fatalf("Failed to get cwd: %v", err) } root := filepath.Join(cwd, "..", "..") - + // 2. Build the project to ensure we have a fresh binary buildCmd := exec.Command("bash", "-c", fmt.Sprintf( - "cd %s && gcc -static -O2 internal/namespace/launcher_src/launcher.c -o internal/namespace/launcher.bin && export CGO_ENABLED=1 && go build -o wg-wrap cmd/wg-wrap/main.go", + "cd %s && gcc -static -O2 internal/namespace/launcher_src/launcher.c -o internal/namespace/launcher.bin && export CGO_ENABLED=1 && go build -o wg-wrap cmd/wg-wrap/main.go", root)) if err := buildCmd.Run(); err != nil { t.Fatalf("Failed to build project for E2E test: %v", err) @@ -43,7 +43,7 @@ func TestNetworkIsolation(t *testing.T) { } // Cleanup - os.Remove(binaryPath) + _ = os.Remove(binaryPath) } func TestDNSLeakage(t *testing.T) { diff --git a/tests/e2e/fuzz_args_test.go b/tests/e2e/fuzz_args_test.go new file mode 100644 index 0000000..0d4a45b --- /dev/null +++ b/tests/e2e/fuzz_args_test.go @@ -0,0 +1,52 @@ +package e2e + +import ( + "fmt" + "os/exec" + "strings" + "testing" +) + +func FuzzArgumentIntegrity(f *testing.F) { + binaryPath := GetBinaryPath() + + f.Add("; rm -rf /") + f.Add("$(whoami)") + f.Add(" spaced ") + f.Add("\"'\"'\"") + f.Add("\x00null\x00") + + f.Fuzz(func(t *testing.T, payload string) { + out, err := exec.Command(binaryPath, "test-args", payload).CombinedOutput() + + if strings.Contains(payload, "\x00") { + if err != nil || strings.Contains(string(out), "contains null byte") { + return + } + } + + if err != nil { + // If we hit a system limit (like disk quota in /tmp during heavy fuzzing), + // it's an environmental issue, not a bug in our binary. + if strings.Contains(string(out), "disk quota exceeded") || + strings.Contains(string(out), "no space left on device") { + return + } + t.Fatalf("Binary crashed for payload %q: %v\nOutput: %s", payload, err, string(out)) + } + + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) < 3 { + t.Fatalf("Unexpected output format for payload %q\nOutput: %s", payload, string(out)) + } + + parts := strings.Split(lines[len(lines)-1], ":") + if len(parts) < 2 { + t.Fatalf("Malformed hex line for payload %q: %s", payload, lines[len(lines)-1]) + } + + if parts[1] != fmt.Sprintf("%x", payload) { + t.Errorf("8-bit mismatch!\nSent Hex: %s\nRecv Hex: %s\nPayload: %q", fmt.Sprintf("%x", payload), parts[1], payload) + } + }) +} diff --git a/tests/e2e/test_helpers.go b/tests/e2e/test_helpers.go new file mode 100644 index 0000000..34aae3f --- /dev/null +++ b/tests/e2e/test_helpers.go @@ -0,0 +1,45 @@ +package e2e + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// GetBinaryPath resolves the path to the wg-wrap binary. +// It checks the current directory, then the project root, then the system PATH. +func GetBinaryPath() string { + binaryName := "wg-wrap" + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + + // 1. Check current working directory + if _, err := os.Stat(binaryName); err == nil { + abs, _ := filepath.Abs(binaryName) + return abs + } + + // 2. Check common project root relative paths + // Since go test can be run from root or package dir, we try both. + candidates := []string{ + filepath.Join("..", "..", binaryName), // from tests/e2e + filepath.Join("..", binaryName), // from tests/ + binaryName, // from root + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + abs, _ := filepath.Abs(c) + return abs + } + } + + // 3. Check system PATH + path, err := exec.LookPath(binaryName) + if err == nil { + return path + } + + return binaryName +} diff --git a/tests/e2e/testdata/fuzz/FuzzArgumentIntegrity/4316c263ab833860 b/tests/e2e/testdata/fuzz/FuzzArgumentIntegrity/4316c263ab833860 new file mode 100644 index 0000000..4a4e8b6 --- /dev/null +++ b/tests/e2e/testdata/fuzz/FuzzArgumentIntegrity/4316c263ab833860 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("\xd7") diff --git a/tests/e2e/testdata/fuzz/FuzzArgumentIntegrity/771e938e4458e983 b/tests/e2e/testdata/fuzz/FuzzArgumentIntegrity/771e938e4458e983 new file mode 100644 index 0000000..ee3f339 --- /dev/null +++ b/tests/e2e/testdata/fuzz/FuzzArgumentIntegrity/771e938e4458e983 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("0") |
