Monorepo for Tangled
tangled.org
1//go:build linux
2
3package sandbox
4
5import (
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "syscall"
12 "unsafe"
13
14 "github.com/landlock-lsm/go-landlock/landlock"
15 "golang.org/x/sys/unix"
16)
17
18var ErrUnsupportedPlatform = errors.New("no sandbox backend available")
19
20// LandlockBackend uses the Linux Landlock LSM via a re-exec pattern.
21// landlock_restrict_self only affects the calling OS thread, so we re-exec
22// the binary as "sandbox-exec" which runs single-threaded before exec'ing git.
23type LandlockBackend struct {
24 selfExe string
25 lookup LookupUID
26}
27
28func (l *LandlockBackend) Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error) {
29 return l.WrapMulti([]string{repoPath}, cmd)
30}
31
32func (l *LandlockBackend) WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error) {
33 if len(paths) == 0 {
34 return cmd, nil
35 }
36
37 // resolve the executable to an absolute path now, while $PATH is still
38 // intact; the re-exec'd sandbox-exec subprocess inherits the env we pass
39 // via cmd.Env, which may not include the wrappers that set up $PATH.
40 args := cmd.Args
41 if len(args) > 0 {
42 if abs, err := exec.LookPath(args[0]); err == nil {
43 args = append([]string{abs}, args[1:]...)
44 }
45 }
46
47 var sandboxArgs []string
48 sandboxArgs = append(sandboxArgs, "sandbox-exec")
49 for _, p := range paths {
50 sandboxArgs = append(sandboxArgs, "--repo-path="+p)
51 }
52 sandboxArgs = append(sandboxArgs, "--")
53 sandboxArgs = append(sandboxArgs, args...)
54
55 wrapped := exec.Command(l.selfExe, sandboxArgs...)
56 wrapped.Env = cmd.Env
57 wrapped.Dir = paths[0] // kernel chdir's here after setuid, before execve
58 wrapped.Stdin = cmd.Stdin
59 wrapped.Stdout = cmd.Stdout
60 wrapped.Stderr = cmd.Stderr
61
62 // drop to the virtual UID if we can resolve one. the kernel handles
63 // fork -> setresuid -> chdir -> execve; requires CAP_SETUID/GID on the caller.
64 //
65 // the primary GID is intentionally set to the virtual UID, NOT the
66 // repo's group ownership. repo dirs are owned by virtualUID:gitGroup
67 // with mode 0770 so the knot service (in gitGroup) can read them, but
68 // sandbox subprocesses must not inherit gitGroup or they would gain
69 // group access to every other repo and lose cross-owner isolation.
70 if l.lookup != nil {
71 if uid, _, err := l.lookup(paths[0]); err == nil && uid > 0 {
72 wrapped.SysProcAttr = &syscall.SysProcAttr{
73 Credential: &syscall.Credential{Uid: uid, Gid: uid, NoSetGroups: true},
74 }
75 }
76 }
77
78 return wrapped, nil
79}
80
81func (l *LandlockBackend) Name() string { return "landlock" }
82
83// RuleSpec describes the paths a sandbox should grant access to, grouped by
84// access tier. It is the input to the Landlock ruleset construction and is
85// exposed so the path-derivation logic can be tested independently of any
86// actual kernel-level enforcement.
87type RuleSpec struct {
88 // SystemRO is the set of system directories granted read+execute.
89 SystemRO []string
90 // GitConfigRO is the global git config file, granted read-only access
91 // at file granularity. Empty when $HOME is not set.
92 GitConfigRO string
93 // DevRW is the set of device-file directories granted read/write +
94 // ioctl access (needed so /dev/null works under Landlock V5+).
95 DevRW []string
96 // TmpRW is the set of directories granted read/write for temporary
97 // patch and object files.
98 TmpRW []string
99 // RepoRW is the set of repository directories granted read/write
100 // access including the REFER right (for cross-directory rename in
101 // receive-pack's quarantine migration).
102 RepoRW []string
103}
104
105// BuildRuleSpec derives the set of paths the sandbox should grant to each
106// access tier given the repository paths the subprocess operates on.
107func BuildRuleSpec(repoPaths []string) RuleSpec {
108 return buildRuleSpec(repoPaths, os.Getenv("HOME"))
109}
110
111// buildRuleSpec is the testable variant of BuildRuleSpec that takes $HOME
112// explicitly instead of reading it from the environment.
113func buildRuleSpec(repoPaths []string, home string) RuleSpec {
114 var gitConfig string
115 if home != "" {
116 // the only thing the sandboxed git subprocess needs from $HOME is the
117 // global config file. granting just that one file (not the whole
118 // .config tree) keeps everything else under $HOME outside the ruleset.
119 gitConfig = filepath.Join(home, ".config", "git", "config")
120 }
121
122 return RuleSpec{
123 SystemRO: []string{"/usr", "/bin", "/lib", "/lib64", "/nix", "/etc"},
124 GitConfigRO: gitConfig,
125 DevRW: []string{"/dev"},
126 TmpRW: []string{"/tmp"},
127 RepoRW: append([]string(nil), repoPaths...),
128 }
129}
130
131// ApplyLandlock applies a Landlock ruleset to the current process then
132// exec's into gitArgs. Called from the hidden "sandbox-exec" subcommand.
133func ApplyLandlock(repoPaths []string, gitArgs []string) error {
134 if len(gitArgs) == 0 {
135 return fmt.Errorf("sandbox-exec: no command specified")
136 }
137
138 spec := BuildRuleSpec(repoPaths)
139
140 rules := []landlock.Rule{
141 landlock.RODirs(spec.SystemRO...).IgnoreIfMissing(),
142 landlock.RWFiles(spec.DevRW...).WithIoctlDev().IgnoreIfMissing(),
143 landlock.RWDirs(spec.TmpRW...).IgnoreIfMissing(),
144 }
145 if spec.GitConfigRO != "" {
146 rules = append(rules, landlock.ROFiles(spec.GitConfigRO).IgnoreIfMissing())
147 }
148 for _, p := range spec.RepoRW {
149 rules = append(rules, landlock.RWDirs(p).WithRefer())
150 }
151
152 // V8.BestEffort enforces the strongest ruleset the running kernel supports,
153 // up to V8. RestrictPaths also sets PR_SET_NO_NEW_PRIVS automatically.
154 if err := landlock.V8.BestEffort().RestrictPaths(rules...); err != nil {
155 return fmt.Errorf("sandbox-exec: restrict paths: %w", err)
156 }
157
158 gitBin := gitArgs[0]
159 if !filepath.IsAbs(gitBin) {
160 return fmt.Errorf("sandbox-exec: expected absolute path, got %q", gitBin)
161 }
162
163 return unix.Exec(gitBin, gitArgs, os.Environ())
164}
165
166func probeLandlock() bool {
167 _, err := landlockCreateRuleset(nil, unix.LANDLOCK_CREATE_RULESET_VERSION)
168 // EOPNOTSUPP and ENOSYS mean the kernel doesn't support landlock.
169 // Any other result (including EINVAL for the nil attr) means it's available.
170 return !errors.Is(err, unix.EOPNOTSUPP) && !errors.Is(err, unix.ENOSYS)
171}
172
173func platformNew(lookup LookupUID) (Backend, string) {
174 if probeLandlock() {
175 selfExe, err := os.Readlink("/proc/self/exe")
176 if err != nil {
177 selfExe = "/proc/self/exe"
178 }
179 return &LandlockBackend{selfExe: selfExe, lookup: lookup}, ""
180 }
181
182 return &NoopBackend{}, "landlock unavailable (kernel < 5.13); git subprocesses run unsandboxed"
183}
184
185func platformProbe() string {
186 if probeLandlock() {
187 return "landlock available (kernel >= 5.13)"
188 }
189 return "no sandbox backend available (kernel < 5.13)"
190}
191
192// landlockCreateRuleset wraps the landlock_create_ruleset(2) syscall.
193// Pass attr=nil and flags=LANDLOCK_CREATE_RULESET_VERSION to query ABI version.
194// Used only for the non-destructive probe in probeLandlock; all ruleset
195// construction is handled by go-landlock.
196func landlockCreateRuleset(attr *unix.LandlockRulesetAttr, flags uint) (int, error) {
197 var attrPtr unsafe.Pointer
198 var attrSize uintptr
199 if attr != nil {
200 attrPtr = unsafe.Pointer(attr)
201 attrSize = unsafe.Sizeof(*attr)
202 }
203 fd, _, errno := unix.Syscall(
204 unix.SYS_LANDLOCK_CREATE_RULESET,
205 uintptr(attrPtr),
206 attrSize,
207 uintptr(flags),
208 )
209 if errno != 0 {
210 return 0, errno
211 }
212 return int(fd), nil
213}