hookd: authenticate the hook socket !494

merged merged by cmc on 2026-09-28 21:50 UTC · krz/gitbay:hook-socket-auth into main

15 files changed, +321 −4

Layout: unified · split

.gitbay/wiki/Architecture/03-Deployment.org +1 −1
@@ -16,7 +16,7 @@ statements in this document about the host rest on those files.
1616| 80/tcp | HTTP | gitbayd | on with ACME| none; ACME HTTP-01 and redirect only | =cmd/gitbayd/main.go= |
1717| 9418/tcp | git:// | gitbayd | off | none; public repositories only | =internal/gitd= |
1818| 2222/tcp | SSH (operator) | host sshd | on | public key, no passwords, fail2ban | =deploy/cloud-init.yaml= |
19| =<root>/hook.sock= | Unix socket | gitbayd | on | filesystem permissions only | =internal/hookd/hookd.go= |
19| =<root>/hook.sock= | Unix socket | gitbayd | on | mode 0600; peer uid must be the daemon's (Linux); per-push token | =internal/hookd/hookd.go= |
2020
2121With =ssh.mode = system= the host's sshd serves port 22 instead and
2222invokes =gitbayd authorized-keys= and =gitbayd shell=
.gitbay/wiki/Architecture/04-Trust-Boundaries.org +2 −2
@@ -22,7 +22,7 @@
2222| TB2 | Z0 → Z1 HTTPS | page requests, form posts, API calls, fetches, LFS | TLS; session cookie or bearer token; =checkOrigin= on posts; CSP and security headers (=internal/httpd/routes.go=); smart HTTP is fetch-only (=smart.go=) |
2323| TB3 | identity → data | every command | =Dispatch= gates, then =resolveRepo= with =policy= predicates; unreadable repositories are indistinguishable from missing ones ([[file:05-Identity-and-Access.org][5]]) |
2424| TB4 | Z1 → Z3 git | argv, repository path, stdin packs | argv built by code, never a shell; repository path from the database, not the request (=internal/gitutil=) |
25| TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, commit objects | the daemon decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=). The socket trusts the ids in the request, so access to the socket is equivalent to acting as any user; it is reachable only through the =gitbay= user's filesystem |
25| TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, push token, commit objects | the socket is mode 0600 and, on Linux, refuses a peer whose uid is not the daemon's; a request must carry the token sshd minted for its receive-pack (stored hashed in =push_tokens=) and name the same repository, account and scope. The daemon then decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=) |
2626| TB6 | Z4 ↔ Z1 runner channel | build claims (with secrets for trusted builds), logs, results | runner-scoped SSH key; claims limited to attached repositories; secrets only when the build is trusted (=internal/control/build.go=) |
2727| TB7 | Z5 → Z4 container | build steps, workspace, build home | rootless podman, operator-provisioned image, cgroup limits; the build home is shared per repository and the network is open (#255, #260) |
2828| TB8 | Z1 → Z0 outbound | webhooks, mirrors, mail, push | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-Deployment.org][3]]) |
@@ -55,7 +55,7 @@ above.
5555 checks, archive and pull-mirror refusals and the owner's storage
5656 quota (=sshd.go=). TB3.
57572. =git receive-pack= runs with the hook socket path, repository id,
58 user id and key scope in its environment (=sshd.go=). TB4.
58 user id and key scope in its environment, and a push token (=sshd.go=). TB4.
59593. git runs =pre-receive=, which is =gitbayd hook pre-receive=. It
6060 reads the ref updates, computes ancestry in git's quarantine
6161 environment and asks the daemon over the socket
.gitbay/wiki/Architecture/10-Known-Gaps.org −1
@@ -20,7 +20,6 @@ what the 2026-09-27 review found; remove a row when its issue closes.
2020| #274 | Backups | The local backup archive is not encrypted | medium |
2121| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium |
2222| #298 | SSRF | =repo import --from= fetches without an address check | medium |
23| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium |
2423| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
2524
2625* Questions an auditor will ask that have no answer yet
CHANGELOG.org +6
@@ -48,6 +48,12 @@ must add =--scope full=. Existing tokens keep their scope.
4848 syncs, and each records why (#279).
4949- Webhook and mirror targets in 100.64.0.0/10 or on a multicast
5050 address are refused, as private addresses are (#279).
51- The hook socket is mode 0600 and, on Linux, refuses a peer with
52 another uid; each receive-pack gets its own token from sshd, stored
53 hashed (migration 0063), and the hook must present it before the
54 daemon acts. *Operators:* deploy with no push in flight, since a
55 receive-pack started by the old daemon has no token and its
56 post-receive will be refused by the new one (#282).
5157
5258* v1.36.0 — 2026-09-23
5359
cmd/gitbayd/hook.go +1
@@ -172,6 +172,7 @@ func hookCmd() *cobra.Command {
172172 RepoID: repoID,
173173 UserID: userID,
174174 Scope: os.Getenv(hookd.EnvScope),
175 Token: os.Getenv(hookd.EnvToken),
175176 Updates: updates,
176177 }, func(emit func(hookd.RawCommit) error) error {
177178 return streamIncomingCommits(updates, emit)
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}
internal/sshd/sshd.go +10
@@ -575,6 +575,16 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
575575 }
576576 }
577577 }
578 if write {
579 // hookd answers only a hook that names this receive-pack.
580 token, err := st.CreatePushToken(repo.ID, user.ID, scope)
581 if err != nil {
582 fmt.Fprintln(stderr, "internal error")
583 return protocol.ExitFailure
584 }
585 defer st.DeletePushToken(token)
586 env = append(env, hookd.EnvToken+"="+token)
587 }
578588 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil {
579589 return protocol.ExitFailure
580590 }
internal/store/migrations/0063_push_tokens.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE push_tokens;
internal/store/migrations/0063_push_tokens.up.sql added +10
@@ -0,0 +1,10 @@
1-- One row per receive-pack in flight. The hook names its push by the
2-- token; hookd answers only a live one. Only the SHA-256 is stored.
3CREATE TABLE push_tokens (
4 token_hash TEXT PRIMARY KEY,
5 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
6 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
7 scope TEXT NOT NULL,
8 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
9 expires_at TEXT NOT NULL
10);
internal/store/pushtokens.go added +52
@@ -0,0 +1,52 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "time"
7)
8
9// PushToken is the receive-pack a hook request speaks for.
10type PushToken struct {
11 RepoID int64
12 UserID int64
13 Scope string
14}
15
16// pushTokenTTL bounds a row whose receive-pack died before deleting it.
17const pushTokenTTL = 24 * time.Hour
18
19// CreatePushToken records a token for one receive-pack and returns it.
20func (s *Store) CreatePushToken(repoID, userID int64, scope string) (string, error) {
21 token, hash, err := NewToken()
22 if err != nil {
23 return "", err
24 }
25 _, err = s.DB.Exec(
26 "INSERT INTO push_tokens (token_hash, repo_id, user_id, scope, expires_at) VALUES (?, ?, ?, ?, ?)",
27 hash, repoID, userID, scope, fmtTime(time.Now().Add(pushTokenTTL)))
28 if err != nil {
29 return "", err
30 }
31 return token, nil
32}
33
34// PushTokenByHash looks up a live token by its stored hash. ErrNotFound
35// covers both an absent row and one that has expired.
36func (s *Store) PushTokenByHash(hash string) (PushToken, error) {
37 var t PushToken
38 err := s.DB.QueryRow(
39 "SELECT repo_id, user_id, scope FROM push_tokens WHERE token_hash = ? AND expires_at > ?",
40 hash, fmtTime(time.Now())).Scan(&t.RepoID, &t.UserID, &t.Scope)
41 if errors.Is(err, sql.ErrNoRows) {
42 return PushToken{}, ErrNotFound
43 }
44 return t, err
45}
46
47// DeletePushToken removes a token by its raw value, once its receive-pack
48// is done with it.
49func (s *Store) DeletePushToken(token string) error {
50 _, err := s.DB.Exec("DELETE FROM push_tokens WHERE token_hash = ?", HashToken(token))
51 return err
52}
internal/store/pushtokens_test.go added +49
@@ -0,0 +1,49 @@
1package store
2
3import (
4 "errors"
5 "testing"
6 "time"
7)
8
9func TestPushTokens(t *testing.T) {
10 s := open(t)
11 if err := s.MigrateUp(); err != nil {
12 t.Fatal(err)
13 }
14 uid, err := s.CreateUser("alice", false)
15 if err != nil {
16 t.Fatal(err)
17 }
18 repoID, err := s.CreateRepo("user", uid, "app", "public")
19 if err != nil {
20 t.Fatal(err)
21 }
22 token, err := s.CreatePushToken(repoID, uid, "full")
23 if err != nil {
24 t.Fatal(err)
25 }
26 got, err := s.PushTokenByHash(HashToken(token))
27 if err != nil || got != (PushToken{RepoID: repoID, UserID: uid, Scope: "full"}) {
28 t.Fatalf("lookup = %+v, %v", got, err)
29 }
30 if err := s.DeletePushToken(token); err != nil {
31 t.Fatal(err)
32 }
33 if _, err := s.PushTokenByHash(HashToken(token)); !errors.Is(err, ErrNotFound) {
34 t.Fatalf("after delete: %v", err)
35 }
36
37 // A token whose receive-pack never cleaned up is swept after a day.
38 stale, err := s.CreatePushToken(repoID, uid, "full")
39 if err != nil {
40 t.Fatal(err)
41 }
42 swept, err := s.Sweep(Retention{}, time.Now().Add(25*time.Hour))
43 if err != nil || swept["push_tokens"] != 1 {
44 t.Fatalf("sweep = %v, %v", swept, err)
45 }
46 if _, err := s.PushTokenByHash(HashToken(stale)); !errors.Is(err, ErrNotFound) {
47 t.Fatalf("after sweep: %v", err)
48 }
49}
internal/store/retention.go +1
@@ -51,6 +51,7 @@ func (s *Store) Sweep(r Retention, now time.Time) (Swept, error) {
5151 {"web_sessions", "expires_at <= ?"},
5252 {"login_tokens", "expires_at <= ?"},
5353 {"email_tokens", "expires_at <= ?"},
54 {"push_tokens", "expires_at <= ?"},
5455 }
5556 for _, e := range expired {
5657 n, err := s.deleteBy("DELETE FROM "+e.table+" WHERE "+e.where, fmtTime(now))