Commit 135af80d08

135af80d08d43a694835e0874c29c7936e5fbc17

parent: c40a116572

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 09:02 UTC

hookd: 0600 socket, peer uid check, push token required

Ref #282

Layout: unified · split

internal/hookd/hookd.go +36
@@ -36,6 +36,10 @@ const (
3636 EnvRepoID = "GITBAY_REPO_ID"
3737 EnvUserID = "GITBAY_USER_ID"
3838 EnvScope = "GITBAY_KEY_SCOPE"
39 // EnvToken names the receive-pack this hook runs under. sshd mints
40 // it per push; hookd answers only a request carrying a live one
41 // whose repository, account and scope match the request's.
42 EnvToken = "GITBAY_PUSH_TOKEN"
3943)
4044
4145type Request struct {
@@ -46,6 +50,7 @@ type Request struct {
4650 // the key belongs to, and a deploy key grants nothing outside its
4751 // binding, so anything acting on another repository needs this too.
4852 Scope string `json:"scope"`
53 Token string `json:"token"`
4954 Updates []policy.RefUpdate `json:"updates"`
5055}
5156
@@ -94,6 +99,12 @@ func Serve(cfg config.Config, st *store.Store) (func() error, error) {
9499 if err != nil {
95100 return nil, err
96101 }
102 // Listen creates the socket under the process umask. Hooks run as
103 // the daemon's own user; nobody else has a reason to connect.
104 if err := os.Chmod(path, 0o600); err != nil {
105 ln.Close()
106 return nil, err
107 }
97108 s := &Server{cfg: cfg, st: st}
98109 go func() {
99110 for {
@@ -111,11 +122,20 @@ func (s *Server) handle(conn net.Conn) {
111122 defer conn.Close()
112123 dec := json.NewDecoder(conn)
113124 enc := json.NewEncoder(conn)
125 if err := checkPeer(conn); err != nil {
126 slog.Warn("hook socket: refused connection", "err", err)
127 enc.Encode(Response{Allow: false, Message: "hook socket: " + err.Error()})
128 return
129 }
114130 var req Request
115131 if err := dec.Decode(&req); err != nil {
116132 enc.Encode(Response{Allow: false, Message: "bad hook request"})
117133 return
118134 }
135 if msg := s.authorize(req); msg != "" {
136 enc.Encode(Response{Allow: false, Message: msg})
137 return
138 }
119139 switch req.Hook {
120140 case "pre-receive":
121141 s.preReceive(req, dec, enc)
@@ -127,6 +147,22 @@ func (s *Server) handle(conn net.Conn) {
127147 }
128148}
129149
150// authorize ties a request to a receive-pack sshd started: its token
151// must be live and name the same repository, account and key scope.
152func (s *Server) authorize(req Request) string {
153 if req.Token == "" {
154 return "push not started by this server"
155 }
156 tok, err := s.st.PushTokenByHash(store.HashToken(req.Token))
157 if err != nil {
158 return "push not started by this server"
159 }
160 if tok.RepoID != req.RepoID || tok.UserID != req.UserID || tok.Scope != req.Scope {
161 return "push token does not match this request"
162 }
163 return ""
164}
165
130166func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
131167 repo, err := s.st.RepoByID(req.RepoID)
132168 if err != nil {
internal/hookd/peercred_linux.go added +38
@@ -0,0 +1,38 @@
1//go:build linux
2
3package hookd
4
5import (
6 "errors"
7 "fmt"
8 "net"
9 "os"
10 "syscall"
11)
12
13// checkPeer refuses a connection from any uid but the daemon's: git,
14// and so every hook, runs as the daemon's user.
15func checkPeer(conn net.Conn) error {
16 uc, ok := conn.(*net.UnixConn)
17 if !ok {
18 return fmt.Errorf("not a unix socket connection")
19 }
20 raw, err := uc.SyscallConn()
21 if err != nil {
22 return err
23 }
24 var cred *syscall.Ucred
25 var credErr error
26 if err := raw.Control(func(fd uintptr) {
27 cred, credErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
28 }); err != nil {
29 return err
30 }
31 if credErr != nil {
32 return credErr
33 }
34 if int(cred.Uid) != os.Getuid() {
35 return errors.New("peer uid not permitted")
36 }
37 return nil
38}
internal/hookd/peercred_other.go added +9
@@ -0,0 +1,9 @@
1//go:build !linux
2
3package hookd
4
5import "net"
6
7// checkPeer reads peer credentials on Linux only; elsewhere the
8// socket's 0600 mode is the boundary.
9func checkPeer(net.Conn) error { return nil }
internal/hookd/socket_test.go added +105
@@ -0,0 +1,105 @@
1package hookd
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func serveSocket(t *testing.T) (sock string, st *store.Store, repoID, uid int64) {
14 t.Helper()
15 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
16 if err != nil {
17 t.Fatal(err)
18 }
19 t.Cleanup(func() { st.Close() })
20 if err := st.MigrateUp(); err != nil {
21 t.Fatal(err)
22 }
23 if uid, err = st.CreateUser("alice", false); err != nil {
24 t.Fatal(err)
25 }
26 if repoID, err = st.CreateRepo("user", uid, "app", "public"); err != nil {
27 t.Fatal(err)
28 }
29 var cfg config.Config
30 cfg.Server.Root = t.TempDir()
31 stop, err := Serve(cfg, st)
32 if err != nil {
33 t.Fatal(err)
34 }
35 t.Cleanup(func() { stop() })
36 return SocketPath(cfg.Server.Root), st, repoID, uid
37}
38
39func TestSocketIsOwnerOnly(t *testing.T) {
40 sock, _, _, _ := serveSocket(t)
41 fi, err := os.Stat(sock)
42 if err != nil {
43 t.Fatal(err)
44 }
45 if fi.Mode().Perm() != 0o600 {
46 t.Fatalf("mode %v, want 0600", fi.Mode().Perm())
47 }
48}
49
50// A request speaks for a receive-pack sshd started, and only for the
51// repository, account and scope that push was started with (#282).
52func TestHookRequestNeedsItsPushToken(t *testing.T) {
53 sock, st, repoID, uid := serveSocket(t)
54 req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full"}
55
56 resp, err := Ask(sock, req, nil)
57 if err != nil {
58 t.Fatal(err)
59 }
60 if resp.Allow || !strings.Contains(resp.Message, "not started by this server") {
61 t.Fatalf("no token: %+v", resp)
62 }
63
64 token, err := st.CreatePushToken(repoID, uid, "full")
65 if err != nil {
66 t.Fatal(err)
67 }
68 req.Token = token
69 if resp, err = Ask(sock, req, nil); err != nil || !resp.Allow {
70 t.Fatalf("with token: %+v, %v", resp, err)
71 }
72
73 other, err := st.CreateUser("mallory", false)
74 if err != nil {
75 t.Fatal(err)
76 }
77 forged := req
78 forged.UserID = other
79 if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow {
80 t.Fatalf("token for another account: %+v, %v", resp, err)
81 }
82
83 otherRepo, err := st.CreateRepo("user", uid, "lib", "public")
84 if err != nil {
85 t.Fatal(err)
86 }
87 forged = req
88 forged.RepoID = otherRepo
89 if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow {
90 t.Fatalf("token for another repository: %+v, %v", resp, err)
91 }
92
93 forged = req
94 forged.Scope = "read"
95 if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow {
96 t.Fatalf("token for another scope: %+v, %v", resp, err)
97 }
98
99 if err := st.DeletePushToken(token); err != nil {
100 t.Fatal(err)
101 }
102 if resp, err = Ask(sock, req, nil); err != nil || resp.Allow {
103 t.Fatalf("finished push: %+v, %v", resp, err)
104 }
105}