summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md1
-rw-r--r--Makefile10
-rw-r--r--internal/cli/cli.go9
-rw-r--r--internal/namespace/namespace.go41
-rw-r--r--internal/namespace/namespace_test.go2
-rw-r--r--tests/e2e/arg_integrity_test.go45
-rw-r--r--tests/e2e/e2e_test.go6
-rw-r--r--tests/e2e/fuzz_args_test.go52
-rw-r--r--tests/e2e/test_helpers.go45
-rw-r--r--tests/e2e/testdata/fuzz/FuzzArgumentIntegrity/4316c263ab8338602
-rw-r--r--tests/e2e/testdata/fuzz/FuzzArgumentIntegrity/771e938e4458e9832
11 files changed, 198 insertions, 17 deletions
diff --git a/AGENTS.md b/AGENTS.md
index f9e085d..dd1bdaa 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/Makefile b/Makefile
index 0ef54dd..1982545 100644
--- a/Makefile
+++ b/Makefile
@@ -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")