Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions guest/boot/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ import (
// 3. Workspace mount (non-fatal if fails)
// 4. Kernel sysctl hardening
// 5. Lock down /root (if enabled)
// 6. Load environment file
// 7. Parse SSH authorized keys
// 8. Drop bounding capabilities + set no_new_privs
// 9. Start SSH server
// 6. Fix home directory ownership (rootfs hooks may not chown on macOS)
// 7. Load environment file
// 8. Parse SSH authorized keys
// 9. Drop bounding capabilities + set no_new_privs
// 10. Start SSH server
func Run(logger *slog.Logger, opts ...Option) (shutdown func(), err error) {
cfg := defaultConfig()
for _, o := range opts {
Expand Down Expand Up @@ -74,19 +75,25 @@ func Run(logger *slog.Logger, opts ...Option) (shutdown func(), err error) {
lockdownRoot(logger)
}

// 6. Load environment file.
// 6. Fix home directory ownership. Rootfs hooks run on the host
// before boot and may not be able to chown to the sandbox UID (e.g.
// macOS non-root users). Fix ownership now that we're running as
// root inside the guest.
fixHomeOwnership(logger, cfg.userHome, int(cfg.userUID), int(cfg.userGID))

// 7. Load environment file.
envVars, err := env.Load(cfg.envFilePath)
if err != nil {
return nil, fmt.Errorf("loading environment: %w", err)
}

// 7. Parse authorized keys.
// 8. Parse authorized keys.
authorizedKeys, err := ParseAuthorizedKeys(cfg.sshKeysPath)
if err != nil {
return nil, fmt.Errorf("parsing authorized keys: %w", err)
}

// 7b. Load injected host key (if present). The key is deleted from
// 8b. Load injected host key (if present). The key is deleted from
// disk after loading into memory so it cannot be read by the sandbox
// user. If the file does not exist, hostKeySigner remains nil and the
// SSH server will generate an ephemeral key.
Expand All @@ -103,7 +110,7 @@ func Run(logger *slog.Logger, opts ...Option) (shutdown func(), err error) {
_ = os.Remove(cfg.sshHostKeyPath)
}

// 8. Drop unneeded capabilities from the bounding set.
// 9. Drop unneeded capabilities from the bounding set.
logger.Info("dropping unnecessary capabilities")
if err := harden.DropBoundingCaps(
harden.CapSetUID,
Expand All @@ -118,7 +125,7 @@ func Run(logger *slog.Logger, opts ...Option) (shutdown func(), err error) {
return nil, fmt.Errorf("setting no_new_privs: %w", err)
}

// 9. Start SSH server.
// 10. Start SSH server.
sshdCfg := sshd.Config{
Port: cfg.sshPort,
AuthorizedKeys: authorizedKeys,
Expand Down
69 changes: 69 additions & 0 deletions guest/boot/fixhome.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

//go:build linux

package boot

import (
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
)

// fixHomeOwnership recursively chowns the user's home directory so that
// files injected by rootfs hooks (which may have been written by a non-root
// host user) are owned by the sandbox user. It also enforces strict SSH
// directory permissions (0700 for .ssh/, 0600 for files inside .ssh/).
//
// This runs as PID 1 (root) inside the guest, so chown always succeeds.
func fixHomeOwnership(logger *slog.Logger, home string, uid, gid int) {
logger.Info("fixing home directory ownership", "home", home, "uid", uid, "gid", gid)

err := filepath.WalkDir(home, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}

// Skip symlinks — Lchown on a symlink itself is harmless but
// Chmod would follow the symlink and modify the target.
if d.Type()&fs.ModeSymlink != 0 {
return nil
}

if chownErr := os.Lchown(path, uid, gid); chownErr != nil {
logger.Warn("chown failed", "path", path, "error", chownErr)
}

// Enforce strict SSH permissions.
// Rel cannot fail for paths from WalkDir(home, ...).
rel, _ := filepath.Rel(home, path)
if isSSHPath(rel) {
enforcePerm := sshPermission(d.IsDir())
if chmodErr := os.Chmod(path, enforcePerm); chmodErr != nil {
logger.Warn("chmod failed", "path", path, "perm", enforcePerm, "error", chmodErr)
}
}

return nil
})
if err != nil {
logger.Warn("home ownership fixup incomplete", "home", home, "error", err)
}
}

// isSSHPath returns true if the relative path is inside the .ssh directory.
func isSSHPath(rel string) bool {
return rel == ".ssh" || strings.HasPrefix(rel, ".ssh"+string(filepath.Separator))
}

// sshPermission returns the required permission for SSH paths:
// 0700 for directories, 0600 for files.
func sshPermission(isDir bool) os.FileMode {
if isDir {
return 0o700
}
return 0o600
}
118 changes: 118 additions & 0 deletions guest/boot/fixhome_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

//go:build linux

package boot

import (
"log/slog"
"os"
"os/user"
"path/filepath"
"strconv"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestFixHomeOwnership_FixesPermissions(t *testing.T) {
t.Parallel()

u, err := user.Current()
require.NoError(t, err)
uid, err := strconv.Atoi(u.Uid)
require.NoError(t, err)
gid, err := strconv.Atoi(u.Gid)
require.NoError(t, err)

home := t.TempDir()

// Create .ssh dir with wrong permissions (0755 instead of 0700).
sshDir := filepath.Join(home, ".ssh")
require.NoError(t, os.MkdirAll(sshDir, 0o755))

// Create authorized_keys with wrong permissions (0644 instead of 0600).
akPath := filepath.Join(sshDir, "authorized_keys")
require.NoError(t, os.WriteFile(akPath, []byte("ssh-ed25519 AAAA test"), 0o644))

// Create a non-SSH file that should not get SSH permission enforcement.
require.NoError(t, os.WriteFile(filepath.Join(home, ".gitconfig"), []byte("[user]"), 0o644))

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))

fixHomeOwnership(logger, home, uid, gid)

// Verify .ssh directory permissions are 0700.
info, err := os.Stat(sshDir)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o700), info.Mode().Perm(), ".ssh dir should be 0700")

// Verify authorized_keys permissions are 0600.
info, err = os.Stat(akPath)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "authorized_keys should be 0600")

// Verify non-SSH file permissions are unchanged.
info, err = os.Stat(filepath.Join(home, ".gitconfig"))
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o644), info.Mode().Perm(), ".gitconfig should be unchanged")
}

func TestFixHomeOwnership_HandlesNestedSSHFiles(t *testing.T) {
t.Parallel()

u, err := user.Current()
require.NoError(t, err)
uid, err := strconv.Atoi(u.Uid)
require.NoError(t, err)
gid, err := strconv.Atoi(u.Gid)
require.NoError(t, err)

home := t.TempDir()

// Create .ssh with a known_hosts file.
sshDir := filepath.Join(home, ".ssh")
require.NoError(t, os.MkdirAll(sshDir, 0o755))
khPath := filepath.Join(sshDir, "known_hosts")
require.NoError(t, os.WriteFile(khPath, []byte("github.com ..."), 0o644))

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))

fixHomeOwnership(logger, home, uid, gid)

info, err := os.Stat(khPath)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "known_hosts should be 0600")
}

func TestFixHomeOwnership_MissingHome(t *testing.T) {
t.Parallel()

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))

// Should not panic on missing directory — just logs a warning.
fixHomeOwnership(logger, "/nonexistent/home/dir", 1000, 1000)
}

func TestIsSSHPath(t *testing.T) {
t.Parallel()

tests := []struct {
rel string
want bool
}{
{".ssh", true},
{".ssh/authorized_keys", true},
{".ssh/known_hosts", true},
{".gitconfig", false},
{".config/opencode", false},
{"", false},
{".sshconfig", false},
}

for _, tt := range tests {
assert.Equal(t, tt.want, isSSHPath(tt.rel), "isSSHPath(%q)", tt.rel)
}
}
41 changes: 34 additions & 7 deletions hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package hooks

import (
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
Expand All @@ -27,21 +28,32 @@ type keyOptionFunc func(*keyConfig)

func (f keyOptionFunc) apply(c *keyConfig) { f(c) }

// ChownFunc abstracts file ownership changes for testability.
// Production code uses BestEffortLchown; tests can pass a recording mock.
type ChownFunc func(path string, uid, gid int) error

type keyConfig struct {
home string
uid int
gid int
home string
uid int
gid int
chown ChownFunc
}

func defaultKeyConfig() *keyConfig {
return &keyConfig{home: "/home/sandbox", uid: 1000, gid: 1000}
return &keyConfig{home: "/home/sandbox", uid: 1000, gid: 1000, chown: BestEffortLchown}
}

// WithKeyUser overrides the default user home, UID, and GID for SSH key injection.
func WithKeyUser(home string, uid, gid int) KeyOption {
return keyOptionFunc(func(c *keyConfig) { c.home = home; c.uid = uid; c.gid = gid })
}

// WithChown overrides the chown function used by InjectAuthorizedKeys.
// Useful for testing or environments where chown must be handled differently.
func WithChown(fn ChownFunc) KeyOption {
return keyOptionFunc(func(c *keyConfig) { c.chown = fn })
}

// InjectAuthorizedKeys returns a RootFSHook that writes the given public key
// to {home}/.ssh/authorized_keys inside the rootfs. The .ssh directory is
// created with 0700 permissions and the authorized_keys file with 0600.
Expand All @@ -61,7 +73,7 @@ func InjectAuthorizedKeys(pubKey string, opts ...KeyOption) func(string, *image.
if err := os.MkdirAll(sshDir, 0o700); err != nil {
return fmt.Errorf("create .ssh dir: %w", err)
}
if err := os.Chown(sshDir, cfg.uid, cfg.gid); err != nil {
if err := cfg.chown(sshDir, cfg.uid, cfg.gid); err != nil {
return fmt.Errorf("chown .ssh dir: %w", err)
}

Expand All @@ -73,7 +85,7 @@ func InjectAuthorizedKeys(pubKey string, opts ...KeyOption) func(string, *image.
if err := os.WriteFile(akPath, []byte(pubKey+"\n"), 0o600); err != nil {
return fmt.Errorf("write authorized_keys: %w", err)
}
if err := os.Chown(akPath, cfg.uid, cfg.gid); err != nil {
if err := cfg.chown(akPath, cfg.uid, cfg.gid); err != nil {
return fmt.Errorf("chown authorized_keys: %w", err)
}

Expand Down Expand Up @@ -152,5 +164,20 @@ func InjectEnvFile(guestPath string, envMap map[string]string) func(string, *ima
// shellEscape wraps a value in single quotes for safe shell sourcing.
// Internal single quotes are escaped with the '\” idiom.
func shellEscape(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

// BestEffortLchown attempts os.Lchown and silently ignores permission errors,
// returning nil. On macOS non-root users cannot chown to a different UID;
// the guest init will fix ownership at boot time. Non-permission errors are
// logged at warn level and also swallowed. Callers that need strict chown
// should call os.Lchown directly instead.
// Lchown is used instead of Chown to avoid following symlinks in the rootfs.
func BestEffortLchown(path string, uid, gid int) error {
if err := os.Lchown(path, uid, gid); err != nil {
if !os.IsPermission(err) {
slog.Warn("lchown failed", "path", path, "uid", uid, "gid", gid, "err", err)
}
}
return nil
}
Loading