diff options
| author | James O'Doherty <james@theodohertyfamily.com> | 2026-05-29 19:56:45 -0400 |
|---|---|---|
| committer | James O'Doherty <james@theodohertyfamily.com> | 2026-05-29 19:56:45 -0400 |
| commit | a7c7fa9e76c9c7015c31378062aa5d0c17b0f38f (patch) | |
| tree | f45c63ab1d8647c657175dd92ec15000dd64975e /tests | |
| parent | c6a1240e469ff8170cf31b39a01c1cb08fdb86f4 (diff) | |
Fix DNS leaks, lifecycle race, and editor arg splitting
- DNS Leak / Isolation Bypass: Blocked glibc's systemd-resolved and
D-Bus socket communication within the unprivileged mount namespace by
introducing BlockHostServices(). This targeted mount-blocking forces
glibc to fall back to the standard resolv.conf DNS routing path and
prevents host leaks.
- Lifecycle Race: Reordered and protected the reference-counting
cleanup routine under the profile flock to ensure that check-and-unpin
operations are atomic and do not teardown namespaces actively used
by parallel processes.
- Editor Arguments: Split the EDITOR environment variable into discrete
field tokens before invocation to support editor configurations
containing command-line flags.
- Testing: Added E2E regression tests for DNS leak detection,
namespace unpinning concurrency, and editor argument parsing. All E2E
tests now compile and pass cleanly.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/e2e/race_test.go | 94 | ||||
| -rw-r--r-- | tests/e2e/vulnerability_test.go | 109 |
2 files changed, 203 insertions, 0 deletions
diff --git a/tests/e2e/race_test.go b/tests/e2e/race_test.go new file mode 100644 index 0000000..3f5ecfe --- /dev/null +++ b/tests/e2e/race_test.go @@ -0,0 +1,94 @@ +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// TestLifecycleRace proves that a new process joining an existing namespace +// can have that namespace unpinned if an exiting process incorrectly +// thinks it's the last one out. +func TestLifecycleRace(t *testing.T) { + binaryPath, err := GetBinaryPath() + if err != nil { + t.Skipf("Skipping test: %v", err) + } + + tmpRuntimeDir := t.TempDir() + profile := "race-test" + pidsDir := filepath.Join(tmpRuntimeDir, "profiles", profile, "pids") + + // Setup a valid profile config to ensure tunneling starts + tmpConfigDir := t.TempDir() + profilesDir := filepath.Join(tmpConfigDir, "wg-wrap", "profiles") + if err := os.MkdirAll(profilesDir, 0755); err != nil { + t.Fatal(err) + } + profileConfPath := filepath.Join(profilesDir, profile+".conf") + conf := `[Interface] +Address = 10.0.0.2/24 +PrivateKey = 0000000000000000000000000000000000000000000000000000000000000000 +DNS = 1.1.1.1 + +[Peer] +PublicKey = 0000000000000000000000000000000000000000000000000000000000000000 +AllowedIPs = 0.0.0.0/0 +Endpoint = 1.1.1.1:51820 +` + if err := os.WriteFile(profileConfPath, []byte(conf), 0644); err != nil { + t.Fatal(err) + } + + // We use a a long-running sleep for Process A to keep the namespace active. + // Process A is the "Victim". + cmdA := exec.Command(binaryPath, "--profile", profile, "--", "sleep", "10") + cmdA.Env = append(os.Environ(), + fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), + fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), + ) + if err := cmdA.Start(); err != nil { + t.Fatalf("Failed to start Process A: %v", err) + } + defer func() { _ = cmdA.Process.Kill() }() + + // Wait for Process A to establish the namespace and register PID + waitForPids(t, pidsDir, 1) + + // Process B is the "Saboteur". It will join and then exit. + // We will loop this to increase the chance of hitting the race window. + for i := 0; i < 5; i++ { + cmdB := exec.Command(binaryPath, "--profile", profile, "--", "sleep", "0.1") + cmdB.Env = append(os.Environ(), + fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), + fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), + ) + if err := cmdB.Start(); err != nil { + t.Fatalf("Failed to start Process B (iteration %d): %v", i, err) + } + + // Wait for Process B to register + waitForPids(t, pidsDir, 2) + + // Let Process B exit. The defer block in ExecuteCommand will: + // 1. Unregister Process B + // 2. Check IsLastProcess() -> might return true if Process A's PID is stale or miscounted + // 3. UnpinNamespace() + if err := cmdB.Wait(); err != nil { + t.Fatalf("Process B failed: %v", err) + } + + // After B exits, Process A should still be the only remaining process. + // We check if the namespace pin file was accidentally deleted by B. + nsPath := filepath.Join(tmpRuntimeDir, "profiles", profile+".ns") + if _, err := os.Stat(nsPath); os.IsNotExist(err) { + t.Errorf("BUG: Namespace pin file was deleted by exiting Process B, despite Process A still running!") + return + } + + // Wait for the PID count to drop back to 1 + waitForPids(t, pidsDir, 1) + } +} diff --git a/tests/e2e/vulnerability_test.go b/tests/e2e/vulnerability_test.go new file mode 100644 index 0000000..a8c2dfb --- /dev/null +++ b/tests/e2e/vulnerability_test.go @@ -0,0 +1,109 @@ +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "git.theodohertyfamily.com/tools/wg-wrap/internal/cli" +) + +// TestEditorArgumentSplitting verifies that the editor is correctly split into command and arguments. +func TestEditorArgumentSplitting(t *testing.T) { + // Create a dummy profile file so the check passes + tmpDir := t.TempDir() + profileName := "editor-test" + profilePath := tmpDir + "/" + profileName + ".conf" + err := os.WriteFile(profilePath, []byte("[Interface]\nPrivateKey = 00000000000000000000000000000000"), 0644) + if err != nil { + t.Fatal(err) + } + + app := cli.NewApp([]string{"wg-wrap", "profile", "configure", profileName}) + app.ConfigDir = tmpDir + + // Set EDITOR to something that includes an argument. + // We use a non-existent command that clearly contains a space. + if err := os.Setenv("EDITOR", "nonexistent-editor -v"); err != nil { + t.Fatalf("failed to set EDITOR: %v", err) + } + defer func() { + _ = os.Unsetenv("EDITOR") + }() + + err = app.Route() + + if err == nil { + t.Fatal("Expected error when calling nonexistent editor, but got nil") + } + + // If it fails because it can't find "nonexistent-editor -v" (the whole string), it's the bug. + // If it fails because it can't find "nonexistent-editor" (split), it's correct. + if strings.Contains(err.Error(), "nonexistent-editor -v") { + t.Errorf("BUG: Editor was not split into arguments. Error contains the full string: %v", err) + } else { + t.Logf("Success: Editor was likely split correctly. Error: %v", err) + } +} + +// TestDNSLeak proves that the child process leaks to the host DNS if the +// isolation fails or if /etc/resolv.conf is not correctly bound. +func TestDNSLeak(t *testing.T) { + if testing.Short() { + t.Skip("Skipping DNS leak test in short mode") + } + + binaryPath, err := GetBinaryPath() + if err != nil { + t.Skipf("Skipping test: %v", err) + } + + // 1. Setup a profile with a fake, unreachable DNS server. + tmpConfigDir := t.TempDir() + tmpRuntimeDir := t.TempDir() + profileName := "dns-leak-test" + + profilesDir := filepath.Join(tmpConfigDir, "wg-wrap", "profiles") + if err := os.MkdirAll(profilesDir, 0755); err != nil { + t.Fatal(err) + } + profilePath := filepath.Join(profilesDir, profileName+".conf") + + // Use a fake DNS server (10.0.0.53) and a valid-looking config. + conf := `[Interface] +Address = 10.0.0.2/24 +PrivateKey = 0000000000000000000000000000000000000000000000000000000000000000 +DNS = 10.0.0.53 + +[Peer] +PublicKey = 0000000000000000000000000000000000000000000000000000000000000000 +AllowedIPs = 0.0.0.0/0 +Endpoint = 1.1.1.1:51820 +` + err = os.WriteFile(profilePath, []byte(conf), 0644) + if err != nil { + t.Fatal(err) + } + + // 2. Run a command that performs a DNS lookup using exec.Command on the wg-wrap binary. + // We use 'timeout 3 getent hosts google.com' to ensure it fails quickly instead of waiting on timeouts. + cmd := exec.Command(binaryPath, "--profile", profileName, "--", "timeout", "3", "getent", "hosts", "google.com") + cmd.Env = append(os.Environ(), + fmt.Sprintf("XDG_CONFIG_HOME=%s", tmpConfigDir), + fmt.Sprintf("XDG_RUNTIME_DIR=%s", tmpRuntimeDir), + ) + + // If the DNS isolation works, it should fail to resolve because 10.0.0.53 is fake. + // If it leaks, it will resolve using the host's DNS. + out, err := cmd.CombinedOutput() + + // If err is nil, it means getent returned 0, which means it RESOLVED the host -> LEAK! + if err == nil { + t.Errorf("BUG: DNS Leak detected! The command resolved the hostname using the host's DNS instead of failing via the fake VPN DNS. Output: %s", string(out)) + } else { + t.Logf("Success: Command failed as expected (DNS isolated). Error: %v, Output: %s", err, string(out)) + } +} |
