From 822cd7337f030397dd8a322f87e9790d57a9847c Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:06:26 +0000 Subject: [PATCH] Enforce non-root customer services with RUN_AS_ROOT --- .../xorg-deps/xf86-input-neko/src/neko.c | 8 + server/README.md | 4 + server/cmd/api/api/process.go | 4 + server/cmd/api/api/process_privileges_test.go | 45 ++++ server/cmd/api/api/process_test.go | 6 +- server/cmd/api/main.go | 5 + server/cmd/chromium-launcher/main.go | 5 +- server/cmd/wrapper/main.go | 40 +++- server/cmd/wrapper/privileges.go | 201 ++++++++++++++++++ server/cmd/wrapper/privileges_test.go | 129 +++++++++++ server/cmd/wrapper/supervisord.go | 48 ++++- server/docs/non-root-mode.md | 76 +++++++ server/lib/browserlocation/localtime.go | 6 +- server/lib/runasroot/runasroot.go | 12 ++ server/lib/runasroot/runasroot_test.go | 15 ++ shared/envoy/init-envoy.sh | 10 +- shared/start-pulseaudio.sh | 10 +- 17 files changed, 597 insertions(+), 27 deletions(-) create mode 100644 server/cmd/api/api/process_privileges_test.go create mode 100644 server/cmd/wrapper/privileges.go create mode 100644 server/cmd/wrapper/privileges_test.go create mode 100644 server/docs/non-root-mode.md create mode 100644 server/lib/runasroot/runasroot.go create mode 100644 server/lib/runasroot/runasroot_test.go diff --git a/images/chromium-headful/xorg-deps/xf86-input-neko/src/neko.c b/images/chromium-headful/xorg-deps/xf86-input-neko/src/neko.c index 21bf9d61e..705a4859a 100644 --- a/images/chromium-headful/xorg-deps/xf86-input-neko/src/neko.c +++ b/images/chromium-headful/xorg-deps/xf86-input-neko/src/neko.c @@ -42,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -422,6 +423,13 @@ PreInit(__attribute__ ((unused)) InputDriverPtr drv, return BadValue; } + /* Allow Neko in Xorg's group to connect without running as root. */ + if (chmod(priv->socket_name, 0660) == -1) + { + xf86IDrvMsg(pInfo, X_ERROR, "unable to set socket permissions\n"); + return BadValue; + } + /* * Prepare for accepting connections. The backlog size is set * to 5. So while one request is being processed other requests diff --git a/server/README.md b/server/README.md index d656b1713..17083a6c5 100644 --- a/server/README.md +++ b/server/README.md @@ -48,6 +48,10 @@ curl http://localhost:10001/recording/download --output recording.mp4 Configure the server using environment variables: +`RUN_AS_ROOT=false` runs customer-facing image services as the `kernel` user; +`true` preserves root access. See [non-root mode](docs/non-root-mode.md) for the +privilege boundary, compatibility changes and validation steps. + | Variable | Default | Description | | -------------- | --------- | ------------------------------------------- | | `PORT` | `10001` | HTTP server port | diff --git a/server/cmd/api/api/process.go b/server/cmd/api/api/process.go index 83f25a8ea..5b310e4d1 100644 --- a/server/cmd/api/api/process.go +++ b/server/cmd/api/api/process.go @@ -26,6 +26,7 @@ import ( "github.com/kernel/kernel-images/server/lib/logger" oapi "github.com/kernel/kernel-images/server/lib/oapi" "github.com/kernel/kernel-images/server/lib/ptyio" + "github.com/kernel/kernel-images/server/lib/runasroot" "github.com/kernel/kernel-images/server/lib/wsdrain" openapi_types "github.com/oapi-codegen/runtime/types" ) @@ -79,6 +80,9 @@ func buildCmd(body *oapi.ProcessExecRequest) (*exec.Cmd, error) { if body == nil || body.Command == "" { return nil, errors.New("command required") } + if !runasroot.Enabled() && ((body.AsRoot != nil && *body.AsRoot) || (body.AsUser != nil && *body.AsUser != "")) { + return nil, errors.New("as_root and as_user are unavailable when RUN_AS_ROOT is false") + } var args []string if body.Args != nil { args = append(args, (*body.Args)...) diff --git a/server/cmd/api/api/process_privileges_test.go b/server/cmd/api/api/process_privileges_test.go new file mode 100644 index 000000000..21bd1fb1e --- /dev/null +++ b/server/cmd/api/api/process_privileges_test.go @@ -0,0 +1,45 @@ +package api + +import ( + "context" + "testing" + + oapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +func TestNonRootProcessIdentityRequests(t *testing.T) { + for _, mode := range []string{"", "false"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("RUN_AS_ROOT", mode) + for _, body := range []oapi.ProcessExecRequest{ + {Command: "true", AsRoot: ptrOf(true)}, + {Command: "true", AsUser: ptrOf("root")}, + {Command: "true", AsUser: ptrOf("0")}, + {Command: "true", AsUser: ptrOf("1000:0")}, + {Command: "true", AsUser: ptrOf("kernel")}, + {Command: "true", AsRoot: ptrOf(true), Env: &map[string]string{"RUN_AS_ROOT": "true"}}, + } { + _, err := buildCmd(&body) + require.ErrorContains(t, err, "RUN_AS_ROOT is false") + svc := &ApiService{} + resp, err := svc.ProcessExec(context.Background(), oapi.ProcessExecRequestObject{Body: &body}) + require.NoError(t, err) + require.IsType(t, oapi.ProcessExec400JSONResponse{}, resp) + spawn := oapi.ProcessSpawnRequest{Command: body.Command, AsRoot: body.AsRoot, AsUser: body.AsUser, Env: body.Env} + spawnResp, err := svc.ProcessSpawn(context.Background(), oapi.ProcessSpawnRequestObject{Body: &spawn}) + require.NoError(t, err) + require.IsType(t, oapi.ProcessSpawn400JSONResponse{}, spawnResp) + } + for _, body := range []oapi.ProcessExecRequest{ + {Command: "true"}, + {Command: "true", AsRoot: ptrOf(false)}, + {Command: "true", AsUser: ptrOf("")}, + } { + cmd, err := buildCmd(&body) + require.NoError(t, err) + require.Nil(t, cmd.SysProcAttr) + } + }) + } +} diff --git a/server/cmd/api/api/process_test.go b/server/cmd/api/api/process_test.go index ea15ae6da..9a951a057 100644 --- a/server/cmd/api/api/process_test.go +++ b/server/cmd/api/api/process_test.go @@ -248,7 +248,7 @@ func TestProcessSpawn_CommandNotFound(t *testing.T) { } func TestBuildCmd_AsRootSetsCredential(t *testing.T) { - t.Parallel() + t.Setenv("RUN_AS_ROOT", "true") asRoot := true body := &oapi.ProcessExecRequest{Command: "true", AsRoot: &asRoot} cmd, err := buildCmd(body) @@ -260,7 +260,7 @@ func TestBuildCmd_AsRootSetsCredential(t *testing.T) { } func TestBuildCmd_AsUserUidAndGidOverride(t *testing.T) { - t.Parallel() + t.Setenv("RUN_AS_ROOT", "true") cur, err := user.Current() if err != nil { t.Skipf("skipping: failed to determine current user: %v", err) @@ -284,7 +284,7 @@ func TestBuildCmd_AsUserUidAndGidOverride(t *testing.T) { } func TestBuildCmd_AsRootAndAsUserConflict(t *testing.T) { - t.Parallel() + t.Setenv("RUN_AS_ROOT", "true") asRoot := true asUser := "0" body := &oapi.ProcessExecRequest{Command: "true", AsRoot: &asRoot, AsUser: &asUser} diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index f2d4316e2..d68f8e20b 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -38,6 +38,7 @@ import ( "github.com/kernel/kernel-images/server/lib/nekoclient" oapi "github.com/kernel/kernel-images/server/lib/oapi" "github.com/kernel/kernel-images/server/lib/recorder" + "github.com/kernel/kernel-images/server/lib/runasroot" "github.com/kernel/kernel-images/server/lib/scaletozero" "github.com/kernel/kernel-images/server/lib/sysmon" "github.com/kernel/kernel-images/server/lib/telemetry" @@ -47,6 +48,10 @@ import ( func main() { slogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + if !runasroot.Enabled() && os.Geteuid() == 0 { + slogger.Error("RUN_AS_ROOT is false but the API was started as root") + os.Exit(1) + } // Load configuration from environment variables config, err := config.Load() diff --git a/server/cmd/chromium-launcher/main.go b/server/cmd/chromium-launcher/main.go index 4dc1cdb26..c8fd94bea 100644 --- a/server/cmd/chromium-launcher/main.go +++ b/server/cmd/chromium-launcher/main.go @@ -13,6 +13,7 @@ import ( "github.com/kernel/kernel-images/server/lib/browserlocation" "github.com/kernel/kernel-images/server/lib/chromiumflags" + "github.com/kernel/kernel-images/server/lib/runasroot" "github.com/kernel/kernel-images/server/lib/x11" ) @@ -111,7 +112,7 @@ func main() { } chromiumArgs = append(chromiumArgs, final...) - runAsRoot := strings.EqualFold(strings.TrimSpace(os.Getenv("RUN_AS_ROOT")), "true") + runAsRoot := runasroot.Enabled() // Prepare environment. PULSE_SERVER/PULSE_SINK route chromium's audio into the // recorder's sink; the root path below relies on this inherited env, while the @@ -124,7 +125,7 @@ func main() { "PULSE_SINK="+pulseSink, ) - if runAsRoot { + if runAsRoot || os.Geteuid() != 0 { // Replace current process with Chromium if p, err := execLookPath(*chromiumPath); err == nil { if err := syscall.Exec(p, append([]string{filepath.Base(p)}, chromiumArgs...), env); err != nil { diff --git a/server/cmd/wrapper/main.go b/server/cmd/wrapper/main.go index 832e3b5ed..528913a72 100644 --- a/server/cmd/wrapper/main.go +++ b/server/cmd/wrapper/main.go @@ -3,9 +3,8 @@ // where the dependency graph allows, and waits for CDP to be reachable through // kernel-images-api. // -// Replaces the legacy /wrapper.sh shipped in both images. Behavior parity is -// intentional — we still rely on supervisord, sysctl, dbus, etc. The only goal -// beyond parity is minimizing time-to-CDP-ready by removing serial dead time. +// Replaces the legacy /wrapper.sh shipped in both images. RUN_AS_ROOT selects +// whether customer-facing services retain root access or run as kernel. // // The headful vs headless profile is detected at boot from supervisor's conf.d // (xorg.conf → headful, xvfb.conf → headless), which keeps a single binary @@ -23,6 +22,8 @@ import ( "strings" "syscall" "time" + + "github.com/kernel/kernel-images/server/lib/runasroot" ) const ( @@ -113,7 +114,12 @@ func main() { _ = os.WriteFile("/proc/sys/net/ipv6/conf/default/disable_ipv6", []byte("1"), 0o644) // Pre-create per-user dirs so chromium subsystems don't error. - prepareUserDirs(os.Getenv("RUN_AS_ROOT") == "true") + prepareUserDirs(runasroot.Enabled()) + if !runasroot.Enabled() { + if err := prepareUnprivilegedServices(); err != nil { + fatalf("non-root service setup: %v", err) + } + } // Tail aggregator for service logs. startLogAggregator() @@ -140,6 +146,19 @@ func main() { // drop us out of supCmd.Wait() and the container would stop. logf("starting supervisord") supCmd := exec.Command("supervisord", "-n", "-c", supervisorConf) + if !runasroot.Enabled() { + rootSup := exec.Command("supervisord", "-n", "-c", rootSupervisorConf) + rootSup.Stdout, rootSup.Stderr = os.Stdout, os.Stderr + if err := rootSup.Start(); err != nil { + fatalf("root supervisord start: %v", err) + } + defer func() { + _ = rootSup.Process.Signal(syscall.SIGTERM) + _ = rootSup.Wait() + }() + waitForSocket(rootSupervisorSock, 10*time.Second) + supCmd = userSupervisorCommand() + } supCmd.Stdout = os.Stdout supCmd.Stderr = os.Stderr if err := supCmd.Start(); err != nil { @@ -155,7 +174,11 @@ func main() { _ = exec.Command("supervisorctl", "-c", supervisorConf, "stop", "all").Run() _ = supCmd.Process.Signal(syscall.SIGTERM) }() - waitForSocket(supervisorSock, 10*time.Second) + if runasroot.Enabled() { + waitForSocket(supervisorSock, 10*time.Second) + } else { + waitForSocket(userSupervisorDir+"/supervisor.sock", 10*time.Second) + } // Browser phase: identity-free services. Chromium itself doesn't read // any per-instance identity envs — it just needs the envoy CA cert @@ -181,7 +204,12 @@ func main() { // Pre-touch chromium's supervisord log so kernel-images-api's `tail -f` // doesn't bail out and enter its 250ms retry backoff when started in // parallel with chromium. - _ = os.WriteFile(filepath.Join(supervisordLogD, "chromium"), nil, 0o644) + chromiumLog := filepath.Join(supervisordLogD, "chromium") + if runasroot.Enabled() { + _ = os.WriteFile(chromiumLog, nil, 0o644) + } else { + runStreamFatal("chromium-log", "runuser", "-u", "kernel", "--", "touch", chromiumLog) + } browserStart := time.Now() startAll(xServer, "dbus", "chromedriver", "pulseaudio") diff --git a/server/cmd/wrapper/privileges.go b/server/cmd/wrapper/privileges.go new file mode 100644 index 000000000..22e8b1d30 --- /dev/null +++ b/server/cmd/wrapper/privileges.go @@ -0,0 +1,201 @@ +package main + +import ( + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + + "github.com/kernel/kernel-images/server/lib/runasroot" +) + +const ( + rootSupervisorConf = "/etc/supervisor/root.conf" + rootSupervisorSock = "/run/supervisor-root.sock" + userSupervisorDir = "/run/kernel-supervisor" + userServiceLogDir = "/var/log/kernel-services" +) + +// These services never execute customer commands. Their configuration and control +// socket stay root-only; the other supervisor and all its children are non-root. +func rootService(name string) bool { + switch name { + case "xorg", "dbus", "envoy": + return true + } + return false +} + +func supervisorConfig(prog string) string { + if !runasroot.Enabled() && rootService(prog) { + return rootSupervisorConf + } + return supervisorConf +} + +func prepareUnprivilegedServices() error { + u, err := user.Lookup("kernel") + if err != nil { + return err + } + uid, err := strconv.Atoi(u.Uid) + if err != nil { + return err + } + gid, err := strconv.Atoi(u.Gid) + if err != nil { + return err + } + for _, dir := range []string{userSupervisorDir, userServiceLogDir, "/recordings", "/chromium", "/run/kernel", "/run/kernel-location"} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := os.Chown(dir, uid, gid); err != nil { + return err + } + } + // Keep /etc root-owned. Only the indirection used for browser timezone + // changes is writable by the browser user. + if _, err := os.Lstat("/run/kernel-location/localtime"); os.IsNotExist(err) { + if err := os.Symlink("/usr/share/zoneinfo/Etc/UTC", "/run/kernel-location/localtime"); err != nil { + return err + } + } + if err := os.Remove("/etc/localtime"); err != nil && !os.IsNotExist(err) { + return err + } + if err := os.Symlink("/run/kernel-location/localtime", "/etc/localtime"); err != nil { + return err + } + + return splitSupervisorServices("/etc/supervisor", supervisordLogD, uid, gid) +} + +func splitSupervisorServices(configDir, logDir string, uid, gid int) error { + userLogDir := filepath.Join(filepath.Dir(logDir), "kernel-services") + if err := os.MkdirAll(userLogDir, 0o755); err != nil { + return err + } + if err := os.Chown(userLogDir, uid, gid); err != nil { + return err + } + rootConfDir := filepath.Join(configDir, "root-services") + rootSupervisorConf := filepath.Join(configDir, "root.conf") + supervisorConfD := filepath.Join(configDir, "conf.d/services") + supervisorConf := filepath.Join(configDir, "supervisord.conf") + if err := os.MkdirAll(rootConfDir, 0o700); err != nil { + return err + } + _, statErr := os.Stat(rootSupervisorConf) + firstBoot := os.IsNotExist(statErr) + if statErr != nil && !firstBoot { + return statErr + } + entries, err := os.ReadDir(supervisorConfD) + if err != nil { + return err + } + for _, entry := range entries { + // After first boot these files belong to the customer. Never read, + // rewrite or promote them from the root startup process again. + if !firstBoot || filepath.Ext(entry.Name()) != ".conf" { + continue + } + path := filepath.Join(supervisorConfD, entry.Name()) + name := strings.TrimSuffix(entry.Name(), ".conf") + if name == "supervisord-shim" { + // Each supervisor publishes crash events for its own children. + data, err := os.ReadFile(path) + if err != nil { + return err + } + rootShim := strings.ReplaceAll(string(data), "/var/log/supervisord/supervisord-shim", "/var/log/supervisord/root-supervisord-shim") + if err := os.WriteFile(filepath.Join(rootConfDir, entry.Name()), []byte(rootShim), 0o600); err != nil { + return err + } + } + if rootService(name) { + if name == "xorg" { + data, err := os.ReadFile(path) + if err != nil { + return err + } + // The input driver's 0660 socket must be accessible to Neko, + // including after an Xorg restart. + conf := strings.Replace(string(data), "command=", "command=/usr/bin/setpriv --regid=kernel --clear-groups ", 1) + if err := os.WriteFile(path, []byte(conf), 0o644); err != nil { + return err + } + } + if err := os.Rename(path, filepath.Join(rootConfDir, entry.Name())); err != nil { + return err + } + continue + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + conf := strings.ReplaceAll(string(data), "/var/log/supervisord/", "/var/log/kernel-services/") + if err := os.WriteFile(path, []byte(conf), 0o644); err != nil { + return err + } + if err := os.Chown(path, uid, gid); err != nil { + return err + } + // Keep the existing API/log-reader paths while allowing the user + // supervisor to rotate its logs without write access to root logs. + logPath := filepath.Join(logDir, name) + if err := os.Remove(logPath); err != nil && !os.IsNotExist(err) { + return err + } + if err := os.Symlink(filepath.Join(userLogDir, name), logPath); err != nil { + return err + } + } + // The user supervisor may reread its own configuration (e.g. Xvfb resize), + // but the root supervisor never reads this directory. + if err := os.Chown(supervisorConfD, uid, gid); err != nil { + return err + } + rootConf := `[unix_http_server] +file=/run/supervisor-root.sock +chmod=0600 +[supervisord] +logfile=/var/log/supervisord-root.log +pidfile=/run/supervisord-root.pid +[rpcinterface:supervisor] +supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface +[supervisorctl] +serverurl=unix:///run/supervisor-root.sock +[include] +files=/etc/supervisor/root-services/*.conf +` + if err := os.WriteFile(rootSupervisorConf, []byte(rootConf), 0o600); err != nil { + return err + } + userConf := `[unix_http_server] +file=/run/kernel-supervisor/supervisor.sock +chmod=0600 +[supervisord] +logfile=/run/kernel-supervisor/supervisord.log +pidfile=/run/kernel-supervisor/supervisord.pid +childlogdir=/run/kernel-supervisor +[rpcinterface:supervisor] +supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface +[supervisorctl] +serverurl=unix:///run/kernel-supervisor/supervisor.sock +[include] +files=/etc/supervisor/conf.d/services/*.conf +` + return os.WriteFile(supervisorConf, []byte(userConf), 0o644) +} + +func userSupervisorCommand() *exec.Cmd { + cmd := exec.Command("setpriv", "--reuid=kernel", "--regid=kernel", "--init-groups", "--no-new-privs", + "supervisord", "-n", "-c", supervisorConf) + cmd.Env = append(os.Environ(), "HOME=/home/kernel", "USER=kernel", "LOGNAME=kernel") + return cmd +} diff --git a/server/cmd/wrapper/privileges_test.go b/server/cmd/wrapper/privileges_test.go new file mode 100644 index 000000000..0ba75ac5f --- /dev/null +++ b/server/cmd/wrapper/privileges_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "net" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSupervisorRouting(t *testing.T) { + for _, mode := range []string{"", "false", "true"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("RUN_AS_ROOT", mode) + for _, service := range []string{"chromium", "chromedriver", "kernel-images-api", "neko", "openbox", "xvfb", "pulseaudio", "supervisord-shim"} { + require.Equal(t, supervisorConf, supervisorConfig(service)) + } + for _, service := range []string{"xorg", "dbus", "envoy"} { + want := rootSupervisorConf + if mode == "true" { + want = supervisorConf + } + require.Equal(t, want, supervisorConfig(service)) + } + }) + } +} + +func TestSplitSupervisorServices(t *testing.T) { + for _, profile := range []string{"chromium-headful", "chromium-headless/image"} { + t.Run(profile, func(t *testing.T) { + dir := t.TempDir() + services := filepath.Join(dir, "conf.d/services") + logs := filepath.Join(dir, "logs") + require.NoError(t, os.MkdirAll(services, 0o755)) + require.NoError(t, os.Mkdir(logs, 0o755)) + repo := filepath.Join("..", "..", "..") + configs, err := filepath.Glob(filepath.Join(repo, "images", profile, "supervisor/services/*.conf")) + require.NoError(t, err) + require.NotEmpty(t, configs) + for _, config := range configs { + data, err := os.ReadFile(config) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(services, filepath.Base(config)), data, 0o644)) + } + envoy, err := os.ReadFile(filepath.Join(repo, "shared/envoy/supervisor-envoy.conf")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(services, "envoy.conf"), envoy, 0o644)) + + require.NoError(t, splitSupervisorServices(dir, logs, os.Getuid(), os.Getgid())) + for _, service := range []string{"dbus", "envoy"} { + require.FileExists(t, filepath.Join(dir, "root-services", service+".conf")) + require.NoFileExists(t, filepath.Join(services, service+".conf")) + } + if profile == "chromium-headful" { + data, err := os.ReadFile(filepath.Join(dir, "root-services/xorg.conf")) + require.NoError(t, err) + require.Contains(t, string(data), "command=/usr/bin/setpriv --regid=kernel --clear-groups /usr/bin/Xorg") + require.NoFileExists(t, filepath.Join(services, "xorg.conf")) + require.FileExists(t, filepath.Join(services, "openbox.conf")) + require.FileExists(t, filepath.Join(services, "neko.conf")) + } else { + require.FileExists(t, filepath.Join(services, "xvfb.conf")) + require.NoFileExists(t, filepath.Join(dir, "root-services/xvfb.conf")) + } + data, err := os.ReadFile(filepath.Join(services, "chromium.conf")) + require.NoError(t, err) + require.Contains(t, string(data), "stdout_logfile=/var/log/kernel-services/chromium") + require.NotContains(t, string(data), "stdout_logfile_maxbytes=0") + logTarget, err := os.Readlink(filepath.Join(logs, "chromium")) + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, "kernel-services/chromium"), logTarget) + data, err = os.ReadFile(filepath.Join(dir, "root-services/supervisord-shim.conf")) + require.NoError(t, err) + require.Contains(t, string(data), "/var/log/supervisord/root-supervisord-shim") + require.FileExists(t, filepath.Join(services, "supervisord-shim.conf")) + for path, mode := range map[string]os.FileMode{"root.conf": 0o600, "root-services": 0o700, "supervisord.conf": 0o644} { + stat, err := os.Stat(filepath.Join(dir, path)) + require.NoError(t, err) + require.Equal(t, mode, stat.Mode().Perm()) + } + + // Customer configuration must not be promoted or followed by root on reboot. + rootDbus, err := os.ReadFile(filepath.Join(dir, "root-services/dbus.conf")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(services, "dbus.conf"), []byte("untrusted"), 0o644)) + protected := filepath.Join(dir, "protected") + require.NoError(t, os.WriteFile(protected, []byte("unchanged"), 0o600)) + require.NoError(t, os.Remove(filepath.Join(services, "chromium.conf"))) + require.NoError(t, os.Symlink(protected, filepath.Join(services, "chromium.conf"))) + require.NoError(t, splitSupervisorServices(dir, logs, os.Getuid(), os.Getgid())) + data, err = os.ReadFile(filepath.Join(dir, "root-services/dbus.conf")) + require.NoError(t, err) + require.Equal(t, rootDbus, data) + data, err = os.ReadFile(protected) + require.NoError(t, err) + require.Equal(t, "unchanged", string(data)) + }) + } +} + +func TestSocketReadinessRejectsStaleSockets(t *testing.T) { + path := filepath.Join(t.TempDir(), "supervisor.sock") + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) + require.NoError(t, err) + listener.SetUnlinkOnClose(false) + require.True(t, unixSocketReady(path)) + require.NoError(t, listener.Close()) + require.FileExists(t, path) + require.False(t, unixSocketReady(path)) +} + +func TestLogReaderDropsPrivileges(t *testing.T) { + t.Setenv("RUN_AS_ROOT", "false") + cmd := tailCommand("/var/log/supervisord/chromium") + require.Contains(t, cmd.Args, "--reuid=kernel") + require.Contains(t, cmd.Args, "--no-new-privs") + t.Setenv("RUN_AS_ROOT", "true") + require.Equal(t, "tail", filepath.Base(tailCommand("/test").Path)) +} + +func TestUserSupervisorCannotAcquirePrivileges(t *testing.T) { + cmd := userSupervisorCommand() + require.Contains(t, cmd.Args, "--no-new-privs") + require.Contains(t, cmd.Args, "--reuid=kernel") + require.Contains(t, cmd.Args, "--regid=kernel") + require.Contains(t, cmd.Env, "HOME=/home/kernel") +} diff --git a/server/cmd/wrapper/supervisord.go b/server/cmd/wrapper/supervisord.go index 1d2ad2565..b024fe2b8 100644 --- a/server/cmd/wrapper/supervisord.go +++ b/server/cmd/wrapper/supervisord.go @@ -3,11 +3,14 @@ package main import ( "bufio" "fmt" + "net" "os" "os/exec" "path/filepath" "strings" "time" + + "github.com/kernel/kernel-images/server/lib/runasroot" ) const ( @@ -17,9 +20,8 @@ const ( supervisordLogD = "/var/log/supervisord" ) -// startAll asks supervisord to start the given programs. We invoke -// supervisorctl once (it accepts multiple args) so we don't pay python -// cold-start costs per service. +// startAll asks each supervisor to start its programs in one call, avoiding +// Python cold-start costs per service. func startAll(progs ...string) { supervisorctl("start", progs...) } @@ -39,17 +41,24 @@ func supervisorctl(verb string, progs ...string) { if len(progs) == 0 { return } - args := append([]string{"-c", supervisorConf, verb}, progs...) - cmd := exec.Command("supervisorctl", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - _ = cmd.Run() // a service that fails to come up will surface via readiness checks + groups := map[string][]string{} + for _, prog := range progs { + conf := supervisorConfig(prog) + groups[conf] = append(groups[conf], prog) + } + for conf, programs := range groups { + args := append([]string{"-c", conf, verb}, programs...) + cmd := exec.Command("supervisorctl", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() // a service that fails to come up will surface via readiness checks + } } func waitForSocket(path string, timeout time.Duration) { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - if fi, err := os.Stat(path); err == nil && fi.Mode()&os.ModeSocket != 0 { + if unixSocketReady(path) { return } time.Sleep(20 * time.Millisecond) @@ -57,6 +66,15 @@ func waitForSocket(path string, timeout time.Duration) { logf("WARNING: socket %s not ready after %s", path, timeout) } +func unixSocketReady(path string) bool { + conn, err := net.DialTimeout("unix", path, 100*time.Millisecond) + if err != nil { + return false + } + _ = conn.Close() + return true +} + // startLogAggregator tails any file under /var/log/supervisord, prefixing // each line with the relative path so the container log stream remains // readable. @@ -81,8 +99,18 @@ func startLogAggregator() { }() } +func tailCommand(path string) *exec.Cmd { + if runasroot.Enabled() { + return exec.Command("tail", "-n", "+1", "-F", path) + } + // Customer logs can be replaced with symlinks. Reading them as root + // would turn the log stream into a privileged file-read API. + return exec.Command("setpriv", "--reuid=kernel", "--regid=kernel", "--init-groups", "--no-new-privs", + "tail", "-n", "+1", "-F", path) +} + func tailFile(path string) { - cmd := exec.Command("tail", "-n", "+1", "-F", path) + cmd := tailCommand(path) stdout, err := cmd.StdoutPipe() if err != nil { return diff --git a/server/docs/non-root-mode.md b/server/docs/non-root-mode.md new file mode 100644 index 000000000..25024f9f4 --- /dev/null +++ b/server/docs/non-root-mode.md @@ -0,0 +1,76 @@ +# Non-root browser images + +`RUN_AS_ROOT=false` (also the default when unset) runs customer-facing services +as the `kernel` user. `RUN_AS_ROOT=true` retains root-mode behavior. This applies +to both headful and headless images. + +This extends the old flag, which primarily selected Chromium's user. Deployments +that already set it to false, or leave it unset, now also lose root access through +the process and filesystem APIs. The launch scripts retain their existing +defaults: headful `run-unikernel.sh` supplies true, while headful `run-docker.sh` +and headless `run-unikernel.sh` supply false. + +Use a fresh container or guest when switching modes. Changing the flag cannot +safely revoke root access from an existing filesystem that a caller has already +controlled as root, and the supervisor split is not reversed by setting true. + +## Privilege boundary + +The wrapper starts two supervisors in non-root mode: + +- The user supervisor runs with `setpriv --no-new-privs` as `kernel`, as do its + children: the API, Chromium launcher/browser, ChromeDriver, Neko, Openbox, + PulseAudio and Xvfb. API-created processes, Playwright executors and the browser + REPL inherit that identity. Setuid binaries and file capabilities cannot + elevate these processes. +- The root supervisor manages Xorg, dbus and Envoy. Its configuration directory + and control socket are inaccessible to the user. Each supervisor has its own + crash-event listener. + +The API can restart its own browser and live-view services, or reconfigure Xvfb, +without access to the root supervisor. Customer-writable supervisor configuration +is never loaded by the root supervisor, including on subsequent boots. User +service logs rotate separately from root service logs; the wrapper reads them as +`kernel` so log symlinks cannot expose root files. + +Profiles, browser policies, recordings and timezone indirection remain +user-writable. Xorg's configuration, Neko's static configuration and executables +remain root-owned. Xorg creates its input socket with group access for Neko. + +## Compatibility + +- `as_root: true` and nonempty `as_user` are rejected with HTTP 400 by both exec + and spawn, including `as_user: "kernel"`. Request environment overrides cannot + change the server's policy. +- Filesystem APIs can only access files allowed by the `kernel` user's OS + permissions. Root package installation and arbitrary system modifications are + unavailable. +- Integrations that bootstrap SSH or other services through root process calls + must be adapted, or use an explicitly root-mode image. +- Root-only API diagnostics such as reading `/dev/kmsg` are unavailable. Crash + events from supervisor remain available. +- This is not a boundary against the host administrator or guest-kernel + vulnerabilities. + +## Validation + +Run the Go tests and vet from `server/`: + +```sh +go test -race $(go list ./... | grep -v /e2e$) +go vet ./... +``` + +On disposable headful and headless containers, exercise both privilege modes: + +- Confirm the API, browser and exec/spawn effective identities and `NoNewPrivs`. +- Check exec/spawn identity overrides, request-environment overrides and + filesystem permissions. +- Verify root supervisor configuration and its socket are inaccessible to the + browser user, including after restarting with modified user service configs. +- Exercise browser restart, display resize, timezone changes, screenshots, + recording with audio and headful live-view input/video. + +The Go tests cover policy checks, the split of both images' service configurations, +configuration isolation across restarts and stale-socket readiness. They do not +replace image-level validation of the browser, display and media services. diff --git a/server/lib/browserlocation/localtime.go b/server/lib/browserlocation/localtime.go index 6d0f1def5..6af70fdfd 100644 --- a/server/lib/browserlocation/localtime.go +++ b/server/lib/browserlocation/localtime.go @@ -29,7 +29,11 @@ func ZonePath(timezone string) (string, error) { // SetLocaltime points /etc/localtime at timezone so that Chromium, which // watches the file, reports HostTimeZone(timezone) as its host timezone. func SetLocaltime(timezone string) error { - return setLocaltime(zoneinfoDir, localtimeCopyDir, localtimePath, timezone) + path := localtimePath + if os.Geteuid() != 0 { + path = "/run/kernel-location/localtime" + } + return setLocaltime(zoneinfoDir, localtimeCopyDir, path, timezone) } // HostTimeZone is the identifier the browser reports as its host timezone diff --git a/server/lib/runasroot/runasroot.go b/server/lib/runasroot/runasroot.go new file mode 100644 index 000000000..42e6c2fa5 --- /dev/null +++ b/server/lib/runasroot/runasroot.go @@ -0,0 +1,12 @@ +// Package runasroot defines the privilege policy for customer-facing services. +package runasroot + +import ( + "os" + "strings" +) + +// Enabled reports whether RUN_AS_ROOT explicitly enables root mode. +func Enabled() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv("RUN_AS_ROOT")), "true") +} diff --git a/server/lib/runasroot/runasroot_test.go b/server/lib/runasroot/runasroot_test.go new file mode 100644 index 000000000..3d79b2b3f --- /dev/null +++ b/server/lib/runasroot/runasroot_test.go @@ -0,0 +1,15 @@ +package runasroot + +import "testing" + +func TestEnabled(t *testing.T) { + for _, value := range []string{"", "false", "0", "invalid", "true", " TRUE "} { + t.Run(value, func(t *testing.T) { + t.Setenv("RUN_AS_ROOT", value) + want := value == "true" || value == " TRUE " + if got := Enabled(); got != want { + t.Fatalf("Enabled() = %t, want %t", got, want) + } + }) + } +} diff --git a/shared/envoy/init-envoy.sh b/shared/envoy/init-envoy.sh index f11b9f2d2..a1ffd68fe 100644 --- a/shared/envoy/init-envoy.sh +++ b/shared/envoy/init-envoy.sh @@ -42,10 +42,14 @@ echo "[envoy-init] Starting Envoy via supervisord" # on the current state so cold boots only `start`, while re-renders (e.g. # post-fork env refresh) `restart` to force a clean re-read of the # rendered bootstrap. -if supervisorctl -c /etc/supervisor/supervisord.conf status envoy | grep -q RUNNING; then - supervisorctl -c /etc/supervisor/supervisord.conf restart envoy +supervisor_config=/etc/supervisor/supervisord.conf +if [[ -f /etc/supervisor/root.conf ]]; then + supervisor_config=/etc/supervisor/root.conf +fi +if supervisorctl -c "$supervisor_config" status envoy | grep -q RUNNING; then + supervisorctl -c "$supervisor_config" restart envoy else - supervisorctl -c /etc/supervisor/supervisord.conf start envoy + supervisorctl -c "$supervisor_config" start envoy fi # Readiness (port 3128 reachable) is probed by the Go wrapper's diff --git a/shared/start-pulseaudio.sh b/shared/start-pulseaudio.sh index 9a1d4b66b..d9a982c18 100644 --- a/shared/start-pulseaudio.sh +++ b/shared/start-pulseaudio.sh @@ -13,7 +13,9 @@ KERNEL_SINK=KernelOutput KERNEL_SOURCE=KernelInput mkdir -p /tmp/pulse /tmp/runtime-kernel /home/kernel/.config/pulse -chown -R kernel:kernel /tmp/pulse /tmp/runtime-kernel /home/kernel/.config/pulse +if [[ "$EUID" -eq 0 ]]; then + chown -R kernel:kernel /tmp/pulse /tmp/runtime-kernel /home/kernel/.config/pulse +fi chmod 1777 /tmp/pulse chmod 700 /tmp/runtime-kernel @@ -24,7 +26,11 @@ rm -f "$PULSE_SOCKET" # Constants are passed into the inner (single-quoted) script via env so the # topology is defined once at the top of this file. -exec runuser -u kernel -- env \ +runner=() +if [[ "$EUID" -eq 0 ]]; then + runner=(runuser -u kernel --) +fi +exec "${runner[@]}" env \ -u DBUS_SESSION_BUS_ADDRESS \ -u DBUS_SYSTEM_BUS_ADDRESS \ HOME=/home/kernel \