diff --git a/internal/daemon/fcproc/fcproc.go b/internal/daemon/fcproc/fcproc.go index 767e126..4b4149f 100644 --- a/internal/daemon/fcproc/fcproc.go +++ b/internal/daemon/fcproc/fcproc.go @@ -68,9 +68,15 @@ func (m *Manager) EnsureBridge(ctx context.Context) error { return err } -// EnsureSocketDir creates the runtime socket directory. +// EnsureSocketDir creates the runtime socket directory at 0700. This is +// the directory the daemon socket, per-VM firecracker API sockets, and +// vsock sockets all live inside, so it must be readable only by the +// invoking user. func (m *Manager) EnsureSocketDir() error { - return os.MkdirAll(m.cfg.RuntimeDir, 0o755) + if err := os.MkdirAll(m.cfg.RuntimeDir, 0o700); err != nil { + return err + } + return os.Chmod(m.cfg.RuntimeDir, 0o700) } // CreateTap (re)creates a TAP owned by the current uid/gid, attaches it to diff --git a/internal/firecracker/client.go b/internal/firecracker/client.go index d0d8aec..b2c3521 100644 --- a/internal/firecracker/client.go +++ b/internal/firecracker/client.go @@ -184,7 +184,14 @@ func defaultDriveID(drive DriveConfig, fallback string) string { } func buildProcessRunner(cfg MachineConfig, logFile *os.File) *exec.Cmd { - script := "umask 000 && exec " + shellQuote(cfg.BinaryPath) + + // umask 077 so the API + vsock sockets firecracker creates are + // mode 0600 from birth (owned by root since we invoke via sudo). + // A follow-up chown in fcproc.EnsureSocketAccess transfers + // ownership to the invoking user. Without this, the sockets + // would briefly exist world-readable/writable between firecracker + // creating them and the daemon tightening the mode — a real + // window for a local attacker to hit the control plane. + script := "umask 077 && exec " + shellQuote(cfg.BinaryPath) + " --api-sock " + shellQuote(cfg.SocketPath) + " --id " + shellQuote(cfg.VMID) cmd := exec.Command("sudo", "-n", "sh", "-c", script) diff --git a/internal/firecracker/client_test.go b/internal/firecracker/client_test.go index e02f6a9..dda9497 100644 --- a/internal/firecracker/client_test.go +++ b/internal/firecracker/client_test.go @@ -88,7 +88,7 @@ func TestBuildProcessRunnerUsesSudoShellWrapper(t *testing.T) { if cmd.Args[1] != "-n" || cmd.Args[2] != "sh" || cmd.Args[3] != "-c" { t.Fatalf("args = %v", cmd.Args) } - want := "umask 000 && exec '/repo/firecracker' --api-sock '/tmp/fc.sock' --id 'vm-1'" + want := "umask 077 && exec '/repo/firecracker' --api-sock '/tmp/fc.sock' --id 'vm-1'" if cmd.Args[4] != want { t.Fatalf("script = %q, want %q", cmd.Args[4], want) } diff --git a/internal/paths/layout_test.go b/internal/paths/layout_test.go index d95ede1..acb5328 100644 --- a/internal/paths/layout_test.go +++ b/internal/paths/layout_test.go @@ -84,12 +84,39 @@ func TestEnsureCreatesAllDirs(t *testing.T) { } } + // RuntimeDir holds sockets; must be 0700. + info, err := os.Stat(layout.RuntimeDir) + if err != nil { + t.Fatalf("stat runtime: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o700 { + t.Errorf("RuntimeDir mode = %#o, want 0700", perm) + } + // Idempotent. if err := Ensure(layout); err != nil { t.Fatalf("Ensure (second run): %v", err) } } +func TestEnsureTightensStaleRuntimeDirMode(t *testing.T) { + base := t.TempDir() + runtime := filepath.Join(base, "runtime") + if err := os.MkdirAll(runtime, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := Ensure(Layout{RuntimeDir: runtime}); err != nil { + t.Fatalf("Ensure: %v", err) + } + info, err := os.Stat(runtime) + if err != nil { + t.Fatalf("stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o700 { + t.Errorf("mode = %#o, want 0700 after Ensure", perm) + } +} + func TestBangerdPathEnvOverride(t *testing.T) { t.Setenv("BANGER_DAEMON_BIN", "/tmp/custom-bangerd") got, err := BangerdPath() diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 518ea63..9cdc455 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "syscall" ) type Layout struct { @@ -26,6 +27,13 @@ type Layout struct { OCICacheDir string SSHDir string KnownHostsPath string + + // runtimeHomeFallback is true when we fabricated the RuntimeHome path + // under /tmp because XDG_RUNTIME_DIR was unset. Ensure() uses the flag + // to apply strict ownership + mode checks on the fallback parent (a + // world-writable /tmp needs us to own and lock the subtree ourselves; + // a systemd-provisioned /run/user/ is already 0700 and trusted). + runtimeHomeFallback bool } func Resolve() (Layout, error) { @@ -37,19 +45,22 @@ func Resolve() (Layout, error) { stateHome := getenvDefault("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) cacheHome := getenvDefault("XDG_CACHE_HOME", filepath.Join(home, ".cache")) runtimeHome := os.Getenv("XDG_RUNTIME_DIR") + runtimeFallback := false if runtimeHome == "" { runtimeHome = filepath.Join(os.TempDir(), fmt.Sprintf("banger-runtime-%d", os.Getuid())) + runtimeFallback = true } layout := Layout{ - ConfigHome: configHome, - StateHome: stateHome, - CacheHome: cacheHome, - RuntimeHome: runtimeHome, - ConfigDir: filepath.Join(configHome, "banger"), - StateDir: filepath.Join(stateHome, "banger"), - CacheDir: filepath.Join(cacheHome, "banger"), - RuntimeDir: filepath.Join(runtimeHome, "banger"), + ConfigHome: configHome, + StateHome: stateHome, + CacheHome: cacheHome, + RuntimeHome: runtimeHome, + runtimeHomeFallback: runtimeFallback, + ConfigDir: filepath.Join(configHome, "banger"), + StateDir: filepath.Join(stateHome, "banger"), + CacheDir: filepath.Join(cacheHome, "banger"), + RuntimeDir: filepath.Join(runtimeHome, "banger"), } layout.SocketPath = filepath.Join(layout.RuntimeDir, "bangerd.sock") layout.DBPath = filepath.Join(layout.StateDir, "state.db") @@ -64,7 +75,32 @@ func Resolve() (Layout, error) { } func Ensure(layout Layout) error { - for _, dir := range []string{layout.ConfigDir, layout.StateDir, layout.CacheDir, layout.RuntimeDir, layout.VMsDir, layout.ImagesDir, layout.KernelsDir, layout.OCICacheDir} { + // When we're using the /tmp fallback, we must create and own the + // runtime-home parent ourselves and reject any pre-existing directory + // that isn't 0700 + owned by the current uid. Otherwise a local + // attacker could pre-create that path and have banger's control + // sockets land inside a directory they control. + if layout.runtimeHomeFallback && strings.TrimSpace(layout.RuntimeHome) != "" { + if err := ensureSafeRuntimeHome(layout.RuntimeHome); err != nil { + return err + } + } + // RuntimeDir holds bangerd.sock + per-VM firecracker API + vsock + // sockets. Lock it to 0700 unconditionally so even if the parent + // runtime-home is traversable by others, none of our sockets are + // reachable. + if strings.TrimSpace(layout.RuntimeDir) != "" { + if err := os.MkdirAll(layout.RuntimeDir, 0o700); err != nil { + return err + } + if err := os.Chmod(layout.RuntimeDir, 0o700); err != nil { + return err + } + } + for _, dir := range []string{layout.ConfigDir, layout.StateDir, layout.CacheDir, layout.VMsDir, layout.ImagesDir, layout.KernelsDir, layout.OCICacheDir} { + if strings.TrimSpace(dir) == "" { + continue + } if err := os.MkdirAll(dir, 0o755); err != nil { return err } @@ -81,6 +117,36 @@ func Ensure(layout Layout) error { return nil } +// ensureSafeRuntimeHome creates path at 0700 if missing, or validates +// existing ownership + mode. Returns an error describing how to remediate +// when the existing directory doesn't meet the bar. +func ensureSafeRuntimeHome(path string) error { + if err := os.MkdirAll(path, 0o700); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + // Must be a real directory, not a symlink an attacker could swap. + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("runtime dir %s is a symlink; refusing to place sockets there — remove it or set XDG_RUNTIME_DIR", path) + } + if !info.IsDir() { + return fmt.Errorf("runtime dir %s exists but is not a directory", path) + } + sys, ok := info.Sys().(*syscall.Stat_t) + if ok && int(sys.Uid) != os.Getuid() { + return fmt.Errorf("runtime dir %s is owned by uid %d, not %d; remove it or set XDG_RUNTIME_DIR", path, sys.Uid, os.Getuid()) + } + if info.Mode().Perm() != 0o700 { + if err := os.Chmod(path, 0o700); err != nil { + return fmt.Errorf("runtime dir %s has insecure mode %#o and chmod failed: %w", path, info.Mode().Perm(), err) + } + } + return nil +} + var executablePath = os.Executable func BangerdPath() (string, error) {