diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ github.com/hpcloud/tail v1.0.0 github.com/ipfs/go-cid v0.6.0 github.com/jackc/pgx/v5 v5.8.0 + github.com/landlock-lsm/go-landlock v0.8.1 github.com/mattn/go-sqlite3 v1.14.34 github.com/microcosm-cc/bluemonday v1.0.27 github.com/multiformats/go-multihash v0.2.3 @@ -70,6 +71,7 @@ golang.org/x/image v0.31.0 golang.org/x/net v0.50.0 golang.org/x/sync v0.19.0 + golang.org/x/sys v0.41.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da gopkg.in/yaml.v3 v3.0.1 ) @@ -274,7 +276,6 @@ go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect - golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/protobuf v1.36.11 // indirect @@ -286,6 +287,7 @@ gorm.io/driver/sqlite v1.6.0 // indirect gorm.io/gorm v1.31.1 // indirect gotest.tools/v3 v3.5.2 // indirect + kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -494,6 +494,8 @@ github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= github.com/labstack/gommon v0.4.1 h1:gqEff0p/hTENGMABzezPoPSRtIh1Cvw0ueMOe0/dfOk= github.com/labstack/gommon v0.4.1/go.mod h1:TyTrpPqxR5KMk8LKVtLmfMjeQ5FEkBYdxLYPw/WfrOM= +github.com/landlock-lsm/go-landlock v0.8.1 h1:Krs1co16IzN7bQcFYIdtNF+BKwZem3geRBkVsZtlCKU= +github.com/landlock-lsm/go-landlock v0.8.1/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw= github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= github.com/libp2p/go-libp2p v0.47.0 h1:qQpBjSCWNQFF0hjBbKirMXE9RHLtSuzTDkTfr1rw0yc= @@ -951,6 +953,8 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml --- a/nix/gomod2nix.toml +++ b/nix/gomod2nix.toml @@ -548,6 +548,9 @@ [mod."github.com/labstack/gommon"] version = "v0.4.1" hash = "sha256-qfjV9jmtR8I7gC7/Hm02XDbuVLX8UkRNi3wPer8Jkm4=" + [mod."github.com/landlock-lsm/go-landlock"] + version = "v0.8.1" + hash = "sha256-7H6/LBmv/d4vDIfZcJ0PeNiNU2PInkw29aQU1yHWxsU=" [mod."github.com/lucasb-eyer/go-colorful"] version = "v1.3.0" hash = "sha256-6BKrJsfmxie+YFAWzTYVPQfrwjQEXRo+J8LY+50C1BU=" @@ -846,6 +849,9 @@ [mod."gotest.tools/v3"] version = "v3.5.2" hash = "sha256-eAxnRrF2bQugeFYzGLOr+4sLyCPOpaTWpoZsIKNP1WE=" + [mod."kernel.org/pub/linux/libs/security/libcap/psx"] + version = "v1.2.77" + hash = "sha256-oqlAG5XMkQ4toFSIbqGg+2biuw+IyNrogcMralnmvfA=" [mod."lukechampine.com/blake3"] version = "v1.4.1" hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" diff --git a/knotserver/sandbox/repofs.go b/knotserver/sandbox/repofs.go new file mode 100644 --- /dev/null +++ b/knotserver/sandbox/repofs.go @@ -0,0 +1,102 @@ +package sandbox + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "syscall" +) + +// ChmodRepoTree sets directory modes to 0770 and file modes to 0660 under +// root, preserving the executable bit on files (hook scripts need it). +// Symlinks are skipped since their mode is not meaningful. +// +// The group bits exist so the knot service (running as the git user, which +// is in the git group that owns the repos) can still read and write the +// repo via group permissions even though the repo's UID owner is a virtual +// UID. Sandbox subprocesses run with NoSetGroups: true so they don't gain +// group access and cross-owner isolation still holds. +func ChmodRepoTree(root string) error { + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type()&fs.ModeSymlink != 0 { + return nil + } + if d.IsDir() { + return os.Chmod(path, 0770) + } + info, err := d.Info() + if err != nil { + return err + } + mode := fs.FileMode(0660) + if info.Mode()&0100 != 0 { + mode = 0770 + } + return os.Chmod(path, mode) + }) +} + +// ChownRepoTree recursively chowns every entry under root to uid:gid. +// Entries are processed deepest-first so a directory is only chowned after +// its contents, preserving the calling process's access throughout the walk. +// Call ChmodRepoTree first if you also want to tighten permissions; this +// function only changes ownership. +func ChownRepoTree(root string, uid int, gid int) error { + type entry struct { + path string + depth int + } + var entries []entry + if err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + depth := strings.Count(path, string(filepath.Separator)) + entries = append(entries, entry{path, depth}) + return nil + }); err != nil { + return err + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].depth > entries[j].depth + }) + + for _, e := range entries { + if err := os.Lchown(e.path, uid, gid); err != nil { + return err + } + } + return nil +} + +// LookupUIDForRepoPath returns the owner UID and GID of the repo directory at +// repoPath. scanPath is validated as a prefix to guard against directory escape. +func LookupUIDForRepoPath(scanPath, repoPath string) (uid uint32, gid uint32, err error) { + if !strings.HasPrefix(repoPath, scanPath) { + return 0, 0, fmt.Errorf("repo path %q is outside scan path %q", repoPath, scanPath) + } + var stat syscall.Stat_t + if err := syscall.Stat(repoPath, &stat); err != nil { + return 0, 0, err + } + return stat.Uid, stat.Gid, nil +} + +// ServiceGid returns the GID of scanPath, which is treated as the "service +// group" that owns all repositories. Callers chown repo trees to +// (virtualUID, ServiceGid(scanPath)) so the knot service (a member of this +// group) retains read+write access via the group bits set by ChmodRepoTree. +func ServiceGid(scanPath string) (uint32, error) { + var stat syscall.Stat_t + if err := syscall.Stat(scanPath, &stat); err != nil { + return 0, fmt.Errorf("stat %s: %w", scanPath, err) + } + return stat.Gid, nil +} diff --git a/knotserver/sandbox/sandbox.go b/knotserver/sandbox/sandbox.go new file mode 100644 --- /dev/null +++ b/knotserver/sandbox/sandbox.go @@ -0,0 +1,45 @@ +package sandbox + +import "os/exec" + +// Backend wraps git subprocesses in a filesystem sandbox. +type Backend interface { + Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error) + WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error) + Name() string +} + +// NoopBackend passes commands through unchanged. +type NoopBackend struct{} + +func (n *NoopBackend) Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error) { + cmd.Dir = repoPath + return cmd, nil +} + +func (n *NoopBackend) WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error) { + if len(paths) > 0 { + cmd.Dir = paths[0] + } + return cmd, nil +} + +func (n *NoopBackend) Name() string { return "noop" } + +// LookupUID resolves a repo path to its owner virtual UID. Used by the sandbox +// to drop privileges before running git. Returning 0 (or any error) means +// don't drop, i.e. the subprocess runs as the calling user. +type LookupUID func(repoPath string) (uid uint32, gid uint32, err error) + +// New returns the best available sandboxing backend. If landlock is not +// available, the warning string is non-empty and the backend falls back +// to NoopBackend. lookup is optional; nil means subprocesses keep the +// caller's UID/GID. +func New(lookup LookupUID) (Backend, string) { + return platformNew(lookup) +} + +// Probe returns a human-readable description of sandbox capability on this host. +func Probe() string { + return platformProbe() +} diff --git a/knotserver/sandbox/sandbox_linux.go b/knotserver/sandbox/sandbox_linux.go new file mode 100644 --- /dev/null +++ b/knotserver/sandbox/sandbox_linux.go @@ -0,0 +1,183 @@ +//go:build linux + +package sandbox + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" + "unsafe" + + "github.com/landlock-lsm/go-landlock/landlock" + "golang.org/x/sys/unix" +) + +var ErrUnsupportedPlatform = errors.New("no sandbox backend available") + +// LandlockBackend uses the Linux Landlock LSM via a re-exec pattern. +// landlock_restrict_self only affects the calling OS thread, so we re-exec +// the binary as "sandbox-exec" which runs single-threaded before exec'ing git. +type LandlockBackend struct { + selfExe string + lookup LookupUID +} + +func (l *LandlockBackend) Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error) { + return l.WrapMulti([]string{repoPath}, cmd) +} + +func (l *LandlockBackend) WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error) { + if len(paths) == 0 { + return cmd, nil + } + + // resolve the executable to an absolute path now, while $PATH is still + // intact; the re-exec'd sandbox-exec subprocess inherits the env we pass + // via cmd.Env, which may not include the wrappers that set up $PATH. + args := cmd.Args + if len(args) > 0 { + if abs, err := exec.LookPath(args[0]); err == nil { + args = append([]string{abs}, args[1:]...) + } + } + + var sandboxArgs []string + sandboxArgs = append(sandboxArgs, "sandbox-exec") + for _, p := range paths { + sandboxArgs = append(sandboxArgs, "--repo-path="+p) + } + sandboxArgs = append(sandboxArgs, "--") + sandboxArgs = append(sandboxArgs, args...) + + wrapped := exec.Command(l.selfExe, sandboxArgs...) + wrapped.Env = cmd.Env + wrapped.Dir = paths[0] // kernel chdir's here after setuid, before execve + wrapped.Stdin = cmd.Stdin + wrapped.Stdout = cmd.Stdout + wrapped.Stderr = cmd.Stderr + + // drop to the virtual UID if we can resolve one. the kernel handles + // fork -> setresuid -> chdir -> execve; requires CAP_SETUID/GID on the caller. + // + // the primary GID is intentionally set to the virtual UID, NOT the + // repo's group ownership. repo dirs are owned by virtualUID:gitGroup + // with mode 0770 so the knot service (in gitGroup) can read them, but + // sandbox subprocesses must not inherit gitGroup or they would gain + // group access to every other repo and lose cross-owner isolation. + if l.lookup != nil { + if uid, _, err := l.lookup(paths[0]); err == nil && uid > 0 { + wrapped.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{Uid: uid, Gid: uid, NoSetGroups: true}, + } + } + } + + return wrapped, nil +} + +func (l *LandlockBackend) Name() string { return "landlock" } + +// ApplyLandlock applies a Landlock ruleset to the current process then +// exec's into gitArgs. Called from the hidden "sandbox-exec" subcommand. +func ApplyLandlock(repoPaths []string, gitArgs []string) error { + if len(gitArgs) == 0 { + return fmt.Errorf("sandbox-exec: no command specified") + } + + // collect unique parent directories so git can read global config + // under $HOME/.config/git/config. repo contents stay DAC-locked + // (0700) so other repos can't actually be read. + parents := map[string]struct{}{} + for _, p := range repoPaths { + parents[filepath.Dir(p)] = struct{}{} + } + parentSlice := make([]string, 0, len(parents)) + for p := range parents { + parentSlice = append(parentSlice, p) + } + + // each repo gets full read/write plus REFER (needed for git's quarantine + // rename in receive-pack, which moves objects across directories). + repoRules := make([]landlock.Rule, len(repoPaths)) + for i, p := range repoPaths { + repoRules[i] = landlock.RWDirs(p).WithRefer() + } + + rules := append([]landlock.Rule{ + // system dirs: read + execute only, no writes + landlock.RODirs("/usr", "/bin", "/lib", "/lib64", "/nix", "/etc").IgnoreIfMissing(), + // /dev/null and friends: read/write files + ioctl (V5+ restricts ioctl + // on device files; WithIoctlDev keeps /dev/null fully accessible) + landlock.RWFiles("/dev").WithIoctlDev().IgnoreIfMissing(), + // parent dirs: read + execute so git can traverse to the repo and read + // global git config; 0700 DAC permissions prevent cross-repo reads + landlock.RODirs(parentSlice...).IgnoreIfMissing(), + // /tmp: read/write for temporary patch and object files + landlock.RWDirs("/tmp").IgnoreIfMissing(), + }, repoRules...) + + // V8.BestEffort enforces the strongest ruleset the running kernel supports, + // up to V8. RestrictPaths also sets PR_SET_NO_NEW_PRIVS automatically. + if err := landlock.V8.BestEffort().RestrictPaths(rules...); err != nil { + return fmt.Errorf("sandbox-exec: restrict paths: %w", err) + } + + gitBin := gitArgs[0] + if !filepath.IsAbs(gitBin) { + return fmt.Errorf("sandbox-exec: expected absolute path, got %q", gitBin) + } + + return unix.Exec(gitBin, gitArgs, os.Environ()) +} + +func probeLandlock() bool { + _, err := landlockCreateRuleset(nil, unix.LANDLOCK_CREATE_RULESET_VERSION) + // EOPNOTSUPP and ENOSYS mean the kernel doesn't support landlock. + // Any other result (including EINVAL for the nil attr) means it's available. + return !errors.Is(err, unix.EOPNOTSUPP) && !errors.Is(err, unix.ENOSYS) +} + +func platformNew(lookup LookupUID) (Backend, string) { + if probeLandlock() { + selfExe, err := os.Readlink("/proc/self/exe") + if err != nil { + selfExe = "/proc/self/exe" + } + return &LandlockBackend{selfExe: selfExe, lookup: lookup}, "" + } + + return &NoopBackend{}, "landlock unavailable (kernel < 5.13); git subprocesses run unsandboxed" +} + +func platformProbe() string { + if probeLandlock() { + return "landlock available (kernel >= 5.13)" + } + return "no sandbox backend available (kernel < 5.13)" +} + +// landlockCreateRuleset wraps the landlock_create_ruleset(2) syscall. +// Pass attr=nil and flags=LANDLOCK_CREATE_RULESET_VERSION to query ABI version. +// Used only for the non-destructive probe in probeLandlock; all ruleset +// construction is handled by go-landlock. +func landlockCreateRuleset(attr *unix.LandlockRulesetAttr, flags uint) (int, error) { + var attrPtr unsafe.Pointer + var attrSize uintptr + if attr != nil { + attrPtr = unsafe.Pointer(attr) + attrSize = unsafe.Sizeof(*attr) + } + fd, _, errno := unix.Syscall( + unix.SYS_LANDLOCK_CREATE_RULESET, + uintptr(attrPtr), + attrSize, + uintptr(flags), + ) + if errno != 0 { + return 0, errno + } + return int(fd), nil +} diff --git a/knotserver/sandbox/sandbox_other.go b/knotserver/sandbox/sandbox_other.go new file mode 100644 --- /dev/null +++ b/knotserver/sandbox/sandbox_other.go @@ -0,0 +1,15 @@ +//go:build !linux + +package sandbox + +import "fmt" + +var ErrUnsupportedPlatform = fmt.Errorf("sandboxing is only supported on Linux") + +func platformNew(_ LookupUID) (Backend, string) { + return &NoopBackend{}, "sandboxing is not supported on this platform (Linux only)" +} + +func platformProbe() string { + return "sandboxing not supported (Linux only)" +} diff --git a/knotserver/sandbox/sandboxexec/exec_linux.go b/knotserver/sandbox/sandboxexec/exec_linux.go new file mode 100644 --- /dev/null +++ b/knotserver/sandbox/sandboxexec/exec_linux.go @@ -0,0 +1,11 @@ +//go:build linux + +package sandboxexec + +import ( + "tangled.org/core/knotserver/sandbox" +) + +func applyAndExec(repoPaths, gitArgs []string) error { + return sandbox.ApplyLandlock(repoPaths, gitArgs) +} diff --git a/knotserver/sandbox/sandboxexec/exec_other.go b/knotserver/sandbox/sandboxexec/exec_other.go new file mode 100644 --- /dev/null +++ b/knotserver/sandbox/sandboxexec/exec_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package sandboxexec + +import "fmt" + +func applyAndExec(repoPaths, gitArgs []string) error { + return fmt.Errorf("sandbox-exec is only supported on Linux") +} diff --git a/knotserver/sandbox/sandboxexec/sandboxexec.go b/knotserver/sandbox/sandboxexec/sandboxexec.go new file mode 100644 --- /dev/null +++ b/knotserver/sandbox/sandboxexec/sandboxexec.go @@ -0,0 +1,43 @@ +package sandboxexec + +import ( + "context" + "fmt" + "os" + + "github.com/urfave/cli/v3" +) + +// Command returns the hidden sandbox-exec subcommand used by LandlockBackend. +// +// landlock_restrict_self only restricts the calling OS thread, so it cannot be +// called from a goroutine (the Go scheduler may migrate the goroutine across +// threads). The workaround is to re-exec the knot binary with this subcommand, +// which runs single-threaded before the Go runtime starts its thread pool, +// applies the ruleset, then exec's into the target git process. +func Command() *cli.Command { + return &cli.Command{ + Name: "sandbox-exec", + Hidden: true, + Usage: "apply landlock sandbox and exec into git (internal use only)", + Action: Run, + Flags: []cli.Flag{ + &cli.StringSliceFlag{ + Name: "repo-path", + Usage: "repository path(s) to allow read/write access to", + }, + }, + } +} + +func Run(ctx context.Context, cmd *cli.Command) error { + repoPaths := cmd.StringSlice("repo-path") + gitArgs := cmd.Args().Slice() + + if len(gitArgs) == 0 { + fmt.Fprintln(os.Stderr, "sandbox-exec: no command specified after --") + os.Exit(1) + } + + return applyAndExec(repoPaths, gitArgs) +}