//go:build linux package host import ( "bytes" "fmt" "io" "os" "os/exec" "os/user" "path/filepath" "runtime" "strconv" "strings" "syscall" "testing" "github.com/carlosframework/platform/internal/registry" "golang.org/x/sys/unix" ) func TestRegistryChildHardeningHelper(t *testing.T) { if os.Args[len(os.Args)-1] != "--hardening-probe" { return } if os.Geteuid() != 0 { t.Fatal("child lost root before its in-process credential drop") } service, err := user.Lookup(serviceUser) if err != nil { t.Fatal(err) } uid, err := strconv.Atoi(service.Uid) if err != nil { t.Fatal(err) } gid, err := strconv.Atoi(service.Gid) if err != nil { t.Fatal(err) } // Malformed input must still drop all credentials and disable dumpability. if err := RunRegistryChild(nil); err == nil { t.Fatal("invalid arguments accepted") } ruid, euid, suid := unix.Getresuid() rgid, egid, sgid := unix.Getresgid() groups, err := os.Getgroups() if err != nil || ruid != uid || euid != uid || suid != uid || rgid != gid || egid != gid || sgid != gid || len(groups) != 0 { t.Fatalf("child credentials = uid %d:%d:%d, gid %d:%d:%d, groups %v: %v", ruid, euid, suid, rgid, egid, sgid, groups, err) } dumpable, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0) if err != nil || dumpable != 0 { t.Fatalf("dumpable = %d, %v", dumpable, err) } threads, err := os.ReadDir("/proc/self/task") if err != nil { t.Fatal(err) } for _, thread := range threads { status, err := os.ReadFile(filepath.Join("/proc/self/task", thread.Name(), "status")) if os.IsNotExist(err) { continue } if err != nil || !bytes.Contains(status, []byte("NoNewPrivs:\t1\n")) { t.Fatalf("thread %s lacks no-new-privileges: %v", thread.Name(), err) } for _, want := range []string{ fmt.Sprintf("Uid:\t%d\t%d\t%d\t%d\n", uid, uid, uid, uid), fmt.Sprintf("Gid:\t%d\t%d\t%d\t%d\n", gid, gid, gid, gid), "Groups:\t\n", } { if !bytes.Contains(status, []byte(want)) { t.Fatalf("thread %s lacks exact credentials %q: %s", thread.Name(), want, status) } } } if err := syscall.Setresuid(0, 0, 0); err == nil { t.Fatal("child regained root uid") } if err := syscall.Setresgid(0, 0, 0); err == nil { t.Fatal("child regained root gid") } } func TestRegistryChildHardening(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires root to verify the child's in-process credential drop and dumpability") } if _, err := user.Lookup(serviceUser); err != nil { t.Skip("requires the carlos service account") } // Keep this thread pinned while verifying the spawn did not change it. runtime.LockOSThread() defer runtime.UnlockOSThread() before, err := unix.PrctlRetInt(unix.PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) if err != nil { t.Fatal(err) } executable, err := os.Executable() if err != nil { t.Fatal(err) } cmd := exec.Command(executable, "-test.run=^TestRegistryChildHardeningHelper$", "--", "--hardening-probe") var output bytes.Buffer cmd.Stdout, cmd.Stderr = &output, &output if err := registryChildStart(cmd); err != nil { t.Fatal(err) } if err := cmd.Wait(); err != nil { t.Fatalf("hardening probe: %v: %s", err, output.String()) } after, err := unix.PrctlRetInt(unix.PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) if err != nil || after != before { t.Fatalf("parent no-new-privileges changed from %d to %d: %v", before, after, err) } } func TestRegistryChildFDHelper(t *testing.T) { if len(os.Args) == 0 || os.Args[len(os.Args)-1] != "--fd-probe" { return } closeRegistryExecutable() entries, err := os.ReadDir("/proc/self/fd") if err != nil { os.Exit(20) } for _, entry := range entries { fd, err := strconv.Atoi(entry.Name()) if err != nil || fd <= 2 { continue } target, err := os.Readlink("/proc/self/fd/" + entry.Name()) if os.IsNotExist(err) { continue } if err != nil { os.Exit(21) } if target != "anon_inode:[eventpoll]" && target != "anon_inode:[eventfd]" { os.Exit(22) } } os.Exit(0) } func TestRegistryChildPinnedExecutableAndMinimalFDs(t *testing.T) { dir := t.TempDir() original, err := os.Open("/proc/self/exe") if err != nil { t.Fatal(err) } defer original.Close() path := filepath.Join(dir, "worker") destination, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0755) if err != nil { t.Fatal(err) } if _, err := io.Copy(destination, original); err != nil { t.Fatal(err) } if err := destination.Close(); err != nil { t.Fatal(err) } pinned, err := os.Open(path) if err != nil { t.Fatal(err) } defer pinned.Close() saved, savedErr := registryExecutable, registryExecutableError registryExecutable, registryExecutableError = pinned, nil defer func() { registryExecutable, registryExecutableError = saved, savedErr }() if err := os.Rename(path, path+".old"); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("replacement is not executable"), 0755); err != nil { t.Fatal(err) } inherited, err := os.OpenFile(filepath.Join(dir, "must-not-inherit"), os.O_CREATE|os.O_RDWR, 0600) if err != nil { t.Fatal(err) } defer inherited.Close() if _, err := unix.FcntlInt(inherited.Fd(), unix.F_SETFD, 0); err != nil { t.Fatal(err) } cmd, err := registryChildExec("unused", 65534, 65534) if err != nil { t.Fatal(err) } cmd.Args = []string{cmd.Path, "-test.run=^TestRegistryChildFDHelper$", "--", "--fd-probe"} if err := registryChildCloseExec(); err != nil { t.Fatal(err) } if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("pinned child/fd probe: %v: %s", err, output) } } func TestRegistryChildRealMainStartup(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires root to exercise the real service boundary") } service, err := user.Lookup(serviceUser) if err != nil { t.Skip("requires the carlos service account") } uid, err := strconv.Atoi(service.Uid) if err != nil || uid <= 0 { t.Fatal("invalid service uid") } gid, err := strconv.Atoi(service.Gid) if err != nil { t.Fatal(err) } dir, err := os.MkdirTemp(os.TempDir(), "registry-real-main-") if err != nil { t.Fatal(err) } defer os.RemoveAll(dir) if err := os.Chmod(dir, 0755); err != nil { t.Fatal(err) } path := filepath.Join(dir, "carlos") build := exec.Command("go", "build", "-o", path, ".") build.Dir = "../.." if output, err := build.CombinedOutput(); err != nil { t.Fatalf("build real main: %v: %s", err, output) } pinned, err := os.Open(path) if err != nil { t.Fatal(err) } defer pinned.Close() saved, savedErr := registryExecutable, registryExecutableError registryExecutable, registryExecutableError = pinned, nil defer func() { registryExecutable, registryExecutableError = saved, savedErr }() if err := os.Rename(path, path+".old"); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("replacement"), 0755); err != nil { t.Fatal(err) } data := filepath.Join(dir, "data") if err := os.Mkdir(data, 0700); err != nil { t.Fatal(err) } if err := os.Chown(data, uid, gid); err != nil { t.Fatal(err) } dbPath := filepath.Join(data, "registry.db") t.Setenv("CARLOS_HOST_CLAIM", "true") r, err := openServiceRegistry(dbPath) if err != nil { t.Fatal(err) } defer r.Close() if err := r.Upsert(registry.Route{Host: "real-main", Kind: "instance"}); err != nil { t.Fatal(err) } for _, suffix := range []string{"", "-wal", "-shm"} { info, err := os.Stat(dbPath + suffix) if err != nil { t.Fatal(err) } gotUID, gotGID, ok := fileOwner(info) if !ok || gotUID != uid || gotGID != gid { t.Fatalf("%s owner = %d:%d", strings.TrimPrefix(dbPath+suffix, dir), gotUID, gotGID) } } }