Defence-in-depth pass over every helper method that touches the host
as root. Each fix narrows what a compromised owner-uid daemon could
ask the helper to do; many close concrete file-ownership and DoS
primitives that the previous validators didn't reach.
Path / identifier validation:
* priv.fsck_snapshot now requires /dev/mapper/fc-rootfs-* (was
"is the string non-empty"). e2fsck -fy on /dev/sda1 was the
motivating exploit.
* priv.kill_process and priv.signal_process now read
/proc/<pid>/cmdline and require a "firecracker" substring before
sending the signal. Killing arbitrary host PIDs (sshd, init, …)
is no longer a one-RPC primitive.
* priv.read_ext4_file and priv.write_ext4_files now require the
image path to live under StateDir or be /dev/mapper/fc-rootfs-*.
* priv.cleanup_dm_snapshot validates every non-empty Handles field:
DM name fc-rootfs-*, DM device /dev/mapper/fc-rootfs-*, loops
/dev/loopN.
* priv.remove_dm_snapshot accepts only fc-rootfs-* names or
/dev/mapper/fc-rootfs-* paths.
* priv.ensure_nat now requires a parsable IPv4 address and a
banger-prefixed tap.
* priv.sync_resolver_routing and priv.clear_resolver_routing now
require a Linux iface-name-shaped bridge name (1–15 chars, no
whitespace/'/'/':') and, for sync, a parsable resolver address.
Symlink defence:
* priv.ensure_socket_access now validates the socket path is under
RuntimeDir and not a symlink. The fcproc layer's chown/chmod
moves to unix.Open(O_PATH|O_NOFOLLOW) + Fchownat(AT_EMPTY_PATH)
+ Fchmodat via /proc/self/fd, so even a swap of the leaf into a
symlink between validation and the syscall is refused. The
local-priv (non-root) fallback uses `chown -h`.
* priv.cleanup_jailer_chroot rejects symlinks at both the leaf
(os.Lstat) and intermediate path components (filepath.EvalSymlinks
+ clean-equality). The umount sweep was rewritten from shell
`umount --recursive --lazy` to direct unix.Unmount(MNT_DETACH |
UMOUNT_NOFOLLOW) per child mount, deepest-first; the findmnt
guard remains as the rm-rf safety net. Local-priv mode falls
back to `sudo umount --lazy`.
Binary validation:
* validateRootExecutable now opens with O_PATH|O_NOFOLLOW and
Fstats through the resulting fd. Rejects path-level symlinks and
narrows the TOCTOU window between validation and the SDK's exec
to fork+exec time on a healthy host.
Daemon socket:
* The owner daemon now reads SO_PEERCRED on every accepted
connection and refuses any UID that isn't 0 or the registered
owner. Filesystem perms (0600 + ownerUID) already enforced this;
the check is belt-and-braces in case the socket FD is ever
leaked to a non-owner process.
Docs:
* docs/privileges.md walked end-to-end. Each helper RPC's
Validation gate row reflects what the code actually enforces.
New section "Running outside the system install" calls out the
looser dev-mode trust model (NOPASSWD sudoers, helper hardening
bypassed) so users don't deploy that path on shared hosts.
Trust list updated to include every new validator.
Tests added: validators (DM-loop, DM-remove-target, DM-handles,
ext4-image-path, iface-name, IPv4, resolver-addr, not-symlink,
firecracker-PID, root-executable variants), the daemon's authorize
path (non-unix conn rejection + unix conn happy path), the umount2
ordering contract (deepest-first + --lazy on the sudo branch), and
positive/negative cases for the chown-no-follow fallback.
Verified end-to-end via `make smoke JOBS=4` on a KVM host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
6.7 KiB
Go
255 lines
6.7 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"banger/internal/api"
|
|
"banger/internal/buildinfo"
|
|
"banger/internal/model"
|
|
"banger/internal/paths"
|
|
"banger/internal/rpc"
|
|
"banger/internal/system"
|
|
)
|
|
|
|
// TestAuthorizeConnRejectsNonUnixConn pins the type guard at the top
|
|
// of authorizeConn: SO_PEERCRED only makes sense on a unix socket, so
|
|
// anything else must be refused outright. net.Pipe gives us a
|
|
// connection that satisfies net.Conn but isn't a *net.UnixConn, which
|
|
// is exactly the shape we need to exercise the early-return.
|
|
func TestAuthorizeConnRejectsNonUnixConn(t *testing.T) {
|
|
d := &Daemon{}
|
|
pipeA, pipeB := net.Pipe()
|
|
defer pipeA.Close()
|
|
defer pipeB.Close()
|
|
if err := d.authorizeConn(pipeA); err == nil {
|
|
t.Fatal("authorizeConn(pipe) succeeded, want error")
|
|
}
|
|
}
|
|
|
|
// TestAuthorizeConnAcceptsOwnerUIDOverUnixSocket pins the happy path:
|
|
// when the test process connects to a freshly bound unix socket as
|
|
// itself, the daemon's peer-cred check matches d.clientUID and lets
|
|
// the connection through.
|
|
func TestAuthorizeConnAcceptsOwnerUIDOverUnixSocket(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sockPath := filepath.Join(dir, "test.sock")
|
|
listener, err := net.Listen("unix", sockPath)
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
defer listener.Close()
|
|
|
|
type result struct {
|
|
err error
|
|
}
|
|
got := make(chan result, 1)
|
|
go func() {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
got <- result{err: err}
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
d := &Daemon{clientUID: os.Getuid()}
|
|
got <- result{err: d.authorizeConn(conn)}
|
|
}()
|
|
|
|
client, err := net.Dial("unix", sockPath)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
select {
|
|
case r := <-got:
|
|
if r.err != nil {
|
|
t.Fatalf("authorizeConn(unix self) = %v, want nil", r.err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("authorizeConn never returned")
|
|
}
|
|
}
|
|
|
|
func TestRegisterImageRequiresKernel(t *testing.T) {
|
|
rootfs := filepath.Join(t.TempDir(), "rootfs.ext4")
|
|
if err := os.WriteFile(rootfs, []byte("rootfs"), 0o644); err != nil {
|
|
t.Fatalf("write rootfs: %v", err)
|
|
}
|
|
d := &Daemon{store: openDaemonStore(t)}
|
|
wireServices(d)
|
|
|
|
_, err := d.img.RegisterImage(context.Background(), api.ImageRegisterParams{
|
|
Name: "missing-kernel",
|
|
RootfsPath: rootfs,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "kernel path is required") {
|
|
t.Fatalf("RegisterImage() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDispatchPingIncludesBuildInfo(t *testing.T) {
|
|
d := &Daemon{pid: 42}
|
|
wireServices(d)
|
|
|
|
resp := d.dispatch(context.Background(), rpc.Request{Version: rpc.Version, Method: "ping"})
|
|
if !resp.OK {
|
|
t.Fatalf("dispatch(ping) = %+v, want ok", resp)
|
|
}
|
|
|
|
var got api.PingResult
|
|
if err := json.Unmarshal(resp.Result, &got); err != nil {
|
|
t.Fatalf("Unmarshal(PingResult): %v", err)
|
|
}
|
|
|
|
info := buildinfo.Current()
|
|
if got.Status != "ok" || got.PID != 42 {
|
|
t.Fatalf("PingResult = %+v, want status/pid populated", got)
|
|
}
|
|
if got.Version != info.Version || got.Commit != info.Commit || got.BuiltAt != info.BuiltAt {
|
|
t.Fatalf("PingResult build info = %+v, want %+v", got, info)
|
|
}
|
|
}
|
|
|
|
func TestServeReturnsOnContextCancel(t *testing.T) {
|
|
dir := t.TempDir()
|
|
runtimeDir := filepath.Join(dir, "runtime")
|
|
if err := os.MkdirAll(runtimeDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll runtime: %v", err)
|
|
}
|
|
socketPath := filepath.Join(runtimeDir, "bangerd.sock")
|
|
probe, err := net.Listen("unix", filepath.Join(runtimeDir, "probe.sock"))
|
|
if err != nil {
|
|
if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") {
|
|
t.Skipf("unix socket listen blocked in this environment: %v", err)
|
|
}
|
|
t.Fatalf("probe listen: %v", err)
|
|
}
|
|
_ = probe.Close()
|
|
_ = os.Remove(filepath.Join(runtimeDir, "probe.sock"))
|
|
d := &Daemon{
|
|
layout: paths.Layout{
|
|
RuntimeDir: runtimeDir,
|
|
SocketPath: socketPath,
|
|
},
|
|
config: model.DaemonConfig{
|
|
StatsPollInterval: time.Hour,
|
|
},
|
|
store: openDaemonStore(t),
|
|
runner: system.NewRunner(),
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
closing: make(chan struct{}),
|
|
clientUID: -1,
|
|
clientGID: -1,
|
|
}
|
|
wireServices(d)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
serveErr := make(chan error, 1)
|
|
go func() {
|
|
serveErr <- d.Serve(ctx)
|
|
}()
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(socketPath); err == nil {
|
|
break
|
|
}
|
|
select {
|
|
case err := <-serveErr:
|
|
t.Fatalf("Serve() returned before socket was ready: %v", err)
|
|
default:
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("socket %s not created before deadline", socketPath)
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-serveErr:
|
|
if err != nil {
|
|
t.Fatalf("Serve() error = %v, want nil on context cancel", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Serve() did not return after context cancel")
|
|
}
|
|
}
|
|
|
|
func TestPromoteImageCopiesBootArtifactsIntoArtifactDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
rootfs := filepath.Join(dir, "rootfs.ext4")
|
|
kernel := filepath.Join(dir, "vmlinux")
|
|
initrd := filepath.Join(dir, "initrd.img")
|
|
modulesDir := filepath.Join(dir, "modules")
|
|
if err := os.MkdirAll(modulesDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir modules: %v", err)
|
|
}
|
|
for path, data := range map[string]string{
|
|
rootfs: "rootfs",
|
|
kernel: "kernel",
|
|
initrd: "initrd",
|
|
filepath.Join(modulesDir, "depmod"): "modules",
|
|
} {
|
|
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
db := openDaemonStore(t)
|
|
image := model.Image{
|
|
ID: "img-promote",
|
|
Name: "void",
|
|
Managed: false,
|
|
RootfsPath: rootfs,
|
|
KernelPath: kernel,
|
|
InitrdPath: initrd,
|
|
ModulesDir: modulesDir,
|
|
CreatedAt: model.Now(),
|
|
UpdatedAt: model.Now(),
|
|
}
|
|
if err := db.UpsertImage(context.Background(), image); err != nil {
|
|
t.Fatalf("UpsertImage: %v", err)
|
|
}
|
|
|
|
imagesDir := filepath.Join(dir, "images")
|
|
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir images dir: %v", err)
|
|
}
|
|
|
|
d := &Daemon{
|
|
layout: paths.Layout{ImagesDir: imagesDir},
|
|
store: db,
|
|
runner: system.NewRunner(),
|
|
}
|
|
wireServices(d)
|
|
got, err := d.img.PromoteImage(context.Background(), image.Name)
|
|
if err != nil {
|
|
t.Fatalf("PromoteImage: %v", err)
|
|
}
|
|
if !got.Managed {
|
|
t.Fatal("promoted image should be managed")
|
|
}
|
|
for _, path := range []string{got.RootfsPath, got.KernelPath, got.InitrdPath, got.ModulesDir} {
|
|
if !strings.HasPrefix(path, got.ArtifactDir) {
|
|
t.Fatalf("artifact path %q does not live under %q", path, got.ArtifactDir)
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("stat %s: %v", path, err)
|
|
}
|
|
}
|
|
}
|