···1010 "syscall"
1111)
12121313-// ChmodRepoTree sets directory modes to 0770 and file modes to 0660 under
1414-// root, preserving the executable bit on files (hook scripts need it).
1515-// Symlinks are skipped since their mode is not meaningful.
1313+// ChmodRepoTree sets directory modes to 2770 (with the setgid bit) and
1414+// file modes to 0660 under root, preserving the executable bit on files
1515+// (hook scripts need it). Symlinks are skipped since their mode is not
1616+// meaningful.
1617//
1718// The group bits exist so the knot service (running as the git user, which
1819// is in the git group that owns the repos) can still read and write the
1920// repo via group permissions even though the repo's UID owner is a virtual
2020-// UID. Sandbox subprocesses run with NoSetGroups: true so they don't gain
2121-// group access and cross-owner isolation still holds.
2121+// UID. Sandbox subprocesses drop supplementary groups so cross-owner
2222+// isolation still holds.
2323+//
2424+// The setgid bit on directories makes new files and subdirectories created
2525+// by sandbox subprocesses inherit the directory's group (the git group)
2626+// rather than the subprocess's primary group (the virtual UID). Without
2727+// it, sandbox-created files would be unreadable to the knot service.
2228func ChmodRepoTree(root string) error {
2329 return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
2430 if err != nil {
···2834 return nil
2935 }
3036 if d.IsDir() {
3131- return os.Chmod(path, 0770)
3737+ return os.Chmod(path, 0770|os.ModeSetgid)
3238 }
3339 info, err := d.Info()
3440 if err != nil {
+39-8
knotserver/sandbox/repofs_test.go
···29293030 cases := []struct {
3131 path string
3232- wantMode os.FileMode
3232+ wantPerm os.FileMode
3333+ wantDir bool
3334 }{
3434- {root, 0770},
3535- {filepath.Join(root, "file.txt"), 0660},
3636- {filepath.Join(root, "script.sh"), 0770},
3737- {filepath.Join(root, "subdir"), 0770},
3838- {filepath.Join(root, "subdir", "nested.txt"), 0660},
3535+ {root, 0770, true},
3636+ {filepath.Join(root, "file.txt"), 0660, false},
3737+ {filepath.Join(root, "script.sh"), 0770, false},
3838+ {filepath.Join(root, "subdir"), 0770, true},
3939+ {filepath.Join(root, "subdir", "nested.txt"), 0660, false},
3940 }
4041 for _, c := range cases {
4142 info, err := os.Stat(c.path)
···4344 t.Errorf("stat %s: %v", c.path, err)
4445 continue
4546 }
4646- if got := info.Mode().Perm(); got != c.wantMode {
4747- t.Errorf("%s: mode = %o, want %o", c.path, got, c.wantMode)
4747+ if got := info.Mode().Perm(); got != c.wantPerm {
4848+ t.Errorf("%s: perm = %o, want %o", c.path, got, c.wantPerm)
4949+ }
5050+ setgid := info.Mode()&os.ModeSetgid != 0
5151+ if c.wantDir && !setgid {
5252+ t.Errorf("%s: setgid bit not set on directory", c.path)
5353+ }
5454+ if !c.wantDir && setgid {
5555+ t.Errorf("%s: setgid bit set on non-directory", c.path)
5656+ }
5757+ }
5858+}
5959+6060+func TestChmodRepoTree_SetsSetgidOnExistingDirs(t *testing.T) {
6161+ // Directories that already exist without the setgid bit should have it
6262+ // applied by the chmod walk; otherwise, files later created inside them
6363+ // by sandbox subprocesses would not inherit the directory's group.
6464+ root := t.TempDir()
6565+ mustMkdir(t, filepath.Join(root, "objects"), 0755)
6666+ mustMkdir(t, filepath.Join(root, "refs", "heads"), 0755)
6767+6868+ if err := ChmodRepoTree(root); err != nil {
6969+ t.Fatalf("ChmodRepoTree: %v", err)
7070+ }
7171+7272+ for _, p := range []string{root, filepath.Join(root, "objects"), filepath.Join(root, "refs"), filepath.Join(root, "refs", "heads")} {
7373+ info, err := os.Stat(p)
7474+ if err != nil {
7575+ t.Fatalf("stat %s: %v", p, err)
7676+ }
7777+ if info.Mode()&os.ModeSetgid == 0 {
7878+ t.Errorf("%s: setgid bit not set", p)
4879 }
4980 }
5081}
+12-2
knotserver/sandbox/sandbox_linux.go
···6060 wrapped.Stderr = cmd.Stderr
61616262 // drop to the virtual UID if we can resolve one. the kernel handles
6363- // fork -> setresuid -> chdir -> execve; requires CAP_SETUID/GID on the caller.
6363+ // fork -> setgroups -> setresgid -> setresuid -> chdir -> execve;
6464+ // requires CAP_SETUID/CAP_SETGID on the caller.
6465 //
6566 // the primary GID is intentionally set to the virtual UID, NOT the
6667 // repo's group ownership. repo dirs are owned by virtualUID:gitGroup
6768 // with mode 0770 so the knot service (in gitGroup) can read them, but
6869 // sandbox subprocesses must not inherit gitGroup or they would gain
6970 // group access to every other repo and lose cross-owner isolation.
7171+ //
7272+ // Groups is an empty (non-nil) slice and NoSetGroups is false so the
7373+ // kernel calls setgroups(0, NULL) and clears supplementary groups.
7474+ // NoSetGroups: true would skip setgroups entirely and the subprocess
7575+ // would inherit the parent's supplementary groups (including gitGroup).
7076 if l.lookup != nil {
7177 if uid, _, err := l.lookup(paths[0]); err == nil && uid > 0 {
7278 wrapped.SysProcAttr = &syscall.SysProcAttr{
7373- Credential: &syscall.Credential{Uid: uid, Gid: uid, NoSetGroups: true},
7979+ Credential: &syscall.Credential{
8080+ Uid: uid,
8181+ Gid: uid,
8282+ Groups: []uint32{},
8383+ },
7484 }
7585 }
7686 }
+8-2
knotserver/sandbox/sandbox_linux_test.go
···202202 if cred.Gid != 100042 {
203203 t.Errorf("Credential.Gid = %d, want 100042 (must equal Uid, not lookup's gid 1234)", cred.Gid)
204204 }
205205- if !cred.NoSetGroups {
206206- t.Error("NoSetGroups should be true")
205205+ // NoSetGroups must be false (the default) so the kernel calls
206206+ // setgroups(0, NULL) and clears supplementary groups. NoSetGroups: true
207207+ // would let the subprocess inherit the parent's groups (gitGroup).
208208+ if cred.NoSetGroups {
209209+ t.Error("NoSetGroups must be false so supplementary groups get cleared")
210210+ }
211211+ if len(cred.Groups) != 0 {
212212+ t.Errorf("Groups = %v, want empty (no supplementary groups granted)", cred.Groups)
207213 }
208214}
209215