summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorJames O'Doherty <james@theodohertyfamily.com>2026-05-29 19:56:45 -0400
committerJames O'Doherty <james@theodohertyfamily.com>2026-05-29 19:56:45 -0400
commita7c7fa9e76c9c7015c31378062aa5d0c17b0f38f (patch)
treef45c63ab1d8647c657175dd92ec15000dd64975e /tests
parentc6a1240e469ff8170cf31b39a01c1cb08fdb86f4 (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.go94
-rw-r--r--tests/e2e/vulnerability_test.go109
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))
+ }
+}